diff --git a/.gitignore b/.gitignore index 1dbcdc6a36..0c85fa8ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ qdrant_storage/ plans/ roo-cli-*.tar.gz* + +# Husky-generated hook shims are machine-local (created by husky/git-lfs install) +.husky/_/ diff --git a/apps/vscode-e2e/fixtures/modes.json b/apps/vscode-e2e/fixtures/modes.json index 39f4c62f35..f38634d0bf 100644 --- a/apps/vscode-e2e/fixtures/modes.json +++ b/apps/vscode-e2e/fixtures/modes.json @@ -13,6 +13,20 @@ } ] } + }, + { + "match": { + "userMessage": "Use the `switch_mode` tool to switch to debug mode." + }, + "response": { + "toolCalls": [ + { + "name": "switch_mode", + "arguments": "{\"mode_slug\":\"debug\",\"reason\":\"User requested to switch to debug mode.\"}", + "id": "call_modes_switch_002" + } + ] + } } ] } diff --git a/apps/vscode-e2e/src/fixtures/view-state.ts b/apps/vscode-e2e/src/fixtures/view-state.ts new file mode 100644 index 0000000000..4225e341aa --- /dev/null +++ b/apps/vscode-e2e/src/fixtures/view-state.ts @@ -0,0 +1,95 @@ +import type { ChatCompletionRequest, ChatMessage, LLMock } from "@copilotkit/aimock" + +const TASKS = ["A", "B", "C"] as const +const ROUNDS = 10 + +const MODE_SEQUENCES: Record<(typeof TASKS)[number], string[]> = { + A: ["ask", "debug", "architect", "orchestrator", "code", "ask", "debug", "architect", "orchestrator", "code"], + B: ["debug", "architect", "orchestrator", "code", "ask", "debug", "architect", "orchestrator", "code", "ask"], + C: ["architect", "orchestrator", "code", "ask", "debug", "architect", "orchestrator", "code", "ask", "debug"], +} + +const markerFor = (taskName: (typeof TASKS)[number]) => `FOLLOWUP_MODE_ISOLATION_${taskName}` +const answerFor = (taskName: (typeof TASKS)[number], round: number) => `${taskName} follow-up round ${round}` +const callIdFor = (taskName: (typeof TASKS)[number], round: number) => + `call_followup_mode_${taskName.toLowerCase()}_${String(round).padStart(2, "0")}` + +const lastToolResultContains = (req: ChatCompletionRequest, toolCallId: string, expected: string[]) => { + const messages = Array.isArray(req?.messages) ? req.messages : [] + const toolMessage = messages.filter((message: ChatMessage) => message?.role === "tool").at(-1) + const content = toolMessage?.content + + return ( + toolMessage?.tool_call_id === toolCallId && + typeof content === "string" && + expected.every((text) => content.includes(text)) + ) +} + +const followupToolCall = (taskName: (typeof TASKS)[number], round: number) => ({ + name: "ask_followup_question", + arguments: JSON.stringify({ + question: `Task ${taskName}: choose mode for round ${round}`, + follow_up: [ + { + text: answerFor(taskName, round), + mode: MODE_SEQUENCES[taskName][round - 1], + }, + ], + }), + id: callIdFor(taskName, round), +}) + +export const getFollowupModeIsolationPlan = () => + TASKS.map((taskName) => ({ + taskName, + marker: markerFor(taskName), + rounds: MODE_SEQUENCES[taskName].map((mode, index) => ({ + round: index + 1, + answer: answerFor(taskName, index + 1), + mode, + })), + })) + +export function addViewStateFixtures(mock: InstanceType) { + for (const taskName of TASKS) { + mock.addFixture({ + match: { + userMessage: markerFor(taskName), + }, + response: { + toolCalls: [followupToolCall(taskName, 1)], + }, + }) + + for (let round = 1; round < ROUNDS; round++) { + mock.addFixture({ + match: { + predicate: (req) => + lastToolResultContains(req, callIdFor(taskName, round), [answerFor(taskName, round)]), + }, + response: { + toolCalls: [followupToolCall(taskName, round + 1)], + }, + }) + } + + mock.addFixture({ + match: { + predicate: (req) => + lastToolResultContains(req, callIdFor(taskName, ROUNDS), [answerFor(taskName, ROUNDS)]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ + result: `Task ${taskName} completed ${ROUNDS} follow-up mode switches.`, + }), + id: `call_followup_mode_${taskName.toLowerCase()}_complete`, + }, + ], + }, + }) + } +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 8162f34068..715bfe0203 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -23,6 +23,8 @@ import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" import { createScenarioWorkspace, removeScenarioWorkspace } from "./restart/scenarioWorkspace" import { runRestartScenario } from "./restart/vscodeCoordinator" +import { toolResultContains } from "./fixtures/tool-result" +import { addViewStateFixtures } from "./fixtures/view-state" function getCliFlagValue(flag: string) { return process.argv.find((arg, index) => process.argv[index - 1] === flag) @@ -143,6 +145,41 @@ async function main() { addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) addDeepSeekV4Fixtures(mock) + addViewStateFixtures(mock) + + // Model-agnostic predicate fixtures for the view-state suite's post-switch + // turns. They coexist with the legacy model-scoped regex fixture below + // (shared response id call_modes_post_switch_001) so the modes suite keeps + // its OpenRouter-scoped match while view-state runs under any default model. + mock.addFixture({ + match: { + predicate: (req) => toolResultContains(req, "call_modes_switch_001", []), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "Switched to ❓ Ask mode as requested." }), + id: "call_modes_post_switch_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req) => toolResultContains(req, "call_modes_switch_002", []), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "Switched to 🪲 Debug mode as requested." }), + id: "call_modes_post_switch_002", + }, + ], + }, + }) // The modes test (switch_mode → ask) triggers a second API call whose last // user message starts with directly — no diff --git a/apps/vscode-e2e/src/suite/view-state.test.ts b/apps/vscode-e2e/src/suite/view-state.test.ts new file mode 100644 index 0000000000..06614b8d56 --- /dev/null +++ b/apps/vscode-e2e/src/suite/view-state.test.ts @@ -0,0 +1,293 @@ +import * as assert from "assert" + +import { isSecretStateKey, RooCodeEventName, type ClineMessage, type GlobalState } from "@roo-code/types" + +import { getFollowupModeIsolationPlan } from "../fixtures/view-state" +import { sleep, waitFor, waitUntilCompleted } from "./utils" +import { setDefaultSuiteTimeout } from "./test-utils" + +const findSecretStatePath = (value: unknown, path: string[] = []): string | undefined => { + if (!value || typeof value !== "object") { + return undefined + } + + for (const [key, nestedValue] of Object.entries(value)) { + const nextPath = [...path, key] + + if (isSecretStateKey(key)) { + return nextPath.join(".") + } + + const nestedSecretPath = findSecretStatePath(nestedValue, nextPath) + if (nestedSecretPath) { + return nestedSecretPath + } + } + + return undefined +} + +suite("Roo Code View State", function () { + setDefaultSuiteTimeout(this) + + teardown(async () => { + try { + await globalThis.api.cancelCurrentTask() + } catch { + // Task might not be running. + } + }) + + test("sidebar and tab panel keep mode isolated through the real ContextProxy singleton", async () => { + const modeEvents: Array<{ taskId: string; mode: string }> = [] + const completionHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "ask" && message.ask === "completion_result") { + void globalThis.api.approveTaskAsk(taskId) + } + } + + const modeHandler = (taskId: string, mode: string) => modeEvents.push({ taskId, mode }) + + globalThis.api.on(RooCodeEventName.TaskModeSwitched, modeHandler) + globalThis.api.on(RooCodeEventName.Message, completionHandler) + + try { + const sidebarTaskId = await globalThis.api.startNewTask({ + configuration: { + mode: "code", + alwaysAllowModeSwitch: true, + autoApprovalEnabled: true, + apiKey: "sidebar-secret-must-not-persist", + }, + text: "Use the `switch_mode` tool to switch to ask mode.", + }) + await waitUntilCompleted({ api: globalThis.api, taskId: sidebarTaskId }) + + const tabTaskId = await globalThis.api.startNewTask({ + configuration: { + mode: "code", + alwaysAllowModeSwitch: true, + autoApprovalEnabled: true, + apiKey: "tab-secret-must-not-persist", + }, + text: "Use the `switch_mode` tool to switch to debug mode.", + newTab: true, + }) + await waitUntilCompleted({ api: globalThis.api, taskId: tabTaskId }) + + // Each task's switch must be attributed to its own taskId only. + assert.deepStrictEqual( + modeEvents.filter((event) => event.taskId === sidebarTaskId).map((event) => event.mode), + ["ask"], + ) + assert.deepStrictEqual( + modeEvents.filter((event) => event.taskId === tabTaskId).map((event) => event.mode), + ["debug"], + ) + + // The tab panel's switch must not overwrite the sidebar's own state. + // api.getConfiguration() always reads the sidebar provider. + assert.strictEqual(globalThis.api.getConfiguration().mode, "ask") + + // Both per-view writes are awaited through the serialized view-state write queue + // before the tasks complete, but a just-resolved globalState write can momentarily + // lag a synchronous globalState.get in the extension host. Poll until both + // persisted selections are visible before asserting on them. + await waitFor( + () => { + const persisted = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + if (!persisted) { + return false + } + + const entries = Object.entries(persisted) + + return ( + entries.length >= 2 && + entries.some(([, entry]) => entry.mode === "ask") && + entries.some(([, entry]) => entry.mode === "debug") + ) + }, + { timeout: 15_000 }, + ) + + const viewStates = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + assert.ok(viewStates, "Expected persisted viewStates to exist") + + const persistedEntries = Object.entries(viewStates) + assert.ok(persistedEntries.length >= 2, "Expected at least sidebar and tab persisted view state entries") + assert.ok( + persistedEntries.some(([, entry]) => entry.mode === "ask"), + "Expected one persisted view state entry for the sidebar ask mode", + ) + assert.ok( + persistedEntries.some(([, entry]) => entry.mode === "debug"), + "Expected one persisted view state entry for the tab debug mode", + ) + + for (const [viewStateId, entry] of persistedEntries) { + const secretStatePath = findSecretStatePath(entry) + assert.strictEqual( + secretStatePath, + undefined, + `Persisted viewStates.${viewStateId} leaked secret state at ${secretStatePath}`, + ) + } + } finally { + globalThis.api.off(RooCodeEventName.TaskModeSwitched, modeHandler) + globalThis.api.off(RooCodeEventName.Message, completionHandler) + } + }) + test("three panels keep follow-up option mode switches isolated across ten staggered rounds", async () => { + const plan = getFollowupModeIsolationPlan() + const rounds = plan.reduce((max, taskPlan) => Math.max(max, taskPlan.rounds.length), 0) + const modeEvents: Array<{ taskId: string; mode: string }> = [] + const taskIds = new Map() + const pendingSuggestions = new Map() + const answeredSuggestions = new Set() + const suggestionKey = (taskId: string, answer: string) => `${taskId}:${answer}` + let releasedRounds = 0 + let roundInFlight = false + + const taskIdsInPlanOrder = () => + plan.map((taskPlan) => taskIds.get(taskPlan.taskName)).filter((taskId): taskId is string => !!taskId) + const modeCountForTask = (taskId: string) => modeEvents.filter((event) => event.taskId === taskId).length + + const maybeReleaseRound = () => { + if (roundInFlight || taskIds.size !== plan.length) { + return + } + + const taskIdsInOrder = taskIdsInPlanOrder() + if ( + taskIdsInOrder.length !== plan.length || + !taskIdsInOrder.every((taskId) => pendingSuggestions.has(taskId)) + ) { + return + } + + roundInFlight = true + releasedRounds++ + + for (const taskId of taskIdsInOrder) { + const suggestion = pendingSuggestions.get(taskId) + assert.ok(suggestion, `Expected pending suggestion for task ${taskId}`) + pendingSuggestions.delete(taskId) + answeredSuggestions.add(suggestionKey(taskId, suggestion.answer)) + void globalThis.api.selectTaskFollowupSuggestion({ taskId, ...suggestion }) + } + } + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "ask" && message.ask === "followup" && message.text) { + try { + const parsed = JSON.parse(message.text) as { suggest?: Array<{ answer: string; mode?: string }> } + const suggestion = parsed.suggest?.[0] + + if (suggestion && !answeredSuggestions.has(suggestionKey(taskId, suggestion.answer))) { + pendingSuggestions.set(taskId, suggestion) + maybeReleaseRound() + } + } catch { + // Ignore partial or malformed follow-up payloads. + } + } + + if (message.type === "ask" && message.ask === "completion_result") { + void globalThis.api.approveTaskAsk(taskId) + } + } + const modeHandler = (taskId: string, mode: string) => { + modeEvents.push({ taskId, mode }) + + if (roundInFlight && taskIdsInPlanOrder().every((id) => modeCountForTask(id) >= releasedRounds)) { + roundInFlight = false + maybeReleaseRound() + } + } + + globalThis.api.on(RooCodeEventName.Message, messageHandler) + globalThis.api.on(RooCodeEventName.TaskModeSwitched, modeHandler) + + try { + for (const [index, taskPlan] of plan.entries()) { + if (index > 0) { + await sleep(1_000) + } + + const taskId = await globalThis.api.startNewTask({ + configuration: { + mode: "code", + alwaysAllowModeSwitch: true, + autoApprovalEnabled: true, + apiKey: `followup-secret-${taskPlan.taskName}-must-not-persist`, + }, + text: taskPlan.marker, + newTab: true, + preserveOpenTabs: index > 0, + }) + taskIds.set(taskPlan.taskName, taskId) + maybeReleaseRound() + } + + await waitFor( + () => { + const expectedSwitches = plan.length * rounds + return modeEvents.length >= expectedSwitches + }, + { timeout: 30_000 }, + ).catch((error) => { + const counts = plan.map((taskPlan) => { + const taskId = taskIds.get(taskPlan.taskName) + return `${taskPlan.taskName}:${taskId ? modeCountForTask(taskId) : 0}` + }) + throw new Error( + `Timed out after ${releasedRounds} coordinated rounds; mode event counts: ${counts.join(", ")}; pending suggestions: ${pendingSuggestions.size}. ${error instanceof Error ? error.message : String(error)}`, + ) + }) + + for (let roundIndex = 0; roundIndex < rounds; roundIndex++) { + const actualRoundModes = plan.map((taskPlan) => { + const taskId = taskIds.get(taskPlan.taskName) + assert.ok(taskId, `Expected task id for task ${taskPlan.taskName}`) + return modeEvents.filter((event) => event.taskId === taskId).map((event) => event.mode)[roundIndex] + }) + const expectedRoundModes = plan.map((taskPlan) => { + const round = taskPlan.rounds[roundIndex] + assert.ok(round, `Expected round ${roundIndex + 1} for task ${taskPlan.taskName}`) + return round.mode + }) + + assert.deepStrictEqual( + actualRoundModes, + expectedRoundModes, + `Round ${roundIndex + 1} should count only after all three tasks switch once`, + ) + } + + for (const taskPlan of plan) { + const taskId = taskIds.get(taskPlan.taskName) + assert.ok(taskId, `Expected task id for task ${taskPlan.taskName}`) + assert.deepStrictEqual( + modeEvents.filter((event) => event.taskId === taskId).map((event) => event.mode), + taskPlan.rounds.map((round) => round.mode), + ) + } + + const viewStates = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + assert.ok(viewStates, "Expected persisted viewStates to exist") + + for (const [viewStateId, entry] of Object.entries(viewStates)) { + const secretStatePath = findSecretStatePath(entry) + assert.strictEqual( + secretStatePath, + undefined, + `Persisted viewStates.${viewStateId} leaked secret state at ${secretStatePath}`, + ) + } + } finally { + globalThis.api.off(RooCodeEventName.Message, messageHandler) + globalThis.api.off(RooCodeEventName.TaskModeSwitched, modeHandler) + } + }) +}) diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 80808a274a..4efaa29026 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -2,7 +2,7 @@ import type { EventEmitter } from "events" import type { Socket } from "net" import type { RooCodeEvents } from "./events.js" -import type { RooCodeSettings } from "./global-settings.js" +import type { GlobalState, RooCodeSettings } from "./global-settings.js" import type { HistoryItem } from "./history.js" import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js" import type { IpcMessage, IpcServerEvents } from "./ipc.js" @@ -22,11 +22,13 @@ export interface RooCodeAPI extends EventEmitter { text, images, newTab, + preserveOpenTabs, }: { configuration?: RooCodeSettings text?: string images?: string[] newTab?: boolean + preserveOpenTabs?: boolean }): Promise /** * Resumes a task with the given ID. @@ -93,6 +95,15 @@ export interface RooCodeAPI extends EventEmitter { * confirming a completion result. No-ops if no task is active. */ approveCurrentAsk(): Promise + /** + * Programmatically approves the pending ask for a task by ID. Intended for use in tests only. + */ + approveTaskAsk(taskId: string): Promise + /** + * Simulates selecting a follow-up suggestion for a task by ID, including its optional mode switch. + * Intended for use in tests only. + */ + selectTaskFollowupSuggestion(options: { taskId: string; answer: string; mode?: string }): Promise /** * Returns true if the API is ready to use. */ @@ -107,6 +118,10 @@ export interface RooCodeAPI extends EventEmitter { * @param values An object containing key-value pairs to set. */ setConfiguration(values: RooCodeSettings): Promise + /** + * Returns a value from VS Code globalState. Intended for use in tests only. + */ + getGlobalState(key: K): GlobalState[K] /** * Returns a list of all configured profile names * @returns Array of profile names diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 1e57533b1f..3b78e6eef3 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -117,6 +117,15 @@ export const DEFAULT_PER_WRITE_CHECKPOINTS = true */ export const DEFAULT_CHANGE_CARD_DETAIL: ChangeCardDetail = "summary" +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -125,6 +134,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b1bfe084f2..0948897384 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -674,6 +674,7 @@ export interface WebviewMessage { | "openRulesDirectory" | "themeFixtureProbeResponse" text?: string + viewStateId?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 52bb209922..92f57492c5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -56,6 +56,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -124,6 +125,14 @@ import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +type PersistedViewState = NonNullable[string] + +/** + * Values that can be held in a view-local state buffer (in-memory) and, for the + * non-secret subset, persisted durably per stable view id. + */ +type ViewLocalStateValues = Partial & Partial + /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -179,6 +188,9 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 + private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -303,6 +315,25 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "aug-2026-v3.80.0-allowlists-models-reliability" // v3.80.0 file allowlists, models, and workflow reliability @@ -317,14 +348,17 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -355,6 +389,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -505,6 +542,248 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy. + * When fresh is set, the map is read directly from globalState (bypassing the + * ContextProxy cache) so serialized writes never observe a stale in-memory value. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return {} + } + + return { ...viewStates } + } + + /** + * Persists this view's non-secret selections through the serialized write queue. + * The write re-reads the map fresh and merges into the existing entry, removing the + * entry entirely when nothing persistable remains, so concurrent views cannot clobber it. + * The entry is keyed by the view id active when the change was made. Writes captured + * while the provider still holds its temporary (pre-launch) id persist under that id + * and are re-keyed to the stable view id when the webview registers one, so a change + * that lands before the launch message stays durable instead of being lost. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const current = states[viewStateId] ?? {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Re-points persisted view pins that reference a removed profile so views do not + * rehydrate a missing profile name after a reload. Runs through the serialized + * write queue like every other viewStates mutation. + */ + private async repointPersistedViewStates( + removedProfileName: string, + replacementProfileName: string, + ): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + let changed = false + + for (const [viewId, entry] of Object.entries(states)) { + if (entry?.currentApiConfigName !== removedProfileName) { + continue + } + + changed = true + const { currentApiConfigName: _removed, ...rest } = entry + + if (rest.mode) { + states[viewId] = { ...rest, currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } else { + states[viewId] = { currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } + } + + if (changed) { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Keeps only the most recently updated entries of the persisted view states map, + * bounded by MAX_PERSISTED_VIEW_STATES so the global key cannot grow unboundedly. + */ + private prunePersistedViewStates(states: Record): Record { + return Object.fromEntries( + Object.entries(states) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + + /** + * Re-keys this provider's temporary pre-launch viewStates entry to the newly + * registered stable id so pre-launch writes become durable under the stable key + * instead of orphaning under a session-local temporary id. Only the provider's own + * temporary id is eligible: an entry under a previously registered stable id belongs + * to that webview's storage and is left alone. When the stable entry already exists + * it wins and the temporary entry is dropped, because temporary ids are session + * counters that can collide across window reloads. Runs through the serialized write + * queue like every other viewStates mutation. + */ + private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { + const previousViewStateId = this.viewId + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Registers this provider's stable view identifier and loads any persisted selections it owns. + * The identifier is sanitized so it remains a safe object key in the shared viewStates map. + */ + public async setViewStateId(viewStateId: string | undefined): Promise { + const normalizedViewStateId = viewStateId?.trim() + + if (!normalizedViewStateId || normalizedViewStateId === this.viewStateId) { + return + } + + this.viewStateId = normalizedViewStateId.replace(/[^A-Za-z0-9_-]/g, "_") + + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) + + await this.loadViewState() + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + */ + private async loadViewState(): Promise { + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + if (persisted?.mode) { + loadedState.mode = persisted.mode as Mode + } + + if (persisted?.currentApiConfigName) { + loadedState.currentApiConfigName = persisted.currentApiConfigName + + try { + const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ + name: persisted.currentApiConfigName, + }) + loadedState.apiConfiguration = apiConfiguration as ProviderSettings + } catch (error) { + this.log( + `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (this.viewStateId !== loadedForViewId) { + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return + } + + this.viewLocalState = loadedState + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Saves a single view-local state value. The in-memory buffer is always updated; the + * non-secret subset (mode, currentApiConfigName) is persisted durably under the view + * id active when the change was made, re-keyed to the stable id on registration. + */ + public async saveViewState( + key: K, + value: ViewLocalStateValues[K] | undefined, + ): Promise { + await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -1202,7 +1481,10 @@ export class ClineProvider historyItem.mode = defaultModeSlug } - await this.updateGlobalState("mode", historyItem.mode) + // Persist the restored mode through this view's per-view pin rather than the + // shared global: a global write would leak the restored mode into other views + // in parallel mode, and a buffer-only write would be lost after a reload. + await this.saveViewState("mode", historyItem.mode) // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, @@ -1659,6 +1941,9 @@ export class ClineProvider * @param newMode The mode to switch to * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. + * A task that is not this view's focused task only receives the task-scoped effects + * (history entry + in-memory mode): the view's durable mode, the ModeChanged + * broadcast, and profile activation keep applying to the focused task's selection. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { return this.enqueueProviderProfileMutation((signal) => @@ -1701,13 +1986,22 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + // A mode switch requested for a task that is not this view's focused task applies + // only to that task (history entry + in-memory mode): pinning the view's durable + // mode, broadcasting ModeChanged, or activating a profile on behalf of a + // background task would clobber the focused task's selection. + const viewScopedSwitch = task === undefined || task === null || this.getCurrentTask() === task - this.emit(RooCodeEventName.ModeChanged, newMode) + if (viewScopedSwitch) { + await this.saveViewState("mode", newMode) + this.emit(RooCodeEventName.ModeChanged, newMode) + } // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { + // Keep the original post semantics: an explicit null target (pending child) + // posts its own state. if (targetTask !== null) { await this.postStateToWebview() } @@ -1715,6 +2009,9 @@ export class ClineProvider } if (signal?.aborted) return + if (!viewScopedSwitch) { + return + } // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) @@ -1851,13 +2148,21 @@ export class ClineProvider // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), this.updateGlobalState("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), ]) + await this._saveViewLocalStateFromMutation({ + listApiConfigMeta, + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1865,7 +2170,9 @@ export class ClineProvider // Keep the current task's sticky provider profile in sync with the newly-activated profile. await this.persistStickyProviderProfileToCurrentTask(name) } else { - await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await this.updateGlobalState("listApiConfigMeta", listApiConfigMeta) + this._updateViewLocalStateFromMutation({ listApiConfigMeta }) } await this.postStateToWebview() @@ -1901,6 +2208,22 @@ export class ClineProvider listApiConfigMeta: entries, }) + // Sync this view's in-memory buffer only when it was pointing at the deleted + // profile (or had no pin of its own): an unrelated pin must survive the deletion. + if ( + this.viewLocalState.currentApiConfigName === undefined || + this.viewLocalState.currentApiConfigName === profileToDelete.name + ) { + this._updateViewLocalStateFromMutation({ + currentApiConfigName: profileToActivate, + listApiConfigMeta: entries, + }) + } + + // Re-point any persisted view pin that referenced the deleted profile so views + // do not rehydrate a missing profile name after a reload. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + await this.postStateToWebview() } @@ -1968,11 +2291,19 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), this.contextProxy.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), ]) + + await this._saveViewLocalStateFromMutation({ + listApiConfigMeta, + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) } const { mode } = await this.getState() @@ -2858,12 +3189,18 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // Merge viewLocalState on top of global state so a provider can serve + // state values scoped to its own view while preserving ContextProxy defaults. + const mergedStateValues = { ...stateValues, ...this.viewLocalState } + const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. + // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = - stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) - ? stateValues.apiProvider + mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) + ? (mergedStateValues.apiProvider as ProviderName) : "anthropic" // Build the apiConfiguration object combining state values and secrets. @@ -2925,121 +3262,124 @@ export class ClineProvider // Return the same structure as before. return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - allowedReadFiles: stateValues.allowedReadFiles ?? [], - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - allowedWriteFiles: stateValues.allowedWriteFiles ?? [], - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + apiConfiguration: { + ...providerSettings, + ...mergedStateValues.apiConfiguration, + }, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - perWriteCheckpoints: stateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, - changeCardDetail: stateValues.changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + perWriteCheckpoints: mergedStateValues.perWriteCheckpoints ?? DEFAULT_PER_WRITE_CHECKPOINTS, + changeCardDetail: mergedStateValues.changeCardDetail ?? DEFAULT_CHANGE_CARD_DETAIL, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } @@ -3142,6 +3482,7 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) + await this._saveViewLocalStateFromMutation({ [key]: value }) } public getValue(key: K) { @@ -3149,11 +3490,103 @@ export class ClineProvider } public getValues() { - return this.contextProxy.getValues() + return { ...this.contextProxy.getValues(), ...this.viewLocalState } } public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) + await this._saveViewLocalStateFromMutation(values) + } + + /** + * Persists the view-local subset of a ContextProxy mutation, then updates the in-memory + * viewLocalState buffer. Persistence is awaited first so a failed durable write cannot + * leave the local cache ahead of the persisted state. + */ + private async _saveViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + await this._persistViewLocalStateFromMutation(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial & Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val + } + } + + if ("apiConfiguration" in values) { + const val = values.apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } else if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + const providerSettingsUpdate = PROVIDER_SETTINGS_KEYS.reduce((acc, key) => { + if (key in values) { + return { ...acc, [key]: values[key as keyof RooCodeSettings] } + } + + return acc + }, {} as ProviderSettings) + + this.viewLocalState.apiConfiguration = + "apiProvider" in providerSettingsUpdate + ? providerSettingsUpdate + : { + ...(this.viewLocalState.apiConfiguration ?? {}), + ...providerSettingsUpdate, + } + } + } + + /** + * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) + * into the registered viewStates map for this view. + */ + private async _persistViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + const persistedValues: Partial = {} + + if ("mode" in values) { + persistedValues.mode = values.mode as PersistedViewState["mode"] + } + + if ("currentApiConfigName" in values) { + persistedValues.currentApiConfigName = values.currentApiConfigName + } + + if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { + await this.savePersistedViewState(persistedValues) + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} } // dev @@ -3182,6 +3615,14 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + + // Clear this view's persisted entry too, so the reset selections are not + // re-applied from the durable viewStates pin after a reload. + await this.clearPersistedViewState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts new file mode 100644 index 0000000000..21e608fe1d --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,1754 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.parallelMode.spec.ts + +import * as vscode from "vscode" + +import { + type ExtensionMessage, + type ExtensionState, + type ProviderSettingsEntry, + type ProviderSettingsWithId, + type RooCodeSettings, + RooCodeEventName, + providerIdentifiers, +} from "@roo-code/types" + +import { defaultModeSlug } from "../../../shared/modes" +import { ContextProxy } from "../../config/ContextProxy" +import { ClineProvider } from "../ClineProvider" +import { TelemetryService } from "@roo-code/telemetry" + +import type { Task } from "../../task/Task" + +// Mock p-wait-for +vi.mock("p-wait-for", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock fs/promises +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + const mocked = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + } + + return { + ...actual, + ...mocked, + default: { + ...actual, + ...mocked, + }, + } +}) + +// Mock axios +vi.mock("axios", () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), + }, + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), +})) + +// Mock safeWriteJson +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + +// Mock path utils +vi.mock("../../../utils/path", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getWorkspacePath: vi.fn().mockReturnValue(""), + } +}) + +// Mock storage utils +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +// Mock MCP types +vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ + CallToolResultSchema: {}, + ListResourcesResultSchema: {}, + ListResourceTemplatesResultSchema: {}, + ListToolsResultSchema: {}, + ReadResourceResultSchema: {}, + ErrorCode: { + InvalidRequest: "InvalidRequest", + MethodNotFound: "MethodNotFound", + InternalError: "InternalError", + }, + McpError: class McpError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.name = "McpError" + this.code = code + } + }, +})) + +// Mock delay +vi.mock("delay", () => { + const delayFn = (_ms: number) => Promise.resolve() + delayFn.createDelay = () => delayFn + delayFn.reject = () => Promise.reject(new Error("Delay rejected")) + delayFn.range = () => Promise.resolve() + return { default: delayFn } +}) + +// Mock MCP client +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + __esModule: true, + Client: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + callTool: vi.fn().mockResolvedValue({ content: [] }), + } + }), +})) + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ + __esModule: true, + StdioClientTransport: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +const { onDidChangeConfigurationMock } = vi.hoisted(() => { + const onDidChangeConfigurationMock = vi.fn( + (handler: (e: { affectsConfiguration: (key: string) => boolean }) => void) => { + const disposable = { + dispose: vi.fn(), + } + const checkedKeys: string[] = [] + void handler({ + affectsConfiguration: (key: string) => { + checkedKeys.push(key) + return false + }, + }) + + if (checkedKeys.includes("workbench.colorTheme")) { + onDidChangeConfigurationMock.mock.calls.pop() + } + + return disposable + }, + ) + + return { onDidChangeConfigurationMock } +}) + +// Mock vscode +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + EventEmitter: vi.fn().mockImplementation(function () { + return { + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + } + }), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + Range: class Range { + constructor( + readonly startLine: number, + readonly startCharacter: number, + readonly endLine: number, + readonly endCharacter: number, + ) {} + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + getWorkspaceFolder: vi.fn(), + createFileSystemWatcher: vi.fn().mockReturnValue({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + onDidChangeConfiguration: onDidChangeConfigurationMock, + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + activeTextEditor: undefined, + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + tabGroups: { + onDidChangeTabs: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +// Mock TTS utils +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +// Mock API +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +// Mock system prompt +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +// Mock WorkspaceTracker - simple mock that works (same pattern as sticky-mode.spec.ts) +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(function () { + return { + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + } + }), +})) +// Mock ContextProxy for viewLocalState tests +vi.mock("../../config/ContextProxy", () => { + const defaultState = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + class MockContextProxy { + public globalStorageUri: { fsPath: string } + public extensionUri: { fsPath: string } + public extensionMode = 1 + /** + * Mirrors the real ContextProxy state cache: seeded from the store in the + * constructor (like initialize()), then mutated only through setValue, so + * getValue can return a stale value that diverges from direct store writes. + */ + private stateCache: Record = {} + + constructor(public context: vscode.ExtensionContext) { + this.globalStorageUri = context.globalStorageUri ?? { fsPath: "/test/storage/path" } + this.extensionUri = context.extensionUri ?? { fsPath: "/test/path" } + + for (const key of context.globalState.keys()) { + const value = context.globalState.get(key) + if (value !== undefined) { + this.stateCache[key] = value + } + } + } + + getValues = vi.fn().mockImplementation(() => ({ + ...defaultState, + mode: this.stateCache.mode ?? defaultState.mode, + currentApiConfigName: this.stateCache.currentApiConfigName ?? defaultState.currentApiConfigName, + apiConfiguration: this.stateCache.apiConfiguration ?? defaultState.apiConfiguration, + customModePrompts: this.stateCache.customModePrompts ?? defaultState.customModePrompts, + modeApiConfigs: this.stateCache.modeApiConfigs ?? defaultState.modeApiConfigs, + listApiConfigMeta: this.stateCache.listApiConfigMeta ?? defaultState.listApiConfigMeta, + pinnedApiConfigs: this.stateCache.pinnedApiConfigs ?? defaultState.pinnedApiConfigs, + })) + getValue = vi.fn().mockImplementation((key: string) => this.stateCache[key]) + getProviderSettings = vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }) + setValue = vi.fn().mockImplementation((key: string, value: unknown) => { + if (value === undefined || value === null) { + delete this.stateCache[key] + } else { + this.stateCache[key] = value + } + return this.context.globalState.update(key, value) ?? Promise.resolve() + }) + setValues = vi.fn().mockImplementation((values: Record) => { + return Promise.all(Object.entries(values).map(([key, value]) => this.setValue(key, value))).then( + () => undefined, + ) + }) + setProviderSettings = vi + .fn() + .mockImplementation((settings: Record) => this.setValues(settings)) + resetAllState = vi.fn().mockImplementation(() => { + const keys = this.context.globalState.keys() + return Promise.all(keys.map((key: string) => this.setValue(key, undefined))).then(() => undefined) + }) + } + return { ContextProxy: MockContextProxy } +}) + +// Mock Task +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(function (options?: { historyItem?: { id?: string } }) { + return { + api: undefined, + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + taskId: options?.historyItem?.id || "test-task-id", + emit: vi.fn(), + } + }), +})) + +// Mock extract-text +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockImplementation(async (_filePath: string) => { + const content = "const x = 1;\nconst y = 2;\nconst z = 3;" + const lines = content.split("\n") + return lines.map((line, index) => `${index + 1} | ${line}`).join("\n") + }), +})) + +// Mock model cache +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), + getModelsFromCache: vi.fn().mockReturnValue(undefined), +})) + +// Mock cloud service +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockResolvedValue([]), + getUserInfo: vi.fn().mockReturnValue(null), + getOrganizationSettings: vi.fn().mockReturnValue(null), + off: vi.fn(), + } + }, + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +// Mock modes +vi.mock("../../../shared/modes", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + modes: [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "debugger", + name: "Debugger Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are a helpful assistant", + groups: ["read"], + }, + ], + getModeBySlug: vi.fn().mockImplementation((slug: string) => { + return actual.modes?.find((m) => m.slug === slug) ?? null + }), + defaultModeSlug: "code", + } +}) + +// Mock custom instructions +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), +})) + +// Mock zoo-code-auth +vi.mock("../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), + getCachedZooCodeToken: vi.fn(), + handleAuthCallback: vi.fn(), + setZooCodeUserInfo: vi.fn(), + disconnectZooCode: vi.fn(), +})) + +// Mock diff strategy +vi.mock("../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(function () { + return { + getToolDescription: () => "test", + getName: () => "test-strategy", + applyDiff: vi.fn(), + } + }), +})) + +// Mock Terminal +vi.mock("../../../integrations/terminal/Terminal", () => ({ + Terminal: { + defaultShellIntegrationTimeout: 10000, + setShellIntegrationTimeout: vi.fn(), + setShellIntegrationDisabled: vi.fn(), + setCommandDelay: vi.fn(), + setTerminalZshClearEolMark: vi.fn(), + setTerminalZshOhMy: vi.fn(), + setTerminalZshP10k: vi.fn(), + setPowershellCounter: vi.fn(), + setTerminalZdotdir: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +// Mock McpHub and McpServerManager +vi.mock("../../services/mcp/McpHub", () => ({ + McpHub: vi.fn().mockImplementation(function () { + return { + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + } + }), +})) + +vi.mock("../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue({ + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + }), + unregisterProvider: vi.fn(), + }, +})) + +// Mock SkillsManager +vi.mock("../../services/skills/SkillsManager", () => ({ + SkillsManager: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), +})) + +// Mock MarketplaceManager +vi.mock("../../services/marketplace", () => ({ + MarketplaceManager: vi.fn().mockImplementation(function () { + return { + cleanup: vi.fn(), + } + }), +})) + +// Mock ProviderSettingsManager +vi.mock("../../config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: vi.fn().mockImplementation(function () { + return { + saveConfig: vi.fn().mockResolvedValue("test-id"), + listConfig: vi.fn().mockResolvedValue([]), + getProfile: vi.fn().mockResolvedValue({}), + activateProfile: vi.fn().mockImplementation(async (args: { name?: string; id?: string }) => ({ + name: args.name ?? "default", + id: args.id ?? "test-id", + apiProvider: providerIdentifiers.anthropic, + })), + setModeConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + resetAllConfigs: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +// Mock CustomModesManager +vi.mock("../../config/CustomModesManager", () => ({ + CustomModesManager: vi.fn().mockImplementation(function () { + return { + updateCustomMode: vi.fn().mockResolvedValue(undefined), + getCustomModes: vi.fn().mockResolvedValue([]), + resetCustomModes: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), +})) + +// Mock task persistence +vi.mock("../../task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) + +vi.mock("../../task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getAll: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + migrateFromGlobalState: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), + assertValidTransition: vi.fn(), +})) + +// Mock RateLimitClock +vi.mock("../../task/RateLimitClock", () => ({ + createRateLimitClock: vi.fn().mockReturnValue({ + isRateLimited: vi.fn().mockReturnValue(false), + resetTimer: vi.fn(), + }), +})) + +beforeAll(() => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + +/** + * ClineProvider - Parallel Mode Support Tests + * + * These tests verify that the view-local state isolation feature works correctly, + * allowing multiple ClineProvider instances (e.g., in parallel tabs) to maintain + * independent mode, API configuration, and other view-specific settings. + */ +describe("ClineProvider - Parallel Mode Support", () => { + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => { + return globalState[key] + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => { + return Object.keys(globalState) + }), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => { + return secrets[key] + }), + store: vi.fn().mockImplementation((key: string, value: string) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + } as vscode.Uri, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + }) + + const createMockWebviewView = (postMessage = vi.fn()) => + ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + }) as unknown as vscode.WebviewView + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider2.saveViewState("mode", "debugger") + await provider1.saveViewState("mode", "architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + const saveViewState1 = provider1.saveViewState.bind(provider1) + const saveViewState2 = provider2.saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await provider["setViewStateId"]("stable-sidebar-view") + + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "my-profile") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const testApiConfig = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("apiConfiguration", testApiConfig) + + expect(provider["viewLocalState"].apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("mode", "architect") + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("currentApiConfigName", "my-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + + await provider.saveViewState("currentApiConfigName", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "currentApiConfigName")).toBe(false) + + await provider.dispose() + }) + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + setViewStateId: (viewStateId: string) => Promise + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + viewLocalState: Partial + } + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await providerAccess.setViewStateId("stable-sidebar-view") + + await expect(providerAccess.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(providerAccess.viewLocalState).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("loadViewState", () => { + it("should keep viewLocalState empty when no stable per-view values exist", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await vi.waitFor(() => { + expect(provider["viewLocalState"]).toEqual({}) + }) + + const state = await provider.getState() + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should restore mode and currentApiConfigName from hydrated viewStates after extension reload", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const stableViewId = "stable-sidebar-view" + + await provider.contextProxy.setValue("viewStates", { + [stableViewId]: { mode: "architect", currentApiConfigName: "new-profile", updatedAt: 123 }, + }) + + await provider["setViewStateId"](stableViewId) + + const state = await provider.getState() + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("new-profile") + + await provider.dispose() + }) + + it("should resolve API configuration from the persisted profile selection", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const stableViewId = "stable-editor-tab-a" + const getProfileSpy = vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "profile-a", + id: "profile-a-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/anthropic/claude-sonnet-4", + }) + + await provider.contextProxy.setValue("viewStates", { + [stableViewId]: { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 123 }, + }) + await provider.contextProxy.setValue("mode", "debugger") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + // "apiConfiguration" is a GlobalState key rather than a RooCodeSettings key, + // so the proxy's generic key type is widened to reach the mock's cache path. + await provider.contextProxy.setValue("apiConfiguration" as unknown as keyof RooCodeSettings, { + apiProvider: providerIdentifiers.anthropic, + }) + + await provider["setViewStateId"](stableViewId) + const state = await provider.getState() + + expect(getProfileSpy).toHaveBeenCalledWith({ name: "profile-a" }) + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("profile-a") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/anthropic/claude-sonnet-4", + }) + + await provider.dispose() + }) + + it("should not throw when a persisted profile selection cannot be resolved", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const stableViewId = "stable-editor-tab-a" + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue(new Error("missing profile")) + + await provider.contextProxy.setValue("viewStates", { + [stableViewId]: { mode: "architect", currentApiConfigName: "deleted-profile", updatedAt: 123 }, + }) + + await expect(provider["setViewStateId"](stableViewId)).resolves.toBeUndefined() + const state = await provider.getState() + + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("deleted-profile") + expect(state.apiConfiguration.apiProvider).toBe("anthropic") + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider, "log") + + provider["viewLocalState"] = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValue").mockImplementation(() => { + throw new Error("load failed") + }) + + await provider["loadViewState"]() + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-5"]).toBeDefined() + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await provider.saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + expect(state.language).toBeDefined() + expect(state.customModes).toBeDefined() + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "local-key", + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("openrouter") + expect(state.apiConfiguration.openRouterApiKey).toBe("local-key") + + await provider.dispose() + }) + + it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + } + const contextProxyAccess = provider.contextProxy as unknown as { + setValues: (values: Partial) => Promise + } + await contextProxyAccess.setValues({ + mode: "debugger", + currentApiConfigName: "shared-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }, + customModePrompts: { code: { roleDefinition: "shared" } }, + }) + + await providerAccess.saveViewState("mode", "architect") + await providerAccess.saveViewState("currentApiConfigName", "view-profile") + await providerAccess.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + + const values = provider.getValues() + + expect(values.mode).toBe("architect") + expect(values.currentApiConfigName).toBe("view-profile") + expect(values.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + expect(values.customModePrompts).toEqual({ code: { roleDefinition: "shared" } }) + + await provider.dispose() + }) + + it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + + await provider.setValues({ + apiProvider: providerIdentifiers.bedrock, + awsUseApiKey: true, + awsApiKey: "mock-key", + awsRegion: "us-east-1", + apiModelId: "anthropic.claude-opus-4-8-20261215-v1:0", + awsBedrockEndpoint: "http://127.0.0.1:4567", + awsBedrockEndpointEnabled: true, + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + expect(provider["viewLocalState"].apiConfiguration?.apiProvider).toBe("bedrock") + expect(provider["viewLocalState"].apiConfiguration).not.toHaveProperty("openRouterModelId") + + await provider.dispose() + }) + + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" }) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("tab panel/with.dots and spaces") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("view-a") + const firstSave = provider.saveViewState("mode", "architect") + await firstWriteStarted + await provider["setViewStateId"]("view-b") + releaseFirstWrite() + await firstSave + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("view-b") + + await provider.dispose() + }) + + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + }) + + it("should read viewStates fresh from storage so out-of-proxy writes are not clobbered", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider["setViewStateId"]("view-a") + await provider.saveViewState("mode", "architect") + + // Simulate a concurrent writer (another view's provider) updating the shared + // map directly in storage, bypassing this proxy's cache. + const stored = (await mockContext.globalState.get>("viewStates")) ?? {} + mockContext.globalState.update("viewStates", { + ...stored, + "view-b": { mode: "debug", updatedAt: 1 }, + }) + + await provider.saveViewState("mode", "code") + + // The serialized write must have merged on top of the fresh storage value, not + // on top of this proxy's stale cache. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "code" }, + "view-b": { mode: "debug" }, + }) + + await provider.dispose() + }) + + it("should re-key durable viewStates entries from the temporary pre-launch view id", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // A change made before the stable id is registered persists under the + // temporary id so it is not lost; registration re-keys it to the stable id. + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + [provider.viewId]: { mode: "architect" }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "debugger") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop the temporary viewStates entry when a stable entry already exists", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // A stable entry already exists (e.g. a previous session persisted under a + // colliding temporary id); it must win over the temporary entry. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("debugger") + + await provider.dispose() + }) + + it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + viewId: string + viewLocalState: { mode?: string; currentApiConfigName?: string } + loadViewState(): Promise + setViewStateId(id: string): Promise + } + + // Seed persisted entries under both ids through the proxy so the loads + // observe them via the cached read path: the temporary entry holds a + // pre-registration selection, the stable entry the post-registration one. + await provider.contextProxy.setValue("viewStates", { + [providerAccess.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, + }) + + // Hang the temporary entry's profile lookup so that load is still in flight + // when the stable id is registered. + let releaseGhost!: () => void + const ghostLoad = new Promise((resolve) => { + releaseGhost = resolve + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( + ghostLoad.then( + () => + ({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }) as unknown as Awaited>, + ), + ) + + const staleLoad = providerAccess.loadViewState() + + // Register the stable id without awaiting its load: the re-key drops the + // temporary entry (the stable one already exists) and the registration's own + // load settles on the stable entry immediately. + const register = providerAccess.setViewStateId("stable-sidebar-view") + await register + + releaseGhost() + await staleLoad + + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(providerAccess.viewLocalState).toEqual({ mode: "debug" }) + + await provider.dispose() + }) + }) + + describe("profile mutations", () => { + it("should synchronize viewLocalState when activateProviderProfile mutates ContextProxy", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValueOnce({ + name: "new-profile", + id: "new-profile-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/new-model", + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([ + { id: "new-profile-id", name: "new-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + provider["viewLocalState"] = { + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + + await provider.activateProviderProfile({ name: "new-profile" }) + const state = await provider.getState() + + expect(saveViewStateSpy).not.toHaveBeenCalled() + expect(state.currentApiConfigName).toBe("new-profile") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/new-model", + }) + + await provider.dispose() + }) + + it("should synchronize viewLocalState when upsertProviderProfile activates a saved profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "test-id", name: "saved-profile", apiProvider: providerIdentifiers.bedrock }, + ]) + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + provider["viewLocalState"] = { + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + + await provider.upsertProviderProfile("saved-profile", { + apiProvider: providerIdentifiers.bedrock, + awsRegion: "us-east-1", + }) + const state = await provider.getState() + + expect(saveViewStateSpy).not.toHaveBeenCalled() + expect(state.currentApiConfigName).toBe("saved-profile") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.bedrock, + awsRegion: "us-east-1", + }) + + await provider.dispose() + }) + + it("should synchronize viewLocalState when deleteProviderProfile selects a replacement profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider.contextProxy.setValue("currentApiConfigName", "deleted-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "deleted-id", name: "deleted-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + provider["viewLocalState"] = { + currentApiConfigName: "deleted-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: providerIdentifiers.anthropic, + }) + const state = await provider.getState() + + expect(state.currentApiConfigName).toBe("replacement-profile") + expect(state.listApiConfigMeta).toEqual([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + + await provider.dispose() + }) + + it("should re-point persisted view pins that referenced a deleted profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Two views have durable pins; one pins the profile about to be deleted. + mockContext.globalState.update("viewStates", { + "view-keeps": { mode: "code", currentApiConfigName: "keeper-profile", updatedAt: 1 }, + "view-deleted": { mode: "architect", currentApiConfigName: "doomed-profile", updatedAt: 2 }, + }) + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // The affected pin is re-pointed to the replacement profile; the unrelated pin survives. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-keeps": { mode: "code", currentApiConfigName: "keeper-profile" }, + "view-deleted": { mode: "architect", currentApiConfigName: "keeper-profile" }, + }) + + await provider.dispose() + }) + it("should clear viewLocalState when resetState resets ContextProxy", async () => { + vi.mocked(vscode.window.showInformationMessage).mockImplementationOnce( + async (_message: string, _options: unknown, ...items: vscode.MessageItem[]) => items[0], + ) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + provider["viewLocalState"] = { + mode: "architect", + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + } + + await provider.resetState() + + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + }) + + describe("provider profile activation", () => { + it("should sync view-local apiConfiguration when activating an upserted profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1", + }) + + const providerSettings = { + apiProvider: providerIdentifiers.zai, + zaiApiKey: "mock-key", + zaiApiLine: "international_api" as const, + apiModelId: "glm-5.1", + } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("zai-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "default", id: "zai-profile-id", apiProvider: providerIdentifiers.zai }, + ]) + + await provider.upsertProviderProfile("default", providerSettings, true) + + const state = await provider.getState() + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration).toMatchObject(providerSettings) + expect(state.apiConfiguration.apiProvider).toBe("zai") + expect(state.apiConfiguration).not.toHaveProperty("openRouterModelId") + expect(provider["viewLocalState"].apiConfiguration).toMatchObject(providerSettings) + + await provider.dispose() + }) + }) + + describe("handleModeSwitch integration", () => { + it("should update viewLocalState.mode when handleModeSwitch is called", async () => { + const postMessage = vi.fn() + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.resolveWebviewView(createMockWebviewView(postMessage)) + + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + + await provider.handleModeSwitch("architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(saveViewStateSpy).toHaveBeenCalledWith("mode", "architect") + + await provider.dispose() + }) + + it("should post state and skip mode config lookup when API config locking is enabled", async () => { + const postMessage = vi.fn() + mockContext.workspaceState.get = vi.fn().mockImplementation((key: string, fallback?: unknown) => { + return key === "lockApiConfigAcrossModes" ? true : fallback + }) + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const getModeConfigIdSpy = vi.spyOn(provider.providerSettingsManager, "getModeConfigId") + + await provider.resolveWebviewView(createMockWebviewView(postMessage)) + postMessage.mockClear() + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(postMessage).toHaveBeenCalled() + + await provider.dispose() + }) + + it("should activate configured mode profile when switching modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("profile-id") + const profileEntry: ProviderSettingsEntry = { + id: "profile-id", + name: "mode-profile", + apiProvider: providerIdentifiers.openrouter, + } + const profileSettings: ProviderSettingsWithId & { name: string } = { + id: "profile-id", + name: "mode-profile", + apiProvider: providerIdentifiers.openrouter, + } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([profileEntry]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce(profileSettings) + const activateProfileSpy = vi + .spyOn(provider.providerSettingsManager, "activateProfile") + .mockResolvedValueOnce(profileSettings) + + await provider.handleModeSwitch("architect") + + expect(activateProfileSpy).toHaveBeenCalledWith({ name: "mode-profile" }) + + await provider.dispose() + }) + + it("should leave current configuration unchanged for empty mode profiles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("empty-profile-id") + const profileEntry: ProviderSettingsEntry = { id: "empty-profile-id", name: "empty-profile" } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([profileEntry]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce({ + id: "empty-profile-id", + name: "empty-profile", + }) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + await provider.handleModeSwitch("architect") + + expect(activateProfileSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should emit ModeChanged event after handleModeSwitch", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const modeChangedSpy = vi.fn() + + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + + await provider.handleModeSwitch("architect") + + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + await provider.dispose() + }) + + // A4 regression: non-focused target task + it("should scope mode switches for non-focused tasks to the task only", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider["resolveWebviewView"](createMockWebviewView()) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + await provider.addClineToStack(makeTask("focused-task") as unknown as Task) + const backgroundTask = makeTask("background-task") + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "code") + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + await provider.handleModeSwitch("architect", backgroundTask as unknown as Task) + // Task-scoped effects apply to the background task: + expect(backgroundTask.emit).toHaveBeenCalledWith( + RooCodeEventName.TaskModeSwitched, + "background-task", + "architect", + ) + expect(backgroundTask._taskMode).toBe("architect") + // ...but the view-level effects (durable mode pin, broadcast, profile) stay untouched: + expect(provider["viewLocalState"].mode).toBe("code") + expect(modeChangedSpy).not.toHaveBeenCalled() + expect(activateProfileSpy).not.toHaveBeenCalled() + await provider.dispose() + }) + }) + + describe("multi-instance isolation", () => { + it("should maintain independent state across three instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider3 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider1.saveViewState("mode", "code") + await provider1.saveViewState("currentApiConfigName", "profile-1") + await provider2.saveViewState("mode", "architect") + await provider2.saveViewState("currentApiConfigName", "profile-2") + await provider3.saveViewState("mode", "debugger") + await provider3.saveViewState("currentApiConfigName", "profile-3") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + const state3 = await provider3.getState() + + expect(state1.mode).toBe("code") + expect(state1.currentApiConfigName).toBe("profile-1") + expect(state2.mode).toBe("architect") + expect(state2.currentApiConfigName).toBe("profile-2") + expect(state3.mode).toBe("debugger") + expect(state3.currentApiConfigName).toBe("profile-3") + + await provider1.dispose() + await provider2.dispose() + await provider3.dispose() + }) + + it("should handle mode switch in one instance without affecting others", async () => { + const postMessage1 = vi.fn() + const postMessage2 = vi.fn() + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await provider1.resolveWebviewView(createMockWebviewView(postMessage1)) + await provider2.resolveWebviewView(createMockWebviewView(postMessage2)) + await provider1.saveViewState("mode", "code") + await provider2.saveViewState("mode", "debugger") + + await provider1.handleModeSwitch("architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + expect(provider2["viewLocalState"].mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("_clearViewLocalState", () => { + it("should clear all view-local state values", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + }) + + // Call _clearViewLocalState + provider["_clearViewLocalState"]() + + // All values should be cleared + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("should cause getState to fall back to contextProxy values after clear", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await provider.saveViewState("mode", "architect") + + let state = await provider.getState() + expect(state.mode).toBe("architect") + + // Clear viewLocalState + provider["_clearViewLocalState"]() + + // getState should now fall back to contextProxy (global) state + state = await provider.getState() + expect(state.mode).toBe("code") // Default from mock context + + await provider.dispose() + }) + + it("should be safe to call on empty viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Should not throw even if viewLocalState is already empty + expect(provider["_clearViewLocalState"]()).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6977d30605..fe42f98809 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -578,7 +578,7 @@ describe("ClineProvider", () => { }) test("does not reload full model details when the LM Studio model is already loaded", async () => { - vi.mocked(hasLoadedFullDetails).mockReturnValue(true) + vi.mocked(hasLoadedFullDetails).mockReturnValueOnce(true) await provider.performPreparationTasks({ apiConfiguration: { @@ -2314,11 +2314,19 @@ describe("ClineProvider", () => { getProfile: vi.fn().mockResolvedValue(profile), } as any + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch to architect mode await provider.handleModeSwitch("architect") - // Verify mode was updated - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify saved config was loaded expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") @@ -2348,11 +2356,19 @@ describe("ClineProvider", () => { return undefined }) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch to architect mode await provider.handleModeSwitch("architect") - // Verify mode was updated - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify current config was saved as default for new mode expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") @@ -2419,8 +2435,10 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("non-existent-mode", expect.any(Array)) - // Verify fallback to default mode - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "code") + // Verify fallback to default mode, view-locally: history restore no longer + // writes the shared global mode + expect(provider["viewLocalState"].mode).toBe("code") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "code") expect(logSpy).toHaveBeenCalledWith( "Mode 'non-existent-mode' from history no longer exists. Falling back to default mode 'code'.", ) @@ -2492,8 +2510,9 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("custom-mode", expect.any(Array)) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "custom-mode") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("custom-mode") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "custom-mode") expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("no longer exists")) // Verify history item mode was not changed @@ -2540,8 +2559,9 @@ describe("ClineProvider", () => { // Initialize with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") // Verify history item mode was not changed expect(historyItem.mode).toBe("architect") diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index fedfa13030..56fbb64ddf 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -348,11 +348,19 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch mode await provider.handleModeSwitch("architect") - // Verify mode was updated in global state - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify task history was updated with new mode expect(updateTaskHistorySpy).toHaveBeenCalledWith( @@ -472,14 +480,14 @@ describe("ClineProvider - Sticky Mode", () => { mode: "architect", // Saved mode } - // Mock updateGlobalState to track mode updates - const updateGlobalStateSpy = vi.spyOn(provider as any, "updateGlobalState").mockResolvedValue(undefined) + await provider["setViewStateId"]("stable-test-view") // Initialize task with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was restored via updateGlobalState - expect(updateGlobalStateSpy).toHaveBeenCalledWith("mode", "architect") + // Verify mode was restored into the view-local pin (no shared global write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) it("should use current mode if history item has no saved mode", async () => { @@ -680,11 +688,19 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch mode - should not throw await expect(provider.handleModeSwitch("architect")).resolves.not.toThrow() - // Verify mode was still updated in global state - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was still updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) }) it("should handle null/undefined mode gracefully", async () => { @@ -848,6 +864,9 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve([]) }) + // Register a stable view id so the durable per-view writes are persisted + await provider["setViewStateId"]("stable-test-view") + // Clear previous calls to globalState.update vi.mocked(mockContext.globalState.update).mockClear() @@ -860,12 +879,16 @@ describe("ClineProvider - Sticky Mode", () => { await Promise.all(switches) - // Find the last mode update call - const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - const lastModeCall = modeCalls[modeCalls.length - 1] + // Find the last durable view state update call + const viewStateCalls = vi + .mocked(mockContext.globalState.update) + .mock.calls.filter((call) => call[0] === "viewStates") + const lastViewStateCall = viewStateCalls[viewStateCalls.length - 1] // Verify the last mode switch wins - expect(lastModeCall).toEqual(["mode", "code"]) + expect(lastViewStateCall?.[1]).toMatchObject({ + ["stable-test-view"]: { mode: "code" }, + }) // Verify task history was updated with final mode const lastCall = updateTaskHistorySpy.mock.calls[updateTaskHistorySpy.mock.calls.length - 1] @@ -952,11 +975,19 @@ describe("ClineProvider - Sticky Mode", () => { // Clear previous calls vi.mocked(mockContext.globalState.update).mockClear() + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Try to switch to invalid mode - it will actually switch await provider.handleModeSwitch("invalid-mode" as any) // The mode WILL be updated to invalid-mode (this is the actual behavior) - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "invalid-mode") + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "invalid-mode" }), + }), + ) }) it("should handle errors during mode switch gracefully", async () => { @@ -1218,13 +1249,9 @@ describe("ClineProvider - Sticky Mode", () => { // Wait for initialization to complete await initPromise - // Check all mode update calls - const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - - // Based on the actual behavior, the mode switch to "code" happens and persists - // The history mode restoration doesn't override it - const lastModeCall = modeCalls[modeCalls.length - 1] - expect(lastModeCall).toEqual(["mode", "code"]) + // Both mutations now land in the view-local buffer. The history restore runs + // early (before the slow getTaskWithId), so the mid-init switch to "code" wins. + expect(provider["viewLocalState"].mode).toBe("code") }) it("should handle rapid task switches during mode changes", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 5a4b3e7be3..de5109af8a 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -8,6 +8,8 @@ import { } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" +import type { WebviewMessage } from "@roo-code/types" + import type { ClineProvider } from "../ClineProvider" const [kimiCodeOAuthAuthMethod, kimiCodeApiKeyAuthMethod] = kimiCodeAuthMethodSchema.options @@ -447,6 +449,33 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect(getModelsMock).toHaveBeenCalledWith(deepSeekOptions) }) + it("continues posting routerModels when Kimi Code OAuth lookup fails", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { + kimiCodeAuthMethod: "oauth", + }, + }) + getKimiCodeAccessTokenMock.mockRejectedValueOnce(new Error("refresh failed")) + + await webviewMessageHandler(mockProvider, { + type: "requestRouterModels", + values: { provider: providerIdentifiers.kimiCode }, + } satisfies WebviewMessage) + + expect(getKimiCodeAccessTokenMock).toHaveBeenCalledOnce() + expect(mockProvider.log).toHaveBeenCalledWith( + "[requestRouterModels] kimi-code credential lookup failed: refresh failed", + ) + expect(getModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.kimiCode }), + ) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "routerModels", + routerModels: {}, + values: { provider: providerIdentifiers.kimiCode }, + }) + }) + it("fetches Moonshot models when stored Moonshot credentials exist", async () => { mockProvider.getState.mockResolvedValue({ apiConfiguration: { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..d4ecb26281 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -64,12 +64,14 @@ vi.mock("@roo-code/telemetry", () => ({ hasInstance: vi.fn().mockReturnValue(false), instance: { updateTelemetryState: vi.fn(), + captureCustomModeCreated: vi.fn(), + captureModeSettingChanged: vi.fn(), captureTelemetrySettingsChanged: vi.fn(), }, }, })) -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, RooCodeSettings } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -99,8 +101,10 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + saveViewState: vi.fn(), customModesManager: { getCustomModes: vi.fn(), + updateCustomMode: vi.fn(), deleteCustomMode: vi.fn(), }, context: { @@ -115,6 +119,16 @@ const mockClineProvider = { setValue: vi.fn(), getValue: vi.fn(), }, + // Delegates to contextProxy.setValue so existing assertions keep holding while + // the updateSettings flow is exercised through the provider-level mutation path. + setValue: vi + .fn() + .mockImplementation((key: string, value: unknown) => + mockClineProvider.contextProxy.setValue( + key as keyof RooCodeSettings, + value as RooCodeSettings[keyof RooCodeSettings], + ), + ), log: vi.fn(), postStateToWebview: vi.fn(), resolveWebviewThemeFixtureProbe: vi.fn(), @@ -122,6 +136,7 @@ const mockClineProvider = { getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -244,6 +259,7 @@ import { getWorkspacePath } from "../../../utils/path" import { ensureSettingsDirectoryExists } from "../../../utils/globalContext" import { generateErrorDiagnostics } from "../diagnosticsHandler" import type { ModeConfig } from "@roo-code/types" +import { defaultModeSlug } from "../../../shared/modes" vi.mock("../../../utils/fs") vi.mock("../../../utils/path") @@ -261,6 +277,77 @@ import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" +describe("webviewMessageHandler - webviewDidLaunch", () => { + // Structural view of the provider members this suite reassigns at runtime: the + // double literal does not declare them and some are readonly on the class, so a + // cast of the mock target alone cannot express these reassignments without any. + type LaunchProviderFixture = { + setViewStateId: (viewStateId: string) => Promise + workspaceTracker: { initializeFilePaths: () => Promise } + providerSettingsManager: { + listConfig: () => Promise + hasConfig: (name: string) => Promise + } + activateProviderProfile: (options: { name: string }) => Promise + getMcpHub: () => unknown + getStateToPostToWebview: () => Promise<{ telemetrySetting: string }> + } + const double = mockClineProvider as unknown as LaunchProviderFixture + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + currentApiConfigName: "view-local-profile", + } as unknown as Awaited>) + double.setViewStateId = vi.fn().mockResolvedValue(undefined) + double.workspaceTracker = { initializeFilePaths: vi.fn().mockResolvedValue(undefined) } + double.providerSettingsManager = { + listConfig: vi + .fn() + .mockResolvedValue([{ name: "shared-profile", apiProvider: providerIdentifiers.anthropic }]), + hasConfig: vi.fn().mockResolvedValue(false), + } + double.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + double.getMcpHub = vi.fn().mockReturnValue(undefined) + double.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "disabled" }) + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue("shared-profile") + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + it("validates the view-local currentApiConfigName on launch", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(double.setViewStateId).toHaveBeenCalledWith("view-1") + + // The merged (view-local) name is validated first; the shared global is only + // consulted when the view-local name is invalid. + expect(double.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(mockClineProvider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("shared-profile") + // Both names are invalid in this setup, so the shared global is repaired. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith({ name: "shared-profile" }) + }) + + it("re-pins only the view when its profile is missing but the shared global is still valid", async () => { + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The view pin is re-pinned to the first available profile, + // and the shared global selection is left untouched: no global write, no global activation. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "currentApiConfigName", + "shared-profile", + ) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) +}) + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -587,7 +674,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockClineProvider.getState = vi.fn().mockResolvedValue({ apiConfiguration: { openRouterApiKey: "openrouter-key", - // Deliberately no opencodeGoApiKey — the endpoint is public. + // Deliberately no opencodeGoApiKey ??the endpoint is public. }, }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 876fec270e..91ea1e2921 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -581,7 +581,9 @@ export const webviewMessageHandler = async ( provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) } break - case "webviewDidLaunch": + case "webviewDidLaunch": { + await provider.setViewStateId(message.viewStateId) + // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) @@ -630,17 +632,33 @@ export const webviewMessageHandler = async ( } } - const currentConfigName = getGlobalState("currentApiConfigName") + const currentState = await provider.getState() + const currentConfigName = currentState.currentApiConfigName if (currentConfigName) { if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) { - // Current config name not valid, get first config in list. + // The merged name (which may be this view's durable pin) no longer + // resolves. When the shared global selection is still valid, re-pin + // only this view so the global selection is left untouched; only + // repair the global when it is invalid as well. + const globalConfigName = getGlobalState("currentApiConfigName") + const globalStillValid = + !!globalConfigName && + (await provider.providerSettingsManager.hasConfig(globalConfigName)) const name = listApiConfig[0]?.name - await updateGlobalState("currentApiConfigName", name) - if (name) { - await provider.activateProviderProfile({ name }) - return + if (globalStillValid && name) { + await provider.saveViewState("currentApiConfigName", name) + // Fall through: refresh listApiConfigMeta and post listApiConfig + // to this webview below. + } else { + // Current config name not valid, get first config in list. + await updateGlobalState("currentApiConfigName", name) + + if (name) { + await provider.activateProviderProfile({ name }) + return + } } } } @@ -690,6 +708,7 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + } case "newTask": // Initializing new instance of Cline will make sure that any // agentically running promises in old instance don't affect our new @@ -857,7 +876,9 @@ export const webviewMessageHandler = async ( } } - await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + // Route through provider.setValue so view-local buffer/pin sync stays + // consistent with the other mutation paths. + await provider.setValue(key as keyof RooCodeSettings, newValue) } await provider.postStateToWebview() @@ -1318,18 +1339,24 @@ export const webviewMessageHandler = async ( }) if (!providerFilter || providerFilter === providerIdentifiers.kimiCode) { - const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") - const kimiCodeAuthMethod = - message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" - const kimiCodeApiKey = - kimiCodeAuthMethod === "api-key" - ? (message?.values?.kimiCodeApiKey ?? apiConfiguration.kimiCodeApiKey) - : await kimiCodeOAuthManager.getAccessToken() - if (kimiCodeApiKey) { - candidates.push({ - key: providerIdentifiers.kimiCode, - options: { provider: providerIdentifiers.kimiCode, apiKey: kimiCodeApiKey }, - }) + try { + const { kimiCodeOAuthManager } = await import("../../integrations/kimi-code/oauth") + const kimiCodeAuthMethod = + message?.values?.kimiCodeAuthMethod ?? apiConfiguration.kimiCodeAuthMethod ?? "oauth" + const kimiCodeApiKey = + kimiCodeAuthMethod === "api-key" + ? (message?.values?.kimiCodeApiKey ?? apiConfiguration.kimiCodeApiKey) + : await kimiCodeOAuthManager.getAccessToken() + if (kimiCodeApiKey) { + candidates.push({ + key: providerIdentifiers.kimiCode, + options: { provider: providerIdentifiers.kimiCode, apiKey: kimiCodeApiKey }, + }) + } + } catch (error) { + provider.log( + `[requestRouterModels] kimi-code credential lookup failed: ${error instanceof Error ? error.message : String(error)}`, + ) } } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 69c02c71ed..55c7f52bb1 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1041,7 +1041,7 @@ }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 37 + "count": 36 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index 80f8cad9cd..1dd9b48d62 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" +import { providerIdentifiers } from "@roo-code/types" + import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" @@ -17,6 +19,7 @@ describe("API - configuration", () => { const provider = { context: {}, on: vi.fn(), + setValues, contextProxy: { setValues }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, @@ -50,6 +53,7 @@ describe("API - configuration", () => { const provider = { context: {}, on: vi.fn(), + setValues, contextProxy: { setValues }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, @@ -62,4 +66,38 @@ describe("API - configuration", () => { expect(setModeConfig).not.toHaveBeenCalled() expect(postStateToWebview).toHaveBeenCalledOnce() }) + + it("flattens the nested view-local apiConfiguration and strips its secrets", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "architect", + currentApiConfigName: "view-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4o", + apiKey: "nested-secret-key", + openRouterApiKey: "nested-openrouter-secret", + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider) + + const configuration = api.getConfiguration() + + expect(getValues).toHaveBeenCalledOnce() + expect(configuration.mode).toBe("architect") + expect(configuration.currentApiConfigName).toBe("view-profile") + expect(configuration.apiProvider).toBe(providerIdentifiers.openrouter) + expect(configuration.openRouterModelId).toBe("openai/gpt-4o") + expect(configuration).not.toHaveProperty("apiConfiguration") + expect(configuration).not.toHaveProperty("apiKey") + expect(configuration).not.toHaveProperty("openRouterApiKey") + }) }) diff --git a/src/extension/__tests__/api-set-configuration.spec.ts b/src/extension/__tests__/api-set-configuration.spec.ts new file mode 100644 index 0000000000..9fd0118cde --- /dev/null +++ b/src/extension/__tests__/api-set-configuration.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" +import type { OutputChannel } from "vscode" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +vi.mock("../../integrations/terminal/Terminal", () => ({ + Terminal: { + getTerminalProfile: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + closeIdleTerminals: vi.fn(), + }, +})) + +describe("API.setConfiguration", () => { + it("routes configuration through ClineProvider.setValues so view-local state stays in sync", async () => { + const provider = { + context: {}, + on: vi.fn(), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + saveConfig: vi.fn().mockResolvedValue("default-id"), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider + const api = new API({ appendLine: vi.fn() } as unknown as OutputChannel, provider) + const configuration = { + apiProvider: providerIdentifiers.bedrock, + currentApiConfigName: "default", + awsRegion: "us-east-1", + apiModelId: "us.anthropic.claude-haiku-4-5-20251001-v1:0", + } + + await api.setConfiguration(configuration) + + expect(provider.setValues).toHaveBeenCalledWith(configuration) + expect(provider.contextProxy.setValues).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", configuration) + expect(provider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/extension/__tests__/api-task-control.spec.ts b/src/extension/__tests__/api-task-control.spec.ts new file mode 100644 index 0000000000..c6e43a7594 --- /dev/null +++ b/src/extension/__tests__/api-task-control.spec.ts @@ -0,0 +1,305 @@ +import { EventEmitter } from "events" + +import { describe, expect, it, vi, beforeEach, type Mock } from "vitest" +import * as vscode from "vscode" + +import { RooCodeEventName, type ModeConfig, type RooCodeSettings } from "@roo-code/types" + +import { API } from "../api" +import { ClineProvider } from "../../core/webview/ClineProvider" + +const { openClineInNewTabMock } = vi.hoisted(() => ({ + openClineInNewTabMock: vi.fn(), +})) + +vi.mock("vscode", () => ({ + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, +})) + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +vi.mock("../../activate/registerCommands", () => ({ + openClineInNewTab: openClineInNewTabMock, +})) + +vi.mock("../../integrations/terminal/Terminal", () => ({ + Terminal: { + getTerminalProfile: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + closeIdleTerminals: vi.fn(), + }, +})) + +type CreatedTask = { + taskId: string +} + +type ProviderDouble = EventEmitter & { + context: vscode.ExtensionContext + evictCurrentTask: Mock<() => Promise> + postStateToWebview: Mock<() => Promise> + postMessageToWebview: Mock<(message: unknown) => Promise> + createTask: Mock<(...args: unknown[]) => Promise> + getCurrentTaskStack: Mock<() => string[]> + getCurrentTask: Mock<() => undefined> + getState: Mock<() => Promise<{ customModes?: ModeConfig[] }>> + handleModeSwitch: Mock<(mode: string, targetTask?: unknown) => Promise> + viewLaunched: boolean +} + +type TaskDouble = EventEmitter & { + taskId: string + parentTaskId?: string + approveAsk: Mock<() => void> + handleWebviewAskResponse: Mock<(response: "messageResponse", text?: string, images?: string[]) => void> +} + +const configuration: RooCodeSettings = {} + +function asClineProvider(provider: ProviderDouble): ClineProvider { + // ClineProvider has private members, so a structural test double requires an unknown bridge. + return provider as unknown as ClineProvider +} + +function createProvider(taskId = "task-1"): ProviderDouble { + const provider = new EventEmitter() as ProviderDouble + provider.context = {} as vscode.ExtensionContext + provider.evictCurrentTask = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + provider.createTask = vi.fn().mockResolvedValue({ taskId }) + provider.getCurrentTaskStack = vi.fn().mockReturnValue([]) + provider.getCurrentTask = vi.fn().mockReturnValue(undefined) + provider.getState = vi.fn().mockResolvedValue({ customModes: [] }) + provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) + provider.viewLaunched = true + return provider +} + +function createTask(taskId: string): TaskDouble { + const task = new EventEmitter() as TaskDouble + task.taskId = taskId + task.approveAsk = vi.fn() + task.handleWebviewAskResponse = vi.fn() + return task +} + +describe("API task controls", () => { + let outputChannel: vscode.OutputChannel + let sidebarProvider: ProviderDouble + let api: API + + beforeEach(() => { + vi.clearAllMocks() + outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + sidebarProvider = createProvider("sidebar-task") + api = new API(outputChannel, asClineProvider(sidebarProvider)) + }) + + describe("startNewTask", () => { + it("reverts and closes existing editors before opening a new tab unless preserveOpenTabs is true", async () => { + const newTabProvider = createProvider("new-tab-task") + openClineInNewTabMock.mockResolvedValue(newTabProvider) + + const taskId = await api.startNewTask({ configuration, text: "new task", newTab: true }) + + expect(taskId).toBe("new-tab-task") + expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "workbench.action.files.revert") + expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(2, "workbench.action.closeAllEditors") + expect(openClineInNewTabMock).toHaveBeenCalledWith({ + context: sidebarProvider.context, + outputChannel, + }) + expect(newTabProvider.evictCurrentTask).toHaveBeenCalledOnce() + expect(newTabProvider.createTask).toHaveBeenCalledWith( + "new task", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER }, + configuration, + ) + }) + + it("opens a new tab without revert or close commands when preserveOpenTabs is true", async () => { + const newTabProvider = createProvider("preserved-tab-task") + openClineInNewTabMock.mockResolvedValue(newTabProvider) + + const taskId = await api.startNewTask({ + configuration, + text: "keep editors", + newTab: true, + preserveOpenTabs: true, + }) + + expect(taskId).toBe("preserved-tab-task") + expect(vscode.commands.executeCommand).not.toHaveBeenCalled() + expect(openClineInNewTabMock).toHaveBeenCalledWith({ + context: sidebarProvider.context, + outputChannel, + }) + expect(newTabProvider.createTask).toHaveBeenCalledWith( + "keep editors", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER }, + configuration, + ) + }) + }) + + describe("task ask registry", () => { + it("returns false when approving an unknown task", async () => { + await expect(api.approveTaskAsk("missing-task")).resolves.toBe(false) + }) + + it("registers tasks on TaskCreated and approves a task by id", async () => { + const task = createTask("task-to-approve") + + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect(api.approveTaskAsk(task.taskId)).resolves.toBe(true) + expect(task.approveAsk).toHaveBeenCalledOnce() + }) + + it("removes completed, aborted, and unfocused tasks from the registry", async () => { + const completedTask = createTask("completed-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, completedTask) + completedTask.emit(RooCodeEventName.TaskCompleted, completedTask.taskId, {}, {}) + + await expect(api.approveTaskAsk(completedTask.taskId)).resolves.toBe(false) + + const abortedTask = createTask("aborted-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, abortedTask) + abortedTask.emit(RooCodeEventName.TaskAborted) + + await expect(api.approveTaskAsk(abortedTask.taskId)).resolves.toBe(false) + + const unfocusedTask = createTask("unfocused-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, unfocusedTask) + unfocusedTask.emit(RooCodeEventName.TaskUnfocused) + + await expect(api.approveTaskAsk(unfocusedTask.taskId)).resolves.toBe(false) + }) + }) + + describe("selectTaskFollowupSuggestion", () => { + it("returns false when the task is unknown", async () => { + await expect( + api.selectTaskFollowupSuggestion({ taskId: "missing-task", answer: "Use this" }), + ).resolves.toBe(false) + }) + + it("responds to the task without switching modes when no mode is provided", async () => { + const task = createTask("task-without-mode") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect(api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Continue" })).resolves.toBe( + true, + ) + + expect(sidebarProvider.getState).not.toHaveBeenCalled() + expect(sidebarProvider.handleModeSwitch).not.toHaveBeenCalled() + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Continue") + }) + + it("switches to a valid built-in mode before responding", async () => { + const task = createTask("task-built-in-mode") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Use architect", mode: "architect" }), + ).resolves.toBe(true) + + expect(sidebarProvider.getState).toHaveBeenCalledOnce() + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Use architect") + }) + + it("responds without switching modes and logs when the requested mode is invalid", async () => { + const task = createTask("task-invalid-mode") + api = new API(outputChannel, asClineProvider(sidebarProvider), undefined, true) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Use invalid", mode: "not-a-mode" }), + ).resolves.toBe(true) + + expect(sidebarProvider.getState).toHaveBeenCalledOnce() + expect(sidebarProvider.handleModeSwitch).not.toHaveBeenCalled() + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Use invalid") + expect(outputChannel.appendLine).toHaveBeenCalledWith( + '[API#selectTaskFollowupSuggestion] ignoring unknown mode "not-a-mode" for task task-invalid-mode', + ) + }) + + it("treats custom modes from the task provider state as valid", async () => { + const task = createTask("task-custom-mode") + const customMode: ModeConfig = { + slug: "custom-review", + name: "Custom Review", + roleDefinition: "Review the implementation", + groups: ["read"], + } + sidebarProvider.getState.mockResolvedValue({ customModes: [customMode] }) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Review it", mode: customMode.slug }), + ).resolves.toBe(true) + + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith(customMode.slug, task) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Review it") + }) + }) +}) + +describe("API task controls - per-view review fixes", () => { + let outputChannel: vscode.OutputChannel + let sidebarProvider: ProviderDouble + let api: API + + beforeEach(() => { + vi.clearAllMocks() + outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + sidebarProvider = createProvider("sidebar-task") + api = new API(outputChannel, asClineProvider(sidebarProvider)) + }) + + it("keeps the new registration when a replaced task instance tears down", async () => { + const staleTask = createTask("replaced-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, staleTask) + + // A new instance reusing the same taskId replaces the stale registration. + const freshTask = createTask("replaced-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, freshTask) + + // The stale instance teardown must not drop the new registration. + staleTask.emit(RooCodeEventName.TaskAborted) + await expect(api.approveTaskAsk("replaced-task")).resolves.toBe(true) + + freshTask.emit(RooCodeEventName.TaskUnfocused) + await expect(api.approveTaskAsk("replaced-task")).resolves.toBe(false) + }) + + it("still delivers the follow-up answer when the mode switch fails", async () => { + const task = createTask("task-failing-switch") + sidebarProvider.handleModeSwitch.mockRejectedValueOnce(new Error("persist failed")) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Deliver anyway", mode: "architect" }), + ).resolves.toBe(true) + + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Deliver anyway") + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 16f1d402bc..04dc2ac70c 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -8,11 +8,13 @@ import pWaitFor from "p-wait-for" import { type RooCodeAPI, + type GlobalState, type RooCodeSettings, type RooCodeEvents, type ProviderSettings, type ProviderSettingsEntry, type TaskEvent, + type TaskLike, type CreateTaskOptions, type WebviewThemeFixture, RooCodeEventName, @@ -24,19 +26,31 @@ import { import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" -import type { Mode } from "../shared/modes" +import { getAllModes, type Mode } from "../shared/modes" import { ClineProvider } from "../core/webview/ClineProvider" +import type { Task } from "../core/task/Task" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { openClineInNewTab } from "../activate/registerCommands" import { getCommands } from "../services/command/commands" import { getModels } from "../api/providers/fetchers/modelCache" +type TaskAskController = { + approveAsk(): void + handleWebviewAskResponse(response: "messageResponse", text?: string, images?: string[]): void +} + +type RegisteredTask = { + task: TaskAskController + provider: ClineProvider +} + export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider private readonly context: vscode.ExtensionContext private readonly ipc?: IpcServer + private readonly tasksById = new Map() private readonly log: (...args: unknown[]) => void private logfile?: string @@ -174,17 +188,21 @@ export class API extends EventEmitter implements RooCodeAPI { text, images, newTab, + preserveOpenTabs, }: { configuration: RooCodeSettings text?: string images?: string[] newTab?: boolean + preserveOpenTabs?: boolean }) { let provider: ClineProvider if (newTab) { - await vscode.commands.executeCommand("workbench.action.files.revert") - await vscode.commands.executeCommand("workbench.action.closeAllEditors") + if (!preserveOpenTabs) { + await vscode.commands.executeCommand("workbench.action.files.revert") + await vscode.commands.executeCommand("workbench.action.closeAllEditors") + } provider = await openClineInNewTab({ context: this.context, outputChannel: this.outputChannel }) this.registerListeners(provider) @@ -311,6 +329,69 @@ export class API extends EventEmitter implements RooCodeAPI { this.sidebarProvider.getCurrentTask()?.approveAsk() } + /** + * Approves the pending ask for a specific task by its ID. + * + * @returns Whether a registered task with the given ID was found and approved. + */ + public async approveTaskAsk(taskId: string): Promise { + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + entry.task.approveAsk() + return true + } + + /** + * Answers a task's pending ask with a follow-up suggestion, optionally switching that + * task's provider to the suggestion's mode before responding. + * + * @returns Whether a registered task with the given ID was found and answered. + */ + public async selectTaskFollowupSuggestion({ + taskId, + answer, + mode, + }: { + taskId: string + answer: string + mode?: string + }): Promise { + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + if (mode) { + try { + const { customModes } = await entry.provider.getState() + const isValidMode = getAllModes(customModes).some((modeConfig) => modeConfig.slug === mode) + + if (isValidMode) { + // entry.task is the registered Task instance (TaskAskController narrows it + // to the ask-response surface); pass it explicitly so the switch is scoped to + // this task rather than the provider's currently focused task. + await entry.provider.handleModeSwitch(mode, entry.task as Task) + } else { + this.log(`[API#selectTaskFollowupSuggestion] ignoring unknown mode "${mode}" for task ${taskId}`) + } + } catch (error) { + // A failed mode switch must not swallow the follow-up answer: the task's + // pending ask would otherwise stay unanswered. + this.log( + `[API#selectTaskFollowupSuggestion] mode switch failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + entry.task.handleWebviewAskResponse("messageResponse", answer) + return true + } + public isReady() { return this.sidebarProvider.viewLaunched } @@ -333,8 +414,25 @@ export class API extends EventEmitter implements RooCodeAPI { } } + /** + * Removes a task's registration only if the registered entry still belongs to this + * task instance: a replaced instance reusing the same taskId must not be dropped by + * the previous instance's teardown events. + */ + private removeRegisteredTask(task: TaskLike): void { + const entry = this.tasksById.get(task.taskId) + + // The stored controller is this exact instance (registered above with a cast to + // the ask-response surface), so reference equality is the right identity check. + if (entry?.task === (task as unknown as TaskAskController)) { + this.tasksById.delete(task.taskId) + } + } + private registerListeners(provider: ClineProvider) { provider.on(RooCodeEventName.TaskCreated, (task) => { + this.tasksById.set(task.taskId, { task: task as unknown as TaskAskController, provider }) + // Task Lifecycle task.on(RooCodeEventName.TaskStarted, async () => { @@ -346,6 +444,7 @@ export class API extends EventEmitter implements RooCodeAPI { this.emit(RooCodeEventName.TaskCompleted, task.taskId, tokenUsage, toolUsage, { isSubtask: !!task.parentTaskId, }) + this.removeRegisteredTask(task) await this.fileLog( `[${new Date().toISOString()}] taskCompleted -> ${task.taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`, @@ -354,6 +453,7 @@ export class API extends EventEmitter implements RooCodeAPI { task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) + this.removeRegisteredTask(task) }) task.on(RooCodeEventName.TaskFocused, () => { @@ -362,6 +462,7 @@ export class API extends EventEmitter implements RooCodeAPI { task.on(RooCodeEventName.TaskUnfocused, () => { this.emit(RooCodeEventName.TaskUnfocused, task.taskId) + this.removeRegisteredTask(task) }) task.on(RooCodeEventName.TaskActive, () => { @@ -503,13 +604,18 @@ export class API extends EventEmitter implements RooCodeAPI { // Global Settings Management public getConfiguration(): RooCodeSettings { + // getValues() merges view-local state, whose apiConfiguration is a nested object that + // can carry provider secrets (e.g. apiKey). Flatten the provider settings onto the top + // level (the pre-existing flat shape) so the secret filter removes them before return. + const values = this.sidebarProvider.getValues() + const { apiConfiguration, ...rest } = values return Object.fromEntries( - Object.entries(this.sidebarProvider.getValues()).filter(([key]) => !isSecretStateKey(key)), + Object.entries({ ...rest, ...apiConfiguration }).filter(([key]) => !isSecretStateKey(key)), ) } public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) + await this.sidebarProvider.setValues(values) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) if (values.modeApiConfigs) { await Promise.all( @@ -521,6 +627,10 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + public getGlobalState(key: K): GlobalState[K] { + return this.context.globalState.get(key) + } + public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index b1fbf82999..112ab91500 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -203,9 +203,6 @@ const App = () => { } }, [telemetrySetting, telemetryKey, machineId, vscodeTelemetryEnabled, didHydrateState]) - // Tell the extension that we are ready to receive messages. - useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) - // Initialize source map support for better error reporting useEffect(() => { // Initialize source maps for better error reporting in production diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 137bed5d70..2ddb3eb15c 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -8,6 +8,7 @@ import AppWithProviders from "../App" vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), + getViewStateId: vi.fn(() => "test-view-state-id"), }, })) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 64768e75e8..ba8e7ab815 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -520,7 +520,10 @@ export const ExtensionStateContextProvider: React.FC<{ }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index c31dac381c..22130f1618 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -20,6 +20,14 @@ import { mergeExtensionState, createInitialExtensionState, } from "../ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + getViewStateId: vi.fn(() => "view-a"), + }, +})) const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -108,7 +116,80 @@ const InitialStateTestComponent = () => { ) } +const ViewLocalStateTestComponent = () => { + const { mode, setMode, currentApiConfigName, setCurrentApiConfigName } = useExtensionState() + + return ( +
+
{mode}
+
{currentApiConfigName}
+ + +
+ ) +} + describe("ExtensionStateContext", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts webviewDidLaunch with the stable viewStateId from vscode API", () => { + render( + + + , + ) + + expect(vscode.getViewStateId).toHaveBeenCalled() + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch", viewStateId: "view-a" }) + }) + + it("reseeds view-local mode and API profile from a new state payload after local edits", () => { + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "code", currentApiConfigName: "profile-a", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("code") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-a") + + act(() => { + screen.getByTestId("set-local-mode").click() + screen.getByTestId("set-local-api-config").click() + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("ask") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("local-profile") + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "architect", currentApiConfigName: "profile-b", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("architect") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-b") + }) + it("initializes with empty allowedCommands array", () => { render( diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..70cc10c0e6 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,89 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +const createMockStorage = (initialState: Record = {}) => { + const state = { ...initialState } + return { + getItem: vi.fn((key: string) => state[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + state[key] = value + }), + removeItem: vi.fn((key: string) => { + delete state[key] + }), + clear: vi.fn(() => { + for (const key of Object.keys(state)) { + delete state[key] + } + }), + } as unknown as Storage +} + +describe("VSCodeAPIWrapper", () => { + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }) + }) + + it("reuses the persisted webview viewStateId when browser storage is available", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "persisted-view" }) }), + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("persisted-view") + }) + + it("creates and persists a new viewStateId when storage has been cleared", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("falls back to in-memory state when browser storage access is restricted", () => { + const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const storage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + } as unknown as Storage + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(randomUUID).toHaveBeenCalledTimes(1) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.setItem).toHaveBeenCalled() + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..63c1ff32a7 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -11,8 +11,9 @@ import { WebviewMessage } from "@roo/WebviewMessage" * dev server by using native web browser features that mock the functionality * enabled by acquireVsCodeApi. */ -class VSCodeAPIWrapper { +export class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private fallbackState: unknown | undefined constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -22,6 +23,40 @@ class VSCodeAPIWrapper { } } + /** + * Generates a unique identifier for this webview instance. + * + * @remarks Used only when no persisted identifier exists yet. + */ + private createViewStateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + /** + * Returns the stable view state identifier for this webview, creating and persisting + * one on first use so the extension can keep per-view state isolated across providers. + */ + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + if (typeof existingViewStateId === "string" && existingViewStateId.length > 0) { + return existingViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,10 +84,18 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { - const state = localStorage.getItem("vscodeState") - return state ? JSON.parse(state) : undefined } + + try { + if (typeof localStorage?.getItem === "function") { + const state = localStorage.getItem("vscodeState") + return state ? JSON.parse(state) : this.fallbackState + } + } catch { + return this.fallbackState + } + + return this.fallbackState } /** @@ -69,10 +112,20 @@ class VSCodeAPIWrapper { public setState(newState: T): T { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) - return newState } + + this.fallbackState = newState + + try { + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + } + + return newState } }