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 88c687bc76..ab4c0cbe29 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/index.ts b/apps/vscode-e2e/src/suite/index.ts index 0d53d65b2b..1ba36719e1 100644 --- a/apps/vscode-e2e/src/suite/index.ts +++ b/apps/vscode-e2e/src/suite/index.ts @@ -4,12 +4,16 @@ import Mocha from "mocha" import { glob } from "glob" import * as vscode from "vscode" -import { RooCodeEventName, type RooCodeAPI } from "@roo-code/types" +import { RooCodeEventName, type RooCodeAPI, type RooCodeTestOnlyApi } from "@roo-code/types" + +// In the e2e host the extension activates in test mode, so the exports object +// carries the production-gated test-only surface on top of RooCodeAPI. +type E2eApi = RooCodeAPI & RooCodeTestOnlyApi import { isCompletedAsk, waitFor } from "./utils" export async function run() { - const extension = vscode.extensions.getExtension("ZooCodeOrganization.zoo-code") + const extension = vscode.extensions.getExtension("ZooCodeOrganization.zoo-code") if (!extension) { throw new Error("Extension not found") 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..8d90390f5f --- /dev/null +++ b/apps/vscode-e2e/src/suite/view-state.test.ts @@ -0,0 +1,453 @@ +import * as assert from "assert" + +import { isSecretStateKey, RooCodeEventName, type ClineMessage, type GlobalState } from "@roo-code/types" +import * as vscode from "vscode" + +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. The 30s budget + // matches the suite's other waits (waitUntilCompleted, follow-up polling) so a + // slow memento flush under CI load cannot turn a correct write into a flake. + 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: 30_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 deliveryFailures: string[] = [] + const taskIds = new Map() + const pendingSuggestions = new Map() + const answeredSuggestions = new Set() + const completedTasks = 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 }) + .then((delivered) => { + if (!delivered) { + deliveryFailures.push(`${taskId}:${suggestion.answer}`) + } + }) + .catch((error: unknown) => { + deliveryFailures.push( + `${taskId}:${suggestion.answer}:${error instanceof Error ? error.message : String(error)}`, + ) + }) + } + } + + 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() + } + } + const completionHandler = (taskId: string) => { + completedTasks.add(taskId) + } + + globalThis.api.on(RooCodeEventName.Message, messageHandler) + globalThis.api.on(RooCodeEventName.TaskModeSwitched, modeHandler) + globalThis.api.on(RooCodeEventName.TaskCompleted, completionHandler) + + 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}` + }) + const deliveryFailureDetail = + deliveryFailures.length > 0 ? `; suggestion delivery failures: ${deliveryFailures.join(", ")}` : "" + throw new Error( + `Timed out after ${releasedRounds} coordinated rounds; mode event counts: ${counts.join(", ")}; pending suggestions: ${pendingSuggestions.size}${deliveryFailureDetail}. ${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}`, + ) + } + + // The final TaskModeSwitched fires before the fixture sends + // attempt_completion: while the message listener is still registered, + // wait for every task to complete so the trailing completion_result + // ask is approved and no task stays pending (the suite teardown's + // cancelCurrentTask() only covers the current task). + await waitFor( + () => + plan.every((taskPlan) => { + const taskId = taskIds.get(taskPlan.taskName) + return !!taskId && completedTasks.has(taskId) + }), + { timeout: 30_000 }, + ).catch((error) => { + throw new Error( + `Timed out waiting for all tasks to complete; completed: ${[...completedTasks].join(", ") || "(none)"}; expected: ${taskIdsInPlanOrder().join(", ")}. ${error instanceof Error ? error.message : String(error)}`, + ) + }) + } finally { + globalThis.api.off(RooCodeEventName.Message, messageHandler) + globalThis.api.off(RooCodeEventName.TaskModeSwitched, modeHandler) + globalThis.api.off(RooCodeEventName.TaskCompleted, completionHandler) + } + }) + /** + * Webview reload / rehydration: hiding and re-showing the primary sidebar must + * not lose the sidebar view's durable mode — the per-view entry survives in the + * global viewStates and the rehydrated view reports the same mode. + */ + test("sidebar webview reload rehydrates the durable per-view mode", async () => { + // Establish the sidebar view's durable mode 'ask' the same way the first test + // in this suite does: a real task whose switch_mode tool call switches the + // sidebar's focused task. + const taskId = await globalThis.api.startNewTask({ + configuration: { + mode: "code", + alwaysAllowModeSwitch: true, + autoApprovalEnabled: true, + apiKey: "reload-secret-must-not-persist", + }, + text: "Use the `switch_mode` tool to switch to ask mode.", + }) + await waitUntilCompleted({ api: globalThis.api, taskId }) + + // The per-view write is awaited through the serialized view-state write queue + // before the task completes, but a just-resolved globalState write can + // momentarily lag a synchronous globalState.get in the extension host. Poll + // until the persisted ask selection is visible before exercising the reload + // (the 30s budget matches the suite's other waits). + await waitFor( + () => { + const persisted = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + if (!persisted) { + return false + } + + return Object.entries(persisted).some(([, entry]) => entry.mode === "ask") + }, + { timeout: 30_000 }, + ) + + // Deterministic precondition: make sure the primary sidebar is visible before + // toggling so the hide/show cycle below is a real hide-then-show regardless + // of which suites ran earlier (the same command the suite index uses at + // startup). + await vscode.commands.executeCommand("zoo-code.SidebarProvider.focus") + + // Stage a durable rehydration marker: rewrite every persisted 'ask' entry to + // mode 'build' through the test-only global-state write. The sidebar provider's + // in-memory mode is still 'ask' at this point, so the two assertions below each + // pin a value only one failure mode can break: (a) passes only if the hide/show + // cycle did not wipe, prune, or re-key the durable entry the marker was written + // to (an unrelated surviving entry cannot satisfy a 'some entry is build' + // assertion because no other test in this suite uses the build mode), and (b) + // passes only if the cycle did not wipe the provider's in-memory view state. + // Asserting the marker through getConfiguration() instead would not work: the + // sidebar provider is a per-extension singleton that does not re-read durable + // viewStates when its webview is re-shown, so its in-memory value is the one + // getConfiguration() reports after the reload. + { + const persisted = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + assert.ok(persisted, "Expected persisted viewStates before writing the rehydration marker") + + const marked: NonNullable = { ...persisted } + let markedAny = false + for (const [viewStateId, entry] of Object.entries(marked)) { + if (entry.mode === "ask") { + marked[viewStateId] = { ...entry, mode: "build" } + markedAny = true + } + } + assert.ok(markedAny, "Expected a persisted 'ask' entry to mark before the reload") + await globalThis.api.setGlobalState("viewStates", marked) + } + + // Hide the primary sidebar and let the dispose settle, show it again and let + // the webview recreation settle, then wait for the reloaded webview to report + // the provider's state. + await vscode.commands.executeCommand("workbench.action.toggleSidebarVisibility") + await sleep(2_000) + await vscode.commands.executeCommand("workbench.action.toggleSidebarVisibility") + await sleep(2_000) + await sleep(5_000) + + // (a) The durable entry the marker was written to still exists after the reload. + // A wipe, prune, or re-key of the durable map during the cycle would drop the + // marker even though a looser 'some entry is ask' assertion could still be + // satisfied by an unrelated entry. Poll in case the just-written memento value + // lags a synchronous read. + await waitFor( + () => { + const persisted = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + if (!persisted) { + return false + } + + return Object.entries(persisted).some(([, entry]) => entry.mode === "build") + }, + { timeout: 30_000 }, + ) + const viewStates = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + assert.ok(viewStates, "Expected persisted viewStates to exist after the sidebar webview reload") + assert.ok( + Object.entries(viewStates).some(([, entry]) => entry.mode === "build"), + "Expected the persisted view states to still contain the rehydration marker after the reload", + ) + + // (b) The reloaded sidebar view still reports the pre-reload in-memory mode. + // getConfiguration() always reads the sidebar provider, so a regression that + // wipes the provider's view state during the hide/show cycle surfaces as the + // view falling back to the global mode instead of 'ask'. + assert.strictEqual(globalThis.api.getConfiguration().mode, "ask") + + // (c) Deliberately skipped: asserting that a fresh task started from this view + // inherits the view's mode is not possible here — there is no deterministic + // extension-side getter for a fresh task's inherited mode. startNewTask takes + // an explicit configuration and the Task constructor reads the provider state + // at a timing the extension API does not expose, so any such assertion would + // require a new API surface (out of scope for this test-only unit) or a + // non-deterministic poll. Mode inheritance is already exercised by the two + // isolation tests above through their per-task TaskModeSwitched events and + // the persisted per-view entries they assert on. + }) +}) diff --git a/apps/vscode-e2e/src/types/global.d.ts b/apps/vscode-e2e/src/types/global.d.ts index 5c7fb164ad..a63010fe81 100644 --- a/apps/vscode-e2e/src/types/global.d.ts +++ b/apps/vscode-e2e/src/types/global.d.ts @@ -1,7 +1,10 @@ -import type { RooCodeAPI } from "@roo-code/types" +import type { RooCodeAPI, RooCodeTestOnlyApi } from "@roo-code/types" +// The e2e host activates the extension in test mode, where the activation +// object also exposes the production-gated test-only surface (task ask +// control and raw global-state reads) declared in RooCodeTestOnlyApi. declare global { - var api: RooCodeAPI + var api: RooCodeAPI & RooCodeTestOnlyApi } export {} diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index c2040383e8..6299b4c2c0 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -38,3 +38,32 @@ describe("alwaysDenyUnapprovedCommands global setting", () => { expect(() => globalSettingsSchema.parse({ alwaysDenyUnapprovedCommands: "true" })).toThrow() }) }) + +describe("viewStates global setting", () => { + it("parses a valid per-view record", () => { + const parsed = globalSettingsSchema.parse({ + viewStates: { + "stable-view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1730000000000 }, + "stable-view-2": {}, + }, + }) + expect(parsed.viewStates?.["stable-view-1"]).toEqual({ + mode: "code", + currentApiConfigName: "profile-a", + updatedAt: 1730000000000, + }) + expect(parsed.viewStates?.["stable-view-2"]).toEqual({}) + expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates") + }) + + it("rejects invalid field values inside a view entry", () => { + expect(() => globalSettingsSchema.parse({ viewStates: { v: { mode: 42 } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { currentApiConfigName: 42 } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { updatedAt: "soon" } } })).toThrow() + }) + + it("rejects non-object viewStates values", () => { + expect(() => globalSettingsSchema.parse({ viewStates: "not-a-record" })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: "not-an-entry" } })).toThrow() + }) +}) 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 de23f67491..cdae0cd77c 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" @@ -21,6 +21,7 @@ export interface RooCodeAPI extends EventEmitter { * Starts a new task with an optional initial message and images. * @param task Optional initial task message. * @param images Optional array of image data URIs (e.g., "data:image/webp;base64,..."). + * @param preserveOpenTabs When true, skips the new-tab editor cleanup (revert + close-all), preserving open tabs and dirty/unsaved editors. * @returns The ID of the new task. */ startNewTask({ @@ -28,11 +29,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. @@ -119,7 +122,9 @@ export interface RooCodeAPI extends EventEmitter { */ getConfiguration(): RooCodeSettings /** - * Sets the configuration for the current task. + * Sets the configuration for the extension. Writes target the extension-host (sidebar) view: + * view-local values are pinned to the sidebar's per-view state, while global values are + * written to the shared ContextProxy. * @param values An object containing key-value pairs to set. */ setConfiguration(values: RooCodeSettings): Promise @@ -186,6 +191,38 @@ export interface RooCodeAPI extends EventEmitter { setTerminalProfile(name: string | undefined): void } +/** + * Test-only extension API surface. + * + * These methods expose raw global-state reads and task ask control by ID that + * production callers must not rely on. The implementation is gated at runtime + * against VS Code ExtensionMode.Production, so only test hosts and the e2e + * harness keep access. + */ +export interface RooCodeTestOnlyApi { + /** + * Programmatically approves the pending ask for a task by ID. Intended for use in tests only. + */ + approveTaskAsk(taskId: string): Promise + /** + * Programmatically denies the pending ask for a task by ID. Intended for use in tests only. + */ + denyTaskAsk(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 a value from VS Code globalState. Intended for use in tests only. + */ + getGlobalState(key: K): GlobalState[K] + /** + * Writes a value to VS Code globalState. Intended for use in tests only. + */ + setGlobalState(key: K, value: GlobalState[K]): Promise +} + export interface RooCodeTestAPI extends RooCodeAPI { captureWebviewThemeFixture(): Promise getLatestAnnouncementId(): string diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 16e9751974..3bf98e39bf 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -111,6 +111,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * 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 */ @@ -119,6 +128,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 c0e8509105..a301cbd1fd 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -653,6 +653,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/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..6a1a08b821 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -35,6 +35,14 @@ export const commandIds = [ "popoutButtonClicked", "settingsButtonClicked", + // Editor-tab (popped-out) surface variants of the title-bar buttons. The + // shared ids above target the sidebar click origin, so the tab surface + // needs its own ids (see registerCommands.ts getTabProvider). + "plusButtonClickedInTab", + "settingsButtonClickedInTab", + "marketplaceButtonClickedInTab", + "historyButtonClickedInTab", + "openInNewTab", "newTask", diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index d041dada2e..7f27344980 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,11 +1,23 @@ import type { Mock } from "vitest" import * as vscode from "vscode" -import { ClineProvider } from "../../core/webview/ClineProvider" +import { TelemetryService } from "@roo-code/telemetry" import { WebviewFocusTracker } from "../../core/webview/WebviewFocusTracker" -import { ContextProxy } from "../../core/config/ContextProxy" import { makeExtensionContext } from "../../test-utils/vscode" -import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { ContextProxy } from "../../core/config/ContextProxy" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { MdmService } from "../../services/mdm/MdmService" + +import { + __getLiveTabPanelCountForTests, + __resetLiveTabPanelsForTests, + createClineTabPanel, + getPanel, + getVisibleProviderOrLog, + openClineInNewTab, + registerCommands, + setPanel, +} from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -137,11 +149,20 @@ describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext let mockVisibleProvider: { postMessageToWebview: Mock } - let mockProvider: { postMessageToWebview: Mock; webviewFocusTracker: WebviewFocusTracker } + let mockProvider: { + postMessageToWebview: Mock + evictCurrentTask: Mock + refreshWorkspace: Mock + webviewFocusTracker: WebviewFocusTracker + } let handlers: Record unknown> beforeEach(() => { vi.clearAllMocks() + // clearAllMocks() keeps mock implementations: reset the instance-lookup + // return value so a preceding test's tab double cannot leak into the + // dead-tab drop assertions below. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) handlers = {} mockOutputChannel = { @@ -163,6 +184,8 @@ describe("registerCommands handlers", () => { mockProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), webviewFocusTracker: new WebviewFocusTracker(), } ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) @@ -242,44 +265,114 @@ describe("registerCommands handlers", () => { }, ) - it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { + // The sidebar title-bar handlers target the registered provider (the + // sidebar click origin) directly, not the visible-instance heuristic. + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions on the registered provider", () => { handlers["zoo-code.settingsButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("settings") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "settingsButtonClicked", }) - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible", }) - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledTimes(2) - }) - - it("settingsButtonClicked is a no-op when no visible provider", () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - handlers["zoo-code.settingsButtonClicked"]() - + expect(mockProvider.postMessageToWebview).toHaveBeenCalledTimes(2) expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("historyButtonClicked posts historyButtonClicked action", () => { + it("historyButtonClicked posts historyButtonClicked action on the registered provider", () => { handlers["zoo-code.historyButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("history") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "historyButtonClicked", }) + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("marketplaceButtonClicked posts marketplaceButtonClicked action", () => { + it("marketplaceButtonClicked posts marketplaceButtonClicked action on the registered provider", () => { handlers["zoo-code.marketplaceButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "marketplaceButtonClicked", }) + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + // The `*InTab` handlers serve the `editor/title` menu: they target the + // instance that owns the tracked tab panel, resolved via + // ClineProvider.getInstanceForView. + const tabHandlerCases: { command: string; actions: string[]; telemetry?: string }[] = [ + { + command: "zoo-code.settingsButtonClickedInTab", + actions: ["settingsButtonClicked", "didBecomeVisible"], + telemetry: "settings", + }, + { command: "zoo-code.historyButtonClickedInTab", actions: ["historyButtonClicked"], telemetry: "history" }, + { command: "zoo-code.marketplaceButtonClickedInTab", actions: ["marketplaceButtonClicked"] }, + ] + it.each(tabHandlerCases)( + "$command targets the tab instance for the tracked tab panel", + ({ command, actions, telemetry }) => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + // Retain the tracked tab panel and pin the instance lookup + // against its identity: a handler that resolved the sidebar view + // or any other view must fail instead of passing on the stubbed + // provider result alone. + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + // Identity pin: the lookup must receive the exact tracked panel + // object, not a different object that merely compares equal. + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + for (const action of actions) { + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action }) + } + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledTimes(actions.length) + if (telemetry) { + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith(telemetry) + } + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }, + ) + + // The `*InTab` handlers must no-op when there is no live tab instance: a + // missing or disposed tab must not crash the handler or fall back to + // another instance. Every handler is awaited, so an async handler that + // slipped past its guard (rejecting on the missing instance) fails the + // test instead of settling as an unhandled rejection. + const inTabNoOpCommands = [ + "zoo-code.plusButtonClickedInTab", + "zoo-code.settingsButtonClickedInTab", + "zoo-code.historyButtonClickedInTab", + "zoo-code.marketplaceButtonClickedInTab", + ] + it.each(inTabNoOpCommands)("%s is a no-op when no tab panel is tracked", async (command) => { + await handlers[command]() + + expect(ClineProvider.getInstanceForView as Mock).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it.each(inTabNoOpCommands)("%s is a no-op when the tab instance is disposed", async (command) => { + const disposedPanel = {} as vscode.WebviewPanel + setPanel(disposedPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await handlers[command]() + + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(disposedPanel) + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) it("acceptInput posts acceptInput message", () => { @@ -352,44 +445,189 @@ describe("registerCommands handlers", () => { }) }) - it("focusInput does not post when no sidebar panel is active", async () => { + it("focusInput does not post when no sidebar panel is tracked", async () => { await handlers["zoo-code.focusInput"]() expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - // Representative coverage for the .catch arm on all five void-prefixed - // postMessageToWebview sites in registerCommands.ts (settingsButtonClicked - // posts twice, plus historyButtonClicked, marketplaceButtonClicked, and - // acceptInput). Each handler is synchronous, so the .catch arm runs on a - // microtask; setImmediate ensures all microtasks are flushed before we assert. The + it("focusInput logs the drop when a tab panel is tracked without a live tab instance", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + + await handlers["zoo-code.focusInput"]() + + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance the action is dropped and the + // drop is logged so the silent no-op stays diagnosable. The guard must + // consult the instance lookup for the tracked tab panel (not throw into + // the catch) before dropping. + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "focusInput: no live provider for the tracked tab panel; action dropped", + ) + }) + + it("focusInput posts to the tracked tab provider when a tab panel is tracked alongside the sidebar", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + const tabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(tabProvider) + + await handlers["zoo-code.focusInput"]() + + // The tab owns the focus (focusPanel priority), so the post targets the + // tab's provider and never the sidebar registration. + expect((tabProvider as { postMessageToWebview: Mock }).postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "focusInput", + }) + // Pin the "never the sidebar" claim: the sidebar registration's provider + // double must stay untouched. + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("focusInput posts the focus message on the tab instance when a tab panel is tracked", async () => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + await handlers["zoo-code.focusInput"]() + + // The tab takes selection priority: assert it was selected by identity + // (reference, not structural equality). + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + // No error was logged on the success path. + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("setPanel keeps independent refs: clearing only the tab ref re-enables the sidebar post", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + // The tab ref does not wipe the sidebar ref... (the dead-tab drop is + // logged, and the sidebar still must not receive the message). + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "focusInput: no live provider for the tracked tab panel; action dropped", + ) + + // ...and clearing only the tab ref re-enables the sidebar post. + setPanel(undefined, "tab") + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + // Coverage for the .catch arm on the sidebar title-bar post sites + // (settingsButtonClicked posts twice, plus historyButtonClicked and + // marketplaceButtonClicked) and acceptInput (the visible-provider path). + // Each handler is synchronous, so the .catch arm runs on a microtask; + // setImmediate ensures all microtasks are flushed before we assert. The // log messages carry a `[]` prefix so multi-failure logs // remain unambiguous; the prefix is per-handler, not per-call (both of - // settingsButtonClicked's posts share the same prefix). + // settingsButtonClicked's posts share the same prefix). Each post rejects + // with its own error and call N is pinned to post N, so a mutant that + // alters one catch's message cannot hide behind the other post's + // identical log. it.each([ - { command: "zoo-code.settingsButtonClicked", prefix: "settingsButtonClicked", expectedCalls: 2 }, - { command: "zoo-code.historyButtonClicked", prefix: "historyButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.marketplaceButtonClicked", prefix: "marketplaceButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.acceptInput", prefix: "acceptInput", expectedCalls: 1 }, + { + command: "zoo-code.settingsButtonClicked", + prefix: "settingsButtonClicked", + errorLabels: ["first post", "second post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.historyButtonClicked", + prefix: "historyButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.marketplaceButtonClicked", + prefix: "marketplaceButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { command: "zoo-code.acceptInput", prefix: "acceptInput", errorLabels: ["post"], target: "visible" as const }, ])( "$command logs to outputChannel when postMessageToWebview rejects", - async ({ command, prefix, expectedCalls }) => { - const boom = new Error("boom") - mockVisibleProvider.postMessageToWebview.mockReset() - mockVisibleProvider.postMessageToWebview.mockRejectedValue(boom) + async ({ command, prefix, errorLabels, target }) => { + const post = + target === "sidebar" ? mockProvider.postMessageToWebview : mockVisibleProvider.postMessageToWebview + post.mockReset() + const booms = errorLabels.map((label) => new Error(label)) + booms.forEach((boom) => post.mockRejectedValueOnce(boom)) handlers[command]() - // Flush microtasks so the chained .catch arm runs. + // Flush microtasks so the chained .catch arms run. await new Promise((resolve) => setImmediate(resolve)) - expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(expectedCalls) - expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( - `[${prefix}] postMessageToWebview failed: ${boom}`, - ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(booms.length) + booms.forEach((boom, index) => { + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + index + 1, + `[${prefix}] postMessageToWebview failed: ${boom}`, + ) + }) }, ) + // The two posts reject with distinct errors and the nth-call assertions + // pin each catch's message, so neither template literal can survive + // behind the other post's identical log. + it("settingsButtonClickedInTab logs to outputChannel when postMessageToWebview rejects", async () => { + const booms = [new Error("first post"), new Error("second post")] + const mockTabProvider = { + postMessageToWebview: vi.fn().mockRejectedValueOnce(booms[0]).mockRejectedValueOnce(booms[1]), + } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers["zoo-code.settingsButtonClickedInTab"]() + + // Flush microtasks so the chained .catch arms run. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 1, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[0]}`, + ) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 2, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[1]}`, + ) + }) + + // The history and marketplace InTab catch sites share the identical + // single-post pattern (their sidebar equivalents are covered by the + // it.each above); pin their exact messages too. + it.each([ + { command: "zoo-code.historyButtonClickedInTab", prefix: "historyButtonClickedInTab" }, + { command: "zoo-code.marketplaceButtonClickedInTab", prefix: "marketplaceButtonClickedInTab" }, + ])("$command logs to outputChannel when the tab postMessageToWebview rejects", async ({ command, prefix }) => { + const boom = new Error("post") + const mockTabProvider = { postMessageToWebview: vi.fn().mockRejectedValue(boom) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + // Flush microtasks so the chained .catch arm runs. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(`[${prefix}] postMessageToWebview failed: ${boom}`) + }) + it("toggleAutoApprove logs to outputChannel when postMessageToWebview rejects", async () => { // toggleAutoApprove is `async` and awaits postMessageToWebview inside a // try/catch (rather than relying on a `.catch` microtask like the @@ -407,22 +645,41 @@ describe("registerCommands handlers", () => { ) }) - it("plusButtonClicked calls evictCurrentTask on the visible provider", async () => { - const evictCurrentTask = vi.fn().mockResolvedValue(undefined) - const refreshWorkspace = vi.fn().mockResolvedValue(undefined) - ;(mockVisibleProvider as any).evictCurrentTask = evictCurrentTask - ;(mockVisibleProvider as any).refreshWorkspace = refreshWorkspace - + it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { await handlers["zoo-code.plusButtonClicked"]() - expect(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") + expect(mockProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProvider.refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) - it("plusButtonClicked is a no-op when no visible provider", async () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - // Should not throw even with no visible provider - await handlers["zoo-code.plusButtonClicked"]() + it("plusButtonClickedInTab evicts and posts on the tab instance for the tracked tab panel", async () => { + const mockTabProvider = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + // Same identity pin as the other InTab cases: the eviction must run + // against the provider resolved from the exact tracked tab panel. + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + await handlers["zoo-code.plusButtonClickedInTab"]() + + // Identity pin: the eviction must run against the provider resolved + // from the exact tracked panel object, not a merely-equal stub. + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") + expect(mockTabProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockTabProvider.refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "chatButtonClicked", + }) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) }) @@ -430,6 +687,754 @@ describe("openClineInNewTab", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext + beforeEach(() => { + vi.clearAllMocks() + // clearAllMocks() keeps mock implementations: reset the instance-lookup + // return value so a preceding test's tab double cannot leak into the + // reuse-path assertions below. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "mock", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockContext = { + subscriptions: [], + extensionUri: { path: "/mock/ext" }, + } as unknown as vscode.ExtensionContext + + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel) + + // Reset module-level panel state. + setPanel(undefined, "sidebar") + setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() + }) + + it("creates a webview panel with title 'Zoo Code'", async () => { + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // No tab was tracked, so the reuse path (and its instance lookup) + // must not run. + expect(ClineProvider.getInstanceForView as Mock).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + expect.any(Number), + expect.objectContaining({ + enableScripts: true, + retainContextWhenHidden: true, + }), + ) + }) + + it("reveals the existing tab instead of creating a second panel", async () => { + const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + }) + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockExistingProvider) + + const result = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(result).toBe(mockExistingProvider) + expect(mockPanel.reveal).toHaveBeenCalledTimes(1) + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + expect(mockExistingProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + }) + + it("creates a new tab panel when the tracked tab's provider has been disposed", async () => { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + }) + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The reuse path must resolve the tracked panel (not skip the lookup): + // without this assertion the test would also pass if the handler + // stopped consulting getInstanceForView at all. + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(mockPanel) + expect(mockPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("re-points the tracked tab ref at the panel that becomes active", async () => { + // Panel A is created first and tracked... + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // ...then panel B is created, which re-points the tracked tab ref. + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Activating A must reassign the tracked tab ref to A's panel... + const stateChange = (panelA.onDidChangeViewState as Mock).mock.calls[0]![0] as (e: { + webviewPanel: vscode.WebviewPanel + }) => void + // Activate panelA in place and pass it through: the production handler + // tracks e.webviewPanel directly, so a clone would let a handler that + // copies the event panel still pass the identity check below. + Object.assign(panelA, { active: true, visible: true }) + stateChange({ webviewPanel: panelA }) + + // ...so plusButtonClickedInTab targets A's provider, not B's. + const mockProviderA = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + // Require the tracked panel by identity: a handler that clones the + // state-change panel can no longer resolve the provider. + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + view === panelA ? mockProviderA : undefined, + ) + const handlers = new Map unknown>() + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + handlers.set(id, cb) + return { dispose: vi.fn() } + }, + ) + const mockSidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: mockSidebarProvider, + }) + + await handlers.get("zoo-code.plusButtonClickedInTab")!() + + expect(mockProviderA.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { + ;(MdmService.getInstance as Mock).mockImplementation(() => { + throw new Error("MDM service not initialized") + }) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The creation must survive the MDM lookup failure: the provider is + // constructed with an undefined MDM service and the tab panel is + // still created. + const ctor = vi.mocked(ClineProvider) + expect(ctor.mock.instances[0]).toBeDefined() + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + undefined, + ) + expect(provider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The fallback is observable in the output channel. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openClineInNewTab] MDM service unavailable, continuing without it: Error: MDM service not initialized", + ) + }) + + it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { + // The vscode mock factory declares a mutable visibleTextEditors slot that the + // readonly public API type hides, so seed it through Object.assign. + Object.assign(vscode.window, { visibleTextEditors: [] }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.lockEditorGroup") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + vscode.ViewColumn.Two, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [mockContext.extensionUri], + }, + ) + + // The panel icon points at the extension's asset files. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + iconPath?: { light: { path: string }; dark: { path: string } } + } + expect(panel.iconPath).toEqual({ + light: { path: "assets/icons/panel_light.png" }, + dark: { path: "assets/icons/panel_dark.png" }, + }) + }) + + it("treats editors without a viewColumn as column 0 when computing the target column", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixture keeps that single field. + const editorWithoutColumn = Object.assign({} as vscode.TextEditor, { viewColumn: undefined }) + Object.assign(vscode.window, { + visibleTextEditors: [editorWithoutColumn], + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // lastCol falls back to 0, so the panel lands on column 1 instead of + // opening a new editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 1, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("places the tab panel one column right of the rightmost visible editor", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixtures keep that single field. + Object.assign(vscode.window, { + visibleTextEditors: [ + Object.assign({} as vscode.TextEditor, { viewColumn: 1 }), + Object.assign({} as vscode.TextEditor, { viewColumn: 3 }), + ], + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // lastCol is 3, so the panel lands on column 4 without opening a new + // editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 4, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { + // MdmService has a private constructor, so pin a sentinel stand-in. + const mockMdm = Object.assign({} as MdmService, { name: "mock-mdm" }) + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const ctor = vi.mocked(ClineProvider) + expect(ctor).toHaveBeenCalledTimes(1) + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + mockMdm, + ) + expect(provider).toBe(ctor.mock.instances[0]) + }) + + it("posts didBecomeVisible only for visible state changes and clears the tracked tab on dispose", async () => { + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Retain the panel returned during creation and pin the tracked tab + // against it with identity (not a weak defined check), so a wrong or + // duplicated tracked panel fails before the dispose assertions. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + onDidChangeViewState: Mock + onDidDispose: Mock + } + expect(getPanel()).toBe(panel) + + const stateHandler = panel.onDidChangeViewState.mock.calls[0][0] as (event: { + webviewPanel: { visible: boolean; webview: { postMessage: (message: unknown) => void } } + }) => void + const visibleEvent = { webviewPanel: { visible: true, webview: { postMessage: vi.fn() } } } + stateHandler(visibleEvent) + expect(visibleEvent.webviewPanel.webview.postMessage).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + + const hiddenEvent = { webviewPanel: { visible: false, webview: { postMessage: vi.fn() } } } + stateHandler(hiddenEvent) + expect(hiddenEvent.webviewPanel.webview.postMessage).not.toHaveBeenCalled() + + const disposeHandler = panel.onDidDispose.mock.calls[0][0] as () => void + disposeHandler() + expect(getPanel()).toBeUndefined() + }) + + it("tracks the activated older tab so tab commands target its provider", async () => { + // Capture each created panel: panel A is created first, panel B second, + // so B (the newest) is the tracked tab after creation. + const createdPanels: Array<{ + webview: { postMessage: Mock } + onDidChangeViewState: Mock + onDidDispose: Mock + active?: boolean + visible?: boolean + }> = [] + ;(vscode.window.createWebviewPanel as Mock).mockImplementation(() => { + const panel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + createdPanels.push(panel) + return panel + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(getPanel()).toBe(createdPanels[1]) + + // Activating the older panel A must re-track the tab to A. VS Code passes + // the panel whose view state changed, so the event carries panel A itself. + const stateHandlerA = createdPanels[0].onDidChangeViewState.mock.calls[0][0] as (event: { + webviewPanel: (typeof createdPanels)[number] + }) => void + Object.assign(createdPanels[0], { active: true, visible: true }) + stateHandlerA({ webviewPanel: createdPanels[0] }) + expect(getPanel()).toBe(createdPanels[0]) + + // A tab command now resolves the provider through panel A, not B. + const makeProvider = () => ({ + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + }) + const providerA = makeProvider() + const providerB = makeProvider() + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((panel: unknown) => + panel === createdPanels[0] ? providerA : providerB, + ) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, callback: (...args: unknown[]) => unknown) => { + commandHandlers[id] = callback + return { dispose: vi.fn() } + }, + ) + registerCommands({ context: mockContext, outputChannel: mockOutputChannel, provider: {} as ClineProvider }) + + await commandHandlers["zoo-code.plusButtonClickedInTab"]() + + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(createdPanels[0]) + expect((providerA as { postMessageToWebview: Mock }).postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "chatButtonClicked", + }) + expect((providerB as { postMessageToWebview: Mock }).postMessageToWebview).not.toHaveBeenCalled() + }) + + it("re-points the tracked tab ref at the remaining live panel when the tracked panel closes", async () => { + // Panel A is created and tracked first... + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // ...then panel B opens in a different group: B becomes active, so the + // state-change handler re-points the tracked ref at B. + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + Object.assign(panelB, { active: true, visible: true }) + ;(panelB.onDidChangeViewState as Mock).mock.calls[0]![0]!({ webviewPanel: panelB }) + expect(getPanel()).toBe(panelB) + + // Closing the tracked panel (B) emits no state change for A, so the dispose + // handler alone must re-point the tracked ref at the remaining live panel. + Object.assign(panelA, { visible: true }) + const disposeB = (panelB.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeB() + expect(getPanel()).toBe(panelA) + + // The re-pointed panel must actually serve the tab-scoped commands: the + // command handler resolves the provider from the tracked panel, so panel + // A's provider receives the message even though B was created last. + const replacementProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(replacementProvider) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + await commandHandlers["zoo-code.historyButtonClickedInTab"]() + expect(replacementProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "historyButtonClicked", + }) + + // Closing the last live tab clears the ref again. + const disposeA = (panelA.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeA() + expect(getPanel()).toBeUndefined() + }) + + it("re-points the tracked tab at the best remaining live panel on close", async () => { + const makePanel = (marker: string) => + Object.assign({} as vscode.WebviewPanel, { + marker, + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + const openThree = async (p1: vscode.WebviewPanel, p2: vscode.WebviewPanel, p3: vscode.WebviewPanel) => { + ;(vscode.window.createWebviewPanel as Mock) + .mockReturnValueOnce(p1) + .mockReturnValueOnce(p2) + .mockReturnValueOnce(p3) + // A preceding test's instance-lookup double must not leak into the reuse + // path: each open here creates a fresh panel. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + // The last created panel becomes the tracked tab. + ;(p3.onDidChangeViewState as Mock).mock.calls[0]![0]!({ webviewPanel: p3 }) + expect(getPanel()).toBe(p3) + return p3 + } + const disposeTracked = (p: vscode.WebviewPanel) => { + const handler = (p.onDidDispose as Mock).mock.calls[0]![0] as () => void + handler() + } + + // Phase 1 — an active remaining panel wins over a visible one, even + // while the active panel itself is not visible: during a view-state + // transition active and visible can diverge, and the active panel is + // the one the user was last looking at, so it must win the re-pointing. + const panelA = makePanel("phase-1-A") + const panelD = makePanel("phase-1-D") + const panelC = makePanel("phase-1-C") + await openThree(panelA, panelD, panelC) + Object.assign(panelA, { active: true, visible: false }) + Object.assign(panelD, { active: false, visible: true }) + disposeTracked(panelC) + expect(getPanel()).toBe(panelA) + + // Phase 2 — a visible remaining panel wins over a hidden one. + __resetLiveTabPanelsForTests() + setPanel(undefined, "tab") + const panelE = makePanel("phase-2-E") + const panelV = makePanel("phase-2-V") + const panelC2 = makePanel("phase-2-C") + await openThree(panelE, panelV, panelC2) + Object.assign(panelE, { active: false, visible: false }) + Object.assign(panelV, { active: false, visible: true }) + disposeTracked(panelC2) + expect(getPanel()).toBe(panelV) + + // Phase 3 — with no active or visible remaining panel, the first remaining + // panel keeps the tracked ref: a panel can be open but hidden behind another + // editor group (neither active nor visible), and dropping the ref here would + // let the next open create a second panel instead of revealing it. + __resetLiveTabPanelsForTests() + setPanel(undefined, "tab") + const panelH1 = makePanel("phase-3-H1") + const panelH2 = makePanel("phase-3-H2") + const panelC3 = makePanel("phase-3-C") + await openThree(panelH1, panelH2, panelC3) + Object.assign(panelH1, { active: false, visible: false }) + Object.assign(panelH2, { active: false, visible: false }) + disposeTracked(panelC3) + expect(getPanel()).toBe(panelH1) + }) + + it("serializes concurrent opens so overlapping calls create one panel and share one provider", async () => { + const [first, second] = await Promise.all([ + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ]) + + // Overlapping "Open in editor" calls must share the in-flight + // creation: exactly one tab panel is created and both callers receive + // the same constructed provider. Pinning both results against the + // mocked constructor (not just against each other) keeps the test + // failing if the shared result is undefined. + const ctor = vi.mocked(ClineProvider) + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("shares one in-flight creation when openInNewTab and popoutButtonClicked start before it resolves", async () => { + // Defer the first creation at ContextProxy.getInstance so both command + // handlers can start while the creation is still in flight. + let resolveContextProxy!: () => void + ;(ContextProxy.getInstance as Mock).mockReturnValue( + new Promise((resolve) => { + resolveContextProxy = resolve + }), + ) + + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + const sidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: sidebarProvider, + }) + + const started = [commandHandlers["zoo-code.openInNewTab"](), commandHandlers["zoo-code.popoutButtonClicked"]()] + + // While the shared creation is suspended at ContextProxy.getInstance, + // neither caller has created a panel yet. + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + + resolveContextProxy() + const [first, second] = await Promise.all(started) + + // Both command entry points await the shared in-flight creation: + // exactly one tab panel is created and both results are the same + // constructed provider. + const ctor = vi.mocked(ClineProvider) + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("creates a fresh panel for a new call once the previous creation settled and its provider disposed", async () => { + // The first open settles and tracks its panel. + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The tracked provider is disposed, so the next open cannot reuse the + // existing tab: the settled (and cleared) in-flight promise must not + // be returned, and a fresh panel is created. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + const secondProvider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const ctor = vi.mocked(ClineProvider) + const second = ctor.mock.instances[1] + expect(second).toBeDefined() + expect(secondProvider).toBe(second) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + }) + + it("keeps the replacement panel tracked when a stale panel's disposal fires late", async () => { + // Capture each created panel so the first panel's (stale) dispose + // handler can fire after the replacement is already tracked. + const createdPanels: { onDidDispose: Mock }[] = [] + ;(vscode.window.createWebviewPanel as Mock).mockImplementation(() => { + const panel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + createdPanels.push(panel) + return panel + }) + + // First open creates and tracks panel A. + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(getPanel()).toBe(createdPanels[0]) + + // Panel A's provider is disposed before the second open, so the + // second open creates the replacement panel B. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(getPanel()).toBe(createdPanels[1]) + + // Panel A's stale dispose handler fires after the replacement is + // tracked; it must not clobber the replacement's ref. + createdPanels[0].onDidDispose.mock.calls[0][0]() + + expect(getPanel()).toBe(createdPanels[1]) + + // Tab-surface commands still reach the provider that owns the + // replacement panel after the stale disposal. + const replacementProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(replacementProvider) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + await commandHandlers["zoo-code.historyButtonClickedInTab"]() + expect(replacementProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "historyButtonClicked", + }) + }) +}) + +describe("openClineInNewTab initialization failure", () => { + let mockOutputChannel: vscode.OutputChannel + let mockContext: vscode.ExtensionContext + beforeEach(() => { vi.clearAllMocks() @@ -459,9 +1464,15 @@ describe("openClineInNewTab", () => { // Reset module-level panel state. setPanel(undefined, "sidebar") setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() }) it("creates a webview panel with title 'Zoo Code'", async () => { + // Pin the MdmService sentinel locally: the module mock's implementation leaks + // across describes once another test set it, so the constructor's sixth + // argument must be asserted against a deterministic double. + const mockMdm = Object.assign({} as MdmService, { name: "mock-mdm" }) + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm) const webviewFocusTracker = new WebviewFocusTracker() await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel, webviewFocusTracker }) expect(ClineProvider).toHaveBeenCalledWith( @@ -470,7 +1481,7 @@ describe("openClineInNewTab", () => { "editor", await ContextProxy.getInstance(mockContext), webviewFocusTracker, - undefined, + mockMdm, ) expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( @@ -483,4 +1494,113 @@ describe("openClineInNewTab", () => { }), ) }) + + it("cleans up the tracked refs and disposes the provider when initialization fails", async () => { + const resolveSpy = vi + .spyOn(ClineProvider.prototype, "resolveWebviewView") + .mockRejectedValue(new Error("init failed")) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockResolvedValue(undefined) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("init failed") + + // The half-registered panel must not stay tracked: the live registry + // drops it, the tracked tab ref is cleared (identity-guarded), and the + // provider is disposed so the failed surface leaves no listeners or + // state behind. + expect(__getLiveTabPanelCountForTests()).toBe(0) + expect(getPanel()).toBeUndefined() + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) +}) + +describe("createClineTabPanel", () => { + let mockOutputChannel: vscode.OutputChannel + let mockContext: vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "mock", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockContext = { + subscriptions: [], + extensionUri: { path: "/mock/ext" }, + } as unknown as vscode.ExtensionContext + + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel) + + // Reset module-level panel state. + setPanel(undefined, "sidebar") + setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() + }) + + it("always creates a fresh panel even when a tab is already tracked", async () => { + const mockTrackedPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WebviewPanel + setPanel(mockTrackedPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue({ postMessageToWebview: vi.fn() }) + + await createClineTabPanel({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(mockTrackedPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("cleans up the tracked refs and disposes the provider when initialization fails", async () => { + const resolveSpy = vi + .spyOn(ClineProvider.prototype, "resolveWebviewView") + .mockRejectedValue(new Error("init failed")) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockResolvedValue(undefined) + + await expect( + createClineTabPanel({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("init failed") + + // The half-registered panel must not stay tracked: the live registry + // drops it, the tracked tab ref is cleared (identity-guarded), and the + // provider is disposed so the failed surface leaves no listeners or + // state behind. + expect(__getLiveTabPanelCountForTests()).toBe(0) + expect(getPanel()).toBeUndefined() + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 9ee3443f9a..206e983858 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import delay from "delay" -import type { CommandId } from "@roo-code/types" +import type { CommandId, ExtensionMessage } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { getCommand } from "../utils/commands" @@ -30,6 +30,36 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl let sidebarPanel: vscode.WebviewView | undefined = undefined let tabPanel: vscode.WebviewPanel | undefined = undefined +// Tab panels that are still open. When the tracked panel closes, its dispose +// handler re-points the tracked ref at one of these: the remaining panel may +// not emit onDidChangeViewState (its own visibility did not change), so +// without this the tab-scoped title-bar commands and focusInput would be left +// without a target. +const liveTabPanels = new Set() + +/** + * Test-only: clears the module-level registry so tab panels created by one + * spec cannot leak into another's tracked-panel re-pointing. + */ +export function __resetLiveTabPanelsForTests(): void { + liveTabPanels.clear() +} + +/** + * Test-only: the number of tab panels currently in the live registry, so a + * spec can assert the cleanup of a failed or closed panel without probing + * through a close of a live panel. + */ +export function __getLiveTabPanelCountForTests(): number { + return liveTabPanels.size +} + +// In-flight "open in editor" creation shared by overlapping calls: a +// double-click starts before the first call tracks its new panel, so +// concurrent callers must share one creation instead of racing to create +// two tab panels. +let pendingTabPanelCreation: Promise | undefined + /** * Get the currently active panel * @returns WebviewPanel或WebviewView @@ -39,7 +69,12 @@ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined } /** - * Set panel references + * Set panel references. + * + * The two refs are independent: each surface keeps its own ref for its whole + * lifetime, so resolving the sidebar view never wipes a live tab panel (and + * vice versa). Callers pass `undefined` only when the surface itself is + * disposed (see the `onDidDispose` wiring in `openClineInNewTab`). */ export function setPanel( newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, @@ -47,13 +82,22 @@ export function setPanel( ): void { if (type === "sidebar") { sidebarPanel = newPanel as vscode.WebviewView - tabPanel = undefined } else { tabPanel = newPanel as vscode.WebviewPanel - sidebarPanel = undefined } } +/** + * The instance that owns the tracked tab panel, if it is still alive. + * + * Title-bar commands on the editor-tab surface use this instead of the + * visible-instance heuristic, so a click on the tab's title bar always + * targets that tab even when the sidebar is visible side-by-side. + */ +function getTabProvider(): ClineProvider | undefined { + return tabPanel ? ClineProvider.getInstanceForView(tabPanel) : undefined +} + export type RegisterCommandOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel @@ -83,27 +127,58 @@ export const registerCommands = (options: RegisterCommandOptions) => { // `filePath?: string`, others take none) and VS Code dispatches positional // args dynamically. type CommandCallback = (...args: any[]) => unknown + +// Posts each action in order to the target instance. Failures are logged +// (not thrown) with the handler-specific prefix so a failed post stays +// attributable in the output channel. +const postActions = ( + outputChannel: vscode.OutputChannel, + target: ClineProvider, + actions: readonly NonNullable[], + logPrefix: string, +) => { + for (const action of actions) { + void target + .postMessageToWebview({ type: "action", action }) + .catch((error) => outputChannel.appendLine(`[${logPrefix}] postMessageToWebview failed: ${error}`)) + } +} + const getCommandsMap = ({ context, outputChannel, provider, }: RegisterCommandOptions): Record, CommandCallback> => ({ activationCompleted: () => {}, + // The `view/title` menu is scoped to the sidebar view, so the click + // origin of these handlers is the sidebar provider wired in at + // activation (`provider`). Target it directly instead of the + // visible-instance heuristic, which would follow the user's focus to a + // tab instance when both surfaces are open side-by-side. The `*InTab` + // variants serve the `editor/title` menu and target the tab instance + // through `getTabProvider()` instead. plusButtonClicked: async () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("plus") - if (!visibleProvider) { + await provider.evictCurrentTask() + await provider.refreshWorkspace() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + // Send focusInput action immediately after chatButtonClicked + // This ensures the focus happens after the view has switched + await provider.postMessageToWebview({ type: "action", action: "focusInput" }) + }, + plusButtonClickedInTab: async () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("plus") - await visibleProvider.evictCurrentTask() - await visibleProvider.refreshWorkspace() - await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - // Send focusInput action immediately after chatButtonClicked - // This ensures the focus happens after the view has switched - await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + await tabProvider.evictCurrentTask() + await tabProvider.refreshWorkspace() + await tabProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) }, popoutButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("popout") @@ -113,43 +188,50 @@ const getCommandsMap = ({ openInNewTab: () => openClineInNewTab({ context, outputChannel, webviewFocusTracker: provider.webviewFocusTracker }), settingsButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("settings") - if (!visibleProvider) { + // Also explicitly post the visibility message to trigger scroll reliably. + postActions(outputChannel, provider, ["settingsButtonClicked", "didBecomeVisible"], "settingsButtonClicked") + }, + settingsButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("settings") - void visibleProvider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) - // Also explicitly post the visibility message to trigger scroll reliably - void visibleProvider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + postActions( + outputChannel, + tabProvider, + ["settingsButtonClicked", "didBecomeVisible"], + "settingsButtonClickedInTab", + ) }, historyButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("history") - if (!visibleProvider) { + postActions(outputChannel, provider, ["historyButtonClicked"], "historyButtonClicked") + }, + historyButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("history") - void visibleProvider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + postActions(outputChannel, tabProvider, ["historyButtonClicked"], "historyButtonClickedInTab") }, marketplaceButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) - if (!visibleProvider) return - void visibleProvider - .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, provider, ["marketplaceButtonClicked"], "marketplaceButtonClicked") + }, + marketplaceButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { + return + } + postActions(outputChannel, tabProvider, ["marketplaceButtonClicked"], "marketplaceButtonClickedInTab") }, newTask: (params: { prompt?: string } | null | undefined) => handleNewTask(params, provider.webviewFocusTracker), setCustomStoragePath: async () => { @@ -176,8 +258,22 @@ const getCommandsMap = ({ try { await focusPanel(tabPanel, sidebarPanel) - // Send focus input message only for sidebar panels - if (sidebarPanel && getPanel() === sidebarPanel) { + // Post to the surface focusPanel selected: the tab takes + // selection priority, so the sidebar is targeted only when no + // tab panel is tracked. + if (tabPanel) { + const tabProvider = getTabProvider() + if (tabProvider) { + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + } else { + // The tracked tab panel has no live provider (its instance was + // disposed while the panel ref survived). focusPanel already + // revealed the tab surface, so posting to the sidebar would + // focus a surface that is not on screen: log the drop instead + // of swallowing it silently. + outputChannel.appendLine("focusInput: no live provider for the tracked tab panel; action dropped") + } + } else if (sidebarPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -226,7 +322,70 @@ type OpenClineInNewTabOptions = { webviewFocusTracker: WebviewFocusTracker } -export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTracker }: OpenClineInNewTabOptions) => { +export const openClineInNewTab = async (options: OpenClineInNewTabOptions) => { + // Serialize overlapping "Open in editor" calls: a double-click starts + // before the first call tracks its new panel, so without a shared + // in-flight creation both calls would race to create two tab panels. + // The shared promise is stored before the creation body awaits + // ContextProxy.getInstance, so every caller started while the creation + // is in flight — openInNewTab and popoutButtonClicked both dispatch + // through here — awaits it instead of creating a second panel: exactly + // one panel is created and every caller receives the same provider. + if (pendingTabPanelCreation) { + return pendingTabPanelCreation + } + + const creation = createTabPanelUnlocked(options) + pendingTabPanelCreation = creation + + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path in createTabPanelUnlocked then takes over + // for the tracked panel. Guard the clear so this settlement cannot + // clobber a replacement already stored in the slot. That clobber is + // unreachable in single-threaded settlement order: while the slot + // holds this in-flight creation, every other caller receives that + // same promise (guard above), so no replacement can be stored before + // this finally block runs — the equality check pins the invariant. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined + } + } +} + +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async (options: OpenClineInNewTabOptions) => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. + if (tabPanel) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + } + + return createClineTabPanel(options) +} + +/** + * Create a fresh "Zoo Code" tab panel and provider. + * + * Unlike `openClineInNewTab`, this never reuses the tracked tab: callers that + * must not evict the task an existing tab is already serving (such as + * `API.startNewTask({ newTab: true })`) get a new surface here so concurrent + * tab tasks stay independent. The UI "Open in editor" button keeps the + * single-tab reuse behavior. + */ +export const createClineTabPanel = async ({ + context, + outputChannel, + webviewFocusTracker, +}: OpenClineInNewTabOptions) => { // (This example uses webviewProvider activation event which is necessary to // deserialize cached webview, but since we use retainContextWhenHidden, we // don't need to use that event). @@ -237,8 +396,9 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr let mdmService: MdmService | undefined try { mdmService = MdmService.getInstance() - } catch { - // MDM service not initialized, which is fine - extension can work without it + } catch (error) { + // MDM service unavailable: log the fallback and continue without it. + outputChannel.appendLine(`[openClineInNewTab] MDM service unavailable, continuing without it: ${error}`) mdmService = undefined } @@ -270,6 +430,7 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr // Save as tab type panel. setPanel(newPanel, "tab") + liveTabPanels.add(newPanel) // TODO: Use better svg icon with light and dark variants (see // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). @@ -278,12 +439,21 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), } - await tabProvider.resolveWebviewView(newPanel) - + // Register the cleanup listeners before the first initialization await: a + // close that lands while resolveWebviewView is still running must be able + // to find the tracked-ref re-pointing already installed, or the panel + // would stay tracked (and the provider registered) after disposal. // Add listener for visibility changes to notify webview newPanel.onDidChangeViewState( (e) => { const panel = e.webviewPanel + // Re-point the tracked tab ref at the panel the user is actually + // looking at: several tab panels can stay visible at once, but + // only the active one is the current tab, and the title-bar + // commands must resolve that instance, not the last created one. + if (panel.active) { + setPanel(panel, "tab") + } if (panel.visible) { panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx } @@ -292,15 +462,47 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr context.subscriptions, // Register listener for disposal ) - // Handle panel closing events. + // Handle panel closing events: clear the tracked ref only if this panel + // is still the tracked one, so a late disposal of an already-replaced + // panel cannot clobber the replacement's ref. newPanel.onDidDispose( () => { - setPanel(undefined, "tab") + liveTabPanels.delete(newPanel) + if (tabPanel === newPanel) { + // Re-point the tracked ref at the best remaining live tab: an active + // panel first, then a visible one, then any remaining panel — a panel + // can be open but hidden behind another editor group (neither active + // nor visible), and clearing the tracked ref here would let the next + // open create a second panel instead of revealing the one already + // serving a task. The identity guard above keeps a late disposal of an + // already-replaced panel from clobbering the replacement's ref. + const remaining = + [...liveTabPanels].find((panel) => panel.active) || + [...liveTabPanels].find((panel) => panel.visible) || + [...liveTabPanels][0] + setPanel(remaining, "tab") + } }, null, context.subscriptions, // Also register dispose listener ) + try { + await tabProvider.resolveWebviewView(newPanel) + } catch (error) { + // Initialization failed: drop the half-registered panel from the live + // registry and the tracked ref (if this panel is still the tracked one), + // and dispose the provider so the failed surface leaves no listeners or + // state behind. The panel's own disposal listener (installed above) has + // already handled a user close that landed during initialization. + liveTabPanels.delete(newPanel) + if (tabPanel === newPanel) { + setPanel(undefined, "tab") + } + await tabProvider.dispose() + throw error + } + // Lock the editor group so clicking on files doesn't open them over the panel. await delay(100) await vscode.commands.executeCommand("workbench.action.lockEditorGroup") diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1250a7baca..0c68237675 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // Per-view selection state is machine-local: it keeps flowing through the + // normal runtime and pruning paths but must not transfer between settings. + viewStates: true, }) export class ContextProxy { @@ -43,6 +46,13 @@ export class ContextProxy { private stateCache: GlobalState private secretCache: SecretState + + // Per-key write tokens: a failed durable write rolls its cache value back only if it is + // still the current write for that key. Value comparison alone would let an older + // failed write restore its stale previous value after a newer same-value write + // succeeded; refresh/reset replace the caches, so their pending writes are dropped. + private stateWriteTokens = new Map() + private secretWriteTokens = new Map() private _isInitialized = false constructor(context: vscode.ExtensionContext) { @@ -365,8 +375,21 @@ export class ContextProxy { return this.originalContext.globalState.update(key, value) } + // Update the cache first so reads reflect the new value immediately, but restore + // the previous value if the durable write fails so the cache cannot diverge from + // storage. + const previous = this.stateCache[key] + const token = Symbol() + this.stateWriteTokens.set(key, token) this.stateCache[key] = value - return this.originalContext.globalState.update(key, value) + return Promise.resolve(this.originalContext.globalState.update(key, value)).catch((error) => { + // Only the write that is still current for this key may roll back: a newer + // write (or a refresh/reset that dropped the token) already superseded it. + if (this.stateWriteTokens.get(key) === token) { + this.stateCache[key] = previous + } + throw error + }) } private getAllGlobalState(): GlobalState { @@ -383,13 +406,27 @@ export class ContextProxy { } storeSecret(key: SecretStateKey, value?: string) { - // Update cache. + // Update the cache first so reads reflect the new value immediately, but restore + // the previous value if the durable write fails so the cache cannot diverge from + // storage. + const previous = this.secretCache[key] + const token = Symbol() + this.secretWriteTokens.set(key, token) this.secretCache[key] = value // Write directly to context. - return value === undefined - ? this.originalContext.secrets.delete(key) - : this.originalContext.secrets.store(key, value) + return Promise.resolve( + value === undefined + ? this.originalContext.secrets.delete(key) + : this.originalContext.secrets.store(key, value), + ).catch((error) => { + // Only the write that is still current for this key may roll back (see + // updateGlobalState): a newer write or a secrets refresh superseded it. + if (this.secretWriteTokens.get(key) === token) { + this.secretCache[key] = previous + } + throw error + }) } /** @@ -397,6 +434,9 @@ export class ContextProxy { * This is useful when you need to ensure the cache has the latest values */ async refreshSecrets(): Promise { + // The refresh re-syncs the whole secret cache from storage: pending pre-refresh + // writes must not roll back over the freshly loaded values. + this.secretWriteTokens.clear() const promises = [ ...SECRET_STATE_KEYS.map(async (key) => { try { @@ -590,6 +630,8 @@ export class ContextProxy { // Clear in-memory caches this.stateCache = {} this.secretCache = {} + this.stateWriteTokens.clear() + this.secretWriteTokens.clear() await Promise.all([ ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index e0f272a578..33bcb6be34 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -54,6 +54,19 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +/** + * Signals that a profile's configuration no longer exists. Callers that treat an + * already-deleted profile as an idempotent no-op branch on this type instead of + * matching error message text, which a profile name containing the phrase could + * otherwise spoof. + */ +export class ProviderSettingsNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderSettingsNotFoundError" + } +} + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() @@ -510,7 +523,7 @@ export class ProviderSettingsManager { const providerProfiles = await this.load() if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config '${name}' not found`) } if (Object.keys(providerProfiles.apiConfigs).length === 1) { @@ -521,6 +534,11 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + // A missing config is a caller-meaningful signal, not a failure: rethrow it + // unwrapped so callers can branch on the type instead of message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2319a6b1a5..234e9dd9a9 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -157,6 +157,130 @@ describe("ContextProxy", () => { }) describe("updateGlobalState", () => { + it("restores the previous cached value when the durable write fails", async () => { + await proxy.updateGlobalState("apiProvider", "deepseek") + + mockGlobalState.update.mockRejectedValueOnce(new Error("storage failed")) + + await expect(proxy.updateGlobalState("apiProvider", "anthropic")).rejects.toThrow("storage failed") + + // A failed durable write must not leave the cache ahead of storage. + expect(proxy.getGlobalState("apiProvider")).toBe("deepseek") + }) + + it("does not roll back over a newer same-value write when the older write fails", async () => { + // Seed the cache so two overlapping writes carry the same new value: the + // stale-previous restore is only visible when value comparison matches. + await proxy.updateGlobalState("apiProvider", "deepseek") + + // The older write's durable update fails while a newer same-key write is + // in flight: its failure must not restore its previous value over the + // newer write's success (its write token was superseded). + mockGlobalState.update.mockRejectedValueOnce(new Error("storage failed")) + const older = proxy.updateGlobalState("apiProvider", "anthropic") + const newer = proxy.updateGlobalState("apiProvider", "anthropic") + + await expect(older).rejects.toThrow("storage failed") + await newer + + expect(proxy.getGlobalState("apiProvider")).toBe("anthropic") + }) + + it("does not roll back a secret over a newer same-value write when the older write fails", async () => { + await proxy.storeSecret("apiKey", "old-key") + + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + const older = proxy.storeSecret("apiKey", "new-key") + const newer = proxy.storeSecret("apiKey", "new-key") + + await expect(older).rejects.toThrow("secrets failed") + await newer + + expect(proxy.getSecret("apiKey")).toBe("new-key") + }) + + it("restores the previous secret when the durable write fails", async () => { + await proxy.storeSecret("apiKey", "old-key") + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + + await expect(proxy.storeSecret("apiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBe("old-key") + }) + + it("does not roll a pre-refresh secret write back over refreshed values", async () => { + await proxy.storeSecret("apiKey", "old-key") + // Defer the durable failure until after the refresh completes: a failure that + // lands before the re-sync runs its rollback microtask first, so the re-sync + // would mask the stale restore regardless of the token guard. + let failStore!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failStore = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "stale-write") + + // The refresh re-syncs the cache from storage and drops the pending write's + // token: its later failure must not restore its previous value. + mockSecrets.get.mockResolvedValueOnce("refreshed-value") + await proxy.refreshSecrets() + + failStore(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + expect(proxy.getSecret("apiKey")).toBe("refreshed-value") + }) + + it("does not restore a pending write into a reset cache", async () => { + // The pending write fails after resetAllState cleared the token: without the + // token guard its stale previous value would resurrect in a cache the reset + // just wiped. The failure is deferred (see the pre-refresh case) so the + // rollback cannot be masked by the re-initialization's own cache writes. + await proxy.updateGlobalState("apiProvider", "deepseek") + let failUpdate!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failUpdate = reject + }), + ) + const pending = proxy.updateGlobalState("apiProvider", "anthropic") + + await proxy.resetAllState() + + failUpdate(new Error("storage failed")) + await expect(pending).rejects.toThrow("storage failed") + + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + }) + + it("does not restore a pending secret write into a reset cache", async () => { + // Same deferred-failure discipline as the state case: the pending secret write + // fails after resetAllState cleared the secret token, so without that clear its + // stale previous value would resurrect in the re-seeded secret cache. + await proxy.storeSecret("apiKey", "old-key") + let failStore!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failStore = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "stale-write") + + // Reset re-initialization reads through secrets.get: seed it with undefined so + // the re-seeded cache holds nothing for the assertion to distinguish from the + // stale restore. + mockSecrets.get.mockResolvedValue(undefined) + await proxy.resetAllState() + + failStore(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBeUndefined() + }) + it("should update state directly in original context", async () => { await proxy.updateGlobalState("apiProvider", "deepseek") @@ -721,4 +845,20 @@ Output only the summary of the conversation so far, without any additional comme expect(customSupportPromptsUpdateCalls.length).toBe(0) }) }) + + describe("export", () => { + it("should exclude viewStates from the exported settings", async () => { + await proxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + await proxy.setValue("customInstructions", "global instructions") + + const exported = await proxy.export() + + // Per-view selection state is machine-local and must never transfer + // between settings, while ordinary global settings keep round-tripping. + expect(exported).not.toHaveProperty("viewStates") + expect(exported?.customInstructions).toBe("global instructions") + }) + }) }) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 3e5a0cd22c..c545cf2b5a 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,7 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -949,7 +949,7 @@ describe("ProviderSettingsManager", () => { expect(storedConfig.apiConfigs.default.id).toBeTruthy() }) - it("should throw error when trying to delete non-existent config", async () => { + it("should throw the typed not-found error when trying to delete a non-existent config", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ currentApiConfigName: "default", @@ -957,6 +957,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index f50fb29d11..3d199b4de6 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -331,6 +331,97 @@ describe("importExport", () => { ]) }) + it("should not apply imported viewStates to the context proxy", async () => { + const fileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + globalSettings: { + mode: "code", + viewStates: { + "stable-sidebar-view": { + mode: "architect", + currentApiConfigName: "profile-a", + updatedAt: 1, + }, + }, + }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(fileContent) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, + ]) + + // Stateful write-tracking proxy: every write path (setValues, setValue, + // setProviderSettings) is merged into `state` and recorded in `writes`, + // so the assertions below cover every payload rather than one call. + // Plain-function doubles cast once, matching the file-level mock pattern. + const makeStatefulProxy = (seed: Record) => { + const state: Record = { ...seed } + const writes: Record[] = [] + const record = (values: Record) => { + writes.push(values) + Object.assign(state, values) + } + return { + state, + writes, + proxy: Object.assign({} as ContextProxy, { + setValues: vi.fn(async (values: Record) => record(values)), + setValue: vi.fn(async (key: string, value: unknown) => record({ [key]: value })), + setProviderSettings: vi.fn(async (settings: Record) => record(settings)), + }), + } + } + + // This machine already has view state: it must survive the import untouched. + const existingViewStates = { + "existing-view": { mode: "code", currentApiConfigName: "default", updatedAt: 0 }, + } + const seeded = makeStatefulProxy({ viewStates: existingViewStates }) + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: seeded.proxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + // Per-view selection state is machine-local: importing settings must not + // apply another machine's view pins, while other settings round-trip. + expect(seeded.state.viewStates).toEqual(existingViewStates) + expect(seeded.state.mode).toBe("code") + expect(result).not.toHaveProperty("globalSettings.viewStates") + + // A machine without view state must not gain any from the import. + const fresh = makeStatefulProxy({}) + const freshResult = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: fresh.proxy, + customModesManager: mockCustomModesManager, + }) + + expect(freshResult.success).toBe(true) + expect(fresh.state).not.toHaveProperty("viewStates") + expect(fresh.state.mode).toBe("code") + expect(freshResult).not.toHaveProperty("globalSettings.viewStates") + + // No write path may carry the imported machine's view pins. + for (const payload of [...seeded.writes, ...fresh.writes]) { + expect(payload).not.toHaveProperty("viewStates") + } + }) + it("should return success: false when file content is invalid", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) @@ -869,6 +960,80 @@ describe("importExport", () => { .spyOn(vscode.window, "showInformationMessage") .mockResolvedValue(undefined) const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + // Should show warning message with short summary (not full details) + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("1 item had issues during import."), + ) + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("See Developer Tools console for details."), + ) + // Should log full details to console + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Settings import completed with warnings:", + expect.arrayContaining([expect.stringContaining("problematic-profile")]), + ) + expect(showInfoMessageSpy).not.toHaveBeenCalled() + + // Provider state should be delivered once, then cleared. + expect(seenImportedAt).toHaveLength(1) + expect(seenImportedAt[0]).toBeGreaterThan(0) + expect(mockProvider.settingsImportedAt).toBeUndefined() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + + showWarningMessageSpy.mockRestore() + showInfoMessageSpy.mockRestore() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } + }) + + it("clears settingsImportedAt after posting the imported state so later launches do not replay it", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const seenImportedAt: Array = [] + const mockProvider = { + settingsImportedAt: undefined as number | undefined, + postStateToWebview: vi.fn().mockImplementation(async () => { + seenImportedAt.push(mockProvider.settingsImportedAt) + }), + } await importSettingsWithFeedback( { @@ -880,32 +1045,12 @@ describe("importExport", () => { filePath, ) - // Should show warning message with short summary (not full details) - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("1 item had issues during import."), - ) - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("See Developer Tools console for details."), - ) - // Should log full details to console - expect(consoleWarnSpy).toHaveBeenCalledWith( - "Settings import completed with warnings:", - expect.arrayContaining([expect.stringContaining("problematic-profile")]), - ) - expect(showInfoMessageSpy).not.toHaveBeenCalled() - - // Provider state should be delivered once, then cleared. expect(seenImportedAt).toHaveLength(1) expect(seenImportedAt[0]).toBeGreaterThan(0) expect(mockProvider.settingsImportedAt).toBeUndefined() - expect(mockProvider.postStateToWebview).toHaveBeenCalled() - - showWarningMessageSpy.mockRestore() - showInfoMessageSpy.mockRestore() - consoleWarnSpy.mockRestore() }) - it("clears settingsImportedAt after posting the imported state so later launches do not replay it", async () => { + it("should call broadcastResetToAllInstances after successful import when available", async () => { const filePath = "/mock/path/settings.json" const mockFileContent = JSON.stringify({ providerProfiles: { @@ -923,7 +1068,55 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) ;(fs.access as Mock).mockResolvedValue(undefined) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + broadcastResetToAllInstances: vi.fn().mockResolvedValue(undefined), + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("settings_imported"), + ) + }) + it("posts the initiating provider state only after the reset broadcast completes", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, @@ -932,15 +1125,26 @@ describe("importExport", () => { { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) - const seenImportedAt: Array = [] + const callOrder: string[] = [] + // Hold the broadcast's promise pending so the test can observe the + // in-between state: a resolved mock would only prove invocation order, + // not that the post actually waits for the broadcast to complete. + let releaseBroadcast: () => void = () => {} + const broadcastSettled = new Promise((resolve) => { + releaseBroadcast = resolve + }) const mockProvider = { - settingsImportedAt: undefined as number | undefined, + settingsImportedAt: 0, postStateToWebview: vi.fn().mockImplementation(async () => { - seenImportedAt.push(mockProvider.settingsImportedAt) + callOrder.push("post") + }), + broadcastResetToAllInstances: vi.fn().mockImplementation(async () => { + callOrder.push("broadcast") + await broadcastSettled }), } - await importSettingsWithFeedback( + const importing = importSettingsWithFeedback( { providerSettingsManager: mockProviderSettingsManager, contextProxy: mockContextProxy, @@ -950,9 +1154,139 @@ describe("importExport", () => { filePath, ) - expect(seenImportedAt).toHaveLength(1) - expect(seenImportedAt[0]).toBeGreaterThan(0) - expect(mockProvider.settingsImportedAt).toBeUndefined() + // Wait until the broadcast is in flight (its start is recorded in + // callOrder), then assert the post has NOT run while it is still + // pending: a post before the reset would leave the webview with the + // stale pre-import per-view mode/profile. + await vi.waitFor(() => { + expect(callOrder).toEqual(["broadcast"]) + }) + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + + // The broadcast clears the durable view state, so the initiating provider's + // webview post must run after it completes. + releaseBroadcast() + await importing + expect(callOrder).toEqual(["broadcast", "post"]) + }) + + it("should skip broadcastResetToAllInstances when callback is missing", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("settings_imported"), + ) + // A missing callback must not reach the broadcast guard's failure path. + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Failed to broadcast reset after settings import"), + ) + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } + }) + + it("should keep successful import result when broadcastResetToAllInstances throws", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const broadcastError = new Error("broadcast failed") + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + broadcastResetToAllInstances: vi.fn().mockRejectedValue(broadcastError), + } + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to broadcast reset after settings import"), + ) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("settings_imported"), + ) + // The rejected broadcast must not leave the import timestamp dangling: the + // guarded catch falls through to the cleanup reset. + expect(mockProvider.settingsImportedAt).toBeUndefined() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } }) it("should handle multiple profiles with mixed valid and invalid providers", async () => { @@ -1211,32 +1545,36 @@ describe("importExport", () => { const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined) const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) - await importSettingsWithFeedback( - { - providerSettingsManager: mockProviderSettingsManager, - contextProxy: mockContextProxy, - customModesManager: mockCustomModesManager, - provider: mockProvider, - }, - filePath, - ) - - // Should show warning message with plural summary for multiple warnings - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("2 items had issues during import."), - ) - // Should log full details to console - expect(consoleWarnSpy).toHaveBeenCalledWith( - "Settings import completed with warnings:", - expect.arrayContaining([ - expect.stringContaining("problematic-profile-1"), - expect.stringContaining("problematic-profile-2"), - ]), - ) + // Should show warning message with plural summary for multiple warnings + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("2 items had issues during import."), + ) + // Should log full details to console + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Settings import completed with warnings:", + expect.arrayContaining([ + expect.stringContaining("problematic-profile-1"), + expect.stringContaining("problematic-profile-2"), + ]), + ) - showWarningMessageSpy.mockRestore() - consoleWarnSpy.mockRestore() + showWarningMessageSpy.mockRestore() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } }) it.each([ @@ -1480,28 +1818,34 @@ describe("importExport", () => { const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined) const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) - await importSettingsWithFeedback( - { - providerSettingsManager: mockProviderSettingsManager, - contextProxy: mockContextProxy, - customModesManager: mockCustomModesManager, - provider: mockProvider, - }, - filePath, - ) - - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("1 item had issues during import."), - ) - expect(showWarningMessageSpy).not.toHaveBeenCalledWith(expect.stringContaining("profile had issues")) - expect(consoleWarnSpy).toHaveBeenCalledWith( - "Settings import completed with warnings:", - expect.arrayContaining([expect.stringContaining("globalSettings.requestDelaySeconds")]), - ) + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("1 item had issues during import."), + ) + expect(showWarningMessageSpy).not.toHaveBeenCalledWith( + expect.stringContaining("profile had issues"), + ) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Settings import completed with warnings:", + expect.arrayContaining([expect.stringContaining("globalSettings.requestDelaySeconds")]), + ) - showWarningMessageSpy.mockRestore() - consoleWarnSpy.mockRestore() + showWarningMessageSpy.mockRestore() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } }) }) }) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 0db0e9c9ea..58a9c608c3 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -37,6 +37,7 @@ type ImportWithProviderOptions = ImportOptions & { provider: { settingsImportedAt?: number postStateToWebview: () => Promise + broadcastResetToAllInstances?(): Promise } } @@ -98,6 +99,14 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { for (const [key, rawValue] of Object.entries(rawGlobalSettings)) { const path = `globalSettings.${key}` + + // Per-view selection state is machine-local: it round-trips through the + // normal runtime and pruning paths, but importing it would pin selections + // from another machine's views on this one. + if (key === "viewStates") { + continue + } + const schema = globalSettingsShape[key as keyof GlobalSettings] if (!schema) { @@ -385,7 +394,21 @@ export const importSettingsWithFeedback = async ( if (result.success) { provider.settingsImportedAt = Date.now() + + // Broadcast invalidation to all other live ClineProvider instances first, so the + // initiating provider's post below reflects the cleared view state rather than the + // stale pre-import per-view mode/profile. + try { + if (provider.broadcastResetToAllInstances) { + await provider.broadcastResetToAllInstances() + } + } catch (error) { + // Log but do not fail the import if broadcast fails — the import itself succeeded. + console.warn(`Failed to broadcast reset after settings import: ${error}`) + } + await provider.postStateToWebview() + provider.settingsImportedAt = undefined const warnings = "warnings" in result ? result.warnings : undefined diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fdcc740998..d9ec0a4e41 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -865,10 +865,17 @@ export class Task extends EventEmitter implements TaskLike { private async initializeTaskMode(provider: ClineProvider): Promise { try { const state = await provider.getState() - this._taskMode = state?.mode || defaultModeSlug + + // Avoid clobbering a newer value that may have been set while awaiting provider state + // (e.g., a message-selected mode switch issued right after task creation). + if (this._taskMode === undefined) { + this._taskMode = state?.mode || defaultModeSlug + } } catch (error) { - // If there's an error getting state, use the default mode - this._taskMode = defaultModeSlug + // If there's an error getting state, use the default mode (unless a newer value was set). + if (this._taskMode === undefined) { + this._taskMode = defaultModeSlug + } // Use the provider's log method for better error visibility const errorMessage = `Failed to initialize task mode: ${error instanceof Error ? error.message : String(error)}` provider.log(errorMessage) @@ -2495,8 +2502,36 @@ export class Task extends EventEmitter implements TaskLike { if (provider) { if (mode) { - await provider.setMode(mode) - this._taskMode = mode + // Route through the shared mode-switch handler so the switch is + // validated and recorded like any other mode change (task history, + // TaskModeSwitched, and — when this is the focused task — the view's + // durable mode pin + ModeChanged broadcast). The handler writes this + // task's mode only after validation and persistence, so an unknown + // slug leaves the task mode untouched instead of recording a bad one. + // A mode-switch failure (e.g. a task-history write failure) must not + // swallow the submitted message: log it locally and continue delivery. + // Let the constructor-started mode initialization settle first: its + // deferred provider-state read would otherwise resolve after the switch + // and clobber the explicitly selected mode with the pre-switch value. + try { + await this.waitForModeInitialization() + await provider.handleModeSwitch(mode, this) + // handleModeSwitch resolves without applying the switch for an + // unknown slug or an aborted/timed-out mutation; the task keeps + // its previous mode while the message below is delivered in the + // old mode, so leave a trace when the requested mode is not the + // task's mode after the call. + if (this._taskMode !== mode) { + console.warn( + `[Task#submitUserMessage] Mode switch to ${mode} was not applied (taskId=${this.taskId})`, + ) + } + } catch (error) { + console.error( + `[Task#submitUserMessage] Mode switch to ${mode} failed (taskId=${this.taskId}):`, + error, + ) + } } if (providerProfile) { @@ -4143,7 +4178,6 @@ export class Task extends EventEmitter implements TaskLike { // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => - // Stryker disable next-line ConditionalExpression: only tool_use and mcp_tool_use entries ever carry an id — the text block pushed in the text case has no id field and the start events reaching this guard always carry the string id locked in by processRawChunk — so the type check cannot change the outcome of entry.id === event.id for any entry this pipeline produces; the false replacement is additionally killed by the rejection tests. (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4196,7 +4230,6 @@ export class Task extends EventEmitter implements TaskLike { // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined - // Stryker disable next-line ConditionalExpression: event.name is always a string on this path — the only delta events reaching this handler are emitted by processRawChunk with the tracked start name (never undefined), so the legacy nameless branch below is unreachable defensive code and the conditional always takes the named side. if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4213,7 +4246,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: streamingKey can never be undefined here — makeStreamingKey always returns a string and the legacy lookup branch above is unreachable (see the directive on the event.name check) — so the null side is unreachable defensive code. streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4222,7 +4254,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: streamingKey is always a defined string on this path (makeStreamingKey never returns undefined; the legacy branch is unreachable per the directives above), and processStreamingChunk returns a partial ToolUse for a known key, so both defensive conditions reduce to the partialToolUse check and cannot change the observed behavior. if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4613,7 +4644,6 @@ export class Task extends EventEmitter implements TaskLike { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. - // Stryker disable next-line LogicalOperator: event.name is always a string on this path — finalizeRawChunks emits end events with the tracked start name (a string, set before the tracker can start), so the ?? event.id fallback guards a nameless end event this pipeline cannot produce. let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4630,13 +4660,11 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: the both-miss case is unreachable via the raw-chunk pipeline — end events carry the locked start name so the compound key resolves the registered entry, and a same-ID rejected-start end is always processed after the accepted start's end has already removed that entry; this defensive path protects against a cross-name end event the pipeline cannot produce. if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 85325b39ff..c1e4a8a5a8 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -38,6 +38,7 @@ import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-p import { asyncStreamFrom } from "../../../test-utils/stream" import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" +import { defaultModeSlug } from "../../../shared/modes" import { readFileTool } from "../../tools/ReadFileTool" import { writeToFileTool } from "../../tools/WriteToFileTool" @@ -59,6 +60,9 @@ type TaskTestAccess = { getFilesReadByRooSafely: (context: string) => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void + // Private on Task; the provider-owned mode write (ClineProvider.handleModeSwitch) + // sets it, and tests mirror that write through this helper. + _taskMode: string | undefined buildCleanConversationHistory: ( messages: ApiMessage[], requestModelInfo: ModelInfo, @@ -3166,6 +3170,7 @@ describe("Cline", () => { getMcpHub: vi.fn().mockReturnValue(undefined), getSkillsManager: vi.fn().mockReturnValue(undefined), say: vi.fn(), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), postStateToWebview: vi.fn().mockResolvedValue(undefined), postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), @@ -3714,8 +3719,16 @@ describe("Cline", () => { }) it("uses a mode selected through submitUserMessage in the next API request", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "ask" })) - vi.spyOn(mockProvider, "setMode").mockResolvedValue(undefined) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + Object.assign({} as ProviderState, { mode: "ask", mcpEnabled: false }), + ) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + // Mirror ClineProvider.handleModeSwitch: after validation and persistence + // the provider owns the task's mode write. + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -3724,6 +3737,10 @@ describe("Cline", () => { }) vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + // Let the task's initial mode ("ask", from provider state) settle first, so the + // mode selected with the user message is the task's final mode write. + await task.getTaskMode() + await task.submitUserMessage("switch modes", undefined, "code") vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") const stream = (async function* () { @@ -3736,10 +3753,217 @@ describe("Cline", () => { await task.attemptApiRequest().next() - expect(mockProvider.setMode).toHaveBeenCalledWith("code") + expect(mockProvider.handleModeSwitch).toHaveBeenCalledWith("code", task) expect(requireDefined(createMessage.mock.calls[0])[2]?.mode).toBe("code") }) + it("still delivers the user message when the mode switch fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + const switchError = new Error("task history write failed") + vi.spyOn(mockProvider, "handleModeSwitch").mockRejectedValue(switchError) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await task.submitUserMessage("still delivered", undefined, "code") + + // The mode-switch rejection is caught and logged locally... + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[Task#submitUserMessage] Mode switch to code failed (taskId=${task.taskId}):`, + switchError, + ) + // ...and the pending ask is still answered, so the submitted text is not lost. + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "still delivered", []) + consoleErrorSpy.mockRestore() + }) + + it("warns when the mode switch resolves without applying the requested mode", async () => { + vi.spyOn(mockProvider, "getState").mockResolvedValue( + Object.assign({} as ProviderState, { mode: "ask", mcpEnabled: false }), + ) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + // Unknown slug / aborted mutation: the handler resolves without writing + // the task's mode (ClineProvider leaves _taskMode untouched in both cases). + vi.spyOn(mockProvider, "handleModeSwitch").mockResolvedValue(undefined) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await task.submitUserMessage("delivered in old mode", undefined, "architect") + + // The message is still delivered in the previous mode... + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "delivered in old mode", []) + // ...and the dropped selection leaves a trace. + expect(warnSpy).toHaveBeenCalledWith( + `[Task#submitUserMessage] Mode switch to architect was not applied (taskId=${task.taskId})`, + ) + warnSpy.mockRestore() + }) + + it("keeps the selected mode when the deferred mode initialization settles after the submission", async () => { + // The constructor-started initializeTaskMode() is still awaiting the + // provider state. submitUserMessage must let that initialization settle + // before switching: without the await, the deferred read resolves after + // the switch and clobbers the selected mode with the pre-switch value. + // One shared pending promise: both the mode and api-config initializers + // await provider.getState(), and a per-call promise would leave the mode + // initializer's own read pending forever. + let releaseState: (state: ProviderState) => void = () => {} + const deferredState = new Promise((resolve) => { + releaseState = resolve + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + const submitted = task.submitUserMessage("select while initializing", undefined, "architect") + + // Flush microtasks: the switch must still wait for the pending + // initialization. Without the await, the mocked switch would already have + // run here and the end-state assertion could not tell the orders apart. + for (let i = 0; i < 5; i++) { + await Promise.resolve() + } + expect(mockProvider.handleModeSwitch).not.toHaveBeenCalled() + + // Resolve the pending provider state only after the submission has started, + // carrying the provider's pre-switch mode. + releaseState(Object.assign({} as ProviderState, { mode: "ask" })) + + await submitted + + expect(mockProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + // The explicit selection survives: the deferred initialization ran before, + // not after, the mode write. + expect(await task.getTaskMode()).toBe("architect") + }) + + it("does not let a pending mode initialization clobber a provider-scoped mode write", async () => { + // The constructor-started initializeTaskMode() is still awaiting the provider + // state. A provider-scoped switch (e.g. selectTaskFollowupSuggestion) writes + // the task's mode without the submitUserMessage wait: when the deferred read + // resolves afterwards, initialization must not restore the pre-switch mode. + let releaseState: (state: ProviderState) => void = () => {} + const deferredState = new Promise((resolve) => { + releaseState = resolve + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + // A provider-scoped switch writes the task mode through the provider-owned write. + await mockProvider.handleModeSwitch("architect", task) + + // The deferred initialization settles after the switch, carrying the + // provider's pre-switch mode. + releaseState({ mode: "ask" } as unknown as ProviderState) + + // The explicit selection survives the late initialization. + expect(await task.getTaskMode()).toBe("architect") + }) + + it("keeps a provider-scoped mode when mode initialization fails", async () => { + // The catch path must apply the same guard as the success path: a provider-scoped + // switch that landed before the failed initialization must survive it. + let failState: (error: Error) => void = () => {} + const deferredState = new Promise((_resolve, reject) => { + failState = reject + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + // The pre-write must differ from the default slug (the catch path's fallback) + // or the clobber it guards against would be unobservable. + const preWriteMode = defaultModeSlug === "code" ? "architect" : "code" + // The switch lands while the initialization is still pending... + await mockProvider.handleModeSwitch(preWriteMode, task) + // ...then the initialization fails. + failState(new Error("state lookup failed")) + await task.getTaskMode() + + // The failed initialization must not clobber the provider-scoped write. + expect(getTaskTestAccess(task)._taskMode).toBe(preWriteMode) + }) + + it("falls back to the default slug when mode initialization fails before any write", async () => { + // The catch path still initializes the default slug when nothing newer exists. + let failState: (error: Error) => void = () => {} + const deferredState = new Promise((_resolve, reject) => { + failState = reject + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + failState(new Error("state lookup failed")) + await task.getTaskMode() + + expect(getTaskTestAccess(task)._taskMode).toBe(defaultModeSlug) + }) + + it("does not log an initialization error when the provider state is merely undefined", async () => { + // state?.mode on an undefined state must not throw into the catch path: the catch + // is reserved for real getState() failures, which it logs through the provider. + vi.spyOn(mockProvider, "getState").mockResolvedValue(undefined as unknown as ProviderState) + const logSpy = vi.spyOn(mockProvider, "log") + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + await task.getTaskMode() + + expect(getTaskTestAccess(task)._taskMode).toBe(defaultModeSlug) + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Failed to initialize task mode")) + }) + it("stores a provider profile selected through submitUserMessage", async () => { const selectedConfiguration: ProviderSettings = { ...mockApiConfig, diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts index e1216fcaad..1f3d302fe9 100644 --- a/src/core/tools/SwitchModeTool.ts +++ b/src/core/tools/SwitchModeTool.ts @@ -55,8 +55,19 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { return } - // Switch the mode using shared handler - await task.providerRef.deref()?.handleModeSwitch(mode_slug) + // Switch the mode using shared handler. Pass this task explicitly so the + // switch is scoped to it rather than the provider's currently focused task. + // A provider released mid-turn (its view disposed) cannot execute the switch: + // the optional chain would swallow the call, so guard explicitly and report + // the failure instead of a false success. + const provider = task.providerRef.deref() + if (!provider) { + task.recordToolError("switch_mode") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError("Cannot switch mode: the provider is no longer available.")) + return + } + await provider.handleModeSwitch(mode_slug, task) pushToolResult( `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ diff --git a/src/core/tools/__tests__/switchModeTool.spec.ts b/src/core/tools/__tests__/switchModeTool.spec.ts index 88af75249b..02c73a7539 100644 --- a/src/core/tools/__tests__/switchModeTool.spec.ts +++ b/src/core/tools/__tests__/switchModeTool.spec.ts @@ -33,20 +33,22 @@ describe("SwitchModeTool", () => { let mockCallbacks: ToolCallbacks let mockHandleModeSwitch: ReturnType let mockGetState: ReturnType + let mockGetTaskMode: ReturnType beforeEach(() => { vi.clearAllMocks() mockHandleModeSwitch = vi.fn().mockResolvedValue(undefined) mockGetState = vi.fn().mockResolvedValue({ mode: "code", customModes: [] }) + mockGetTaskMode = vi.fn().mockResolvedValue("code") mockTask = { - getTaskMode: vi.fn().mockResolvedValue("code"), consecutiveMistakeCount: 0, recordToolError: vi.fn(), didToolFailInCurrentTurn: false, sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), ask: vi.fn().mockResolvedValue({}), + getTaskMode: mockGetTaskMode, providerRef: { deref: vi.fn().mockReturnValue({ getState: mockGetState, @@ -167,8 +169,8 @@ describe("SwitchModeTool", () => { }), ) - // Should have called handleModeSwitch with the target slug - expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect") + // Should have called handleModeSwitch with the target slug and the task + expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect", mockTask) // Should have pushed success result expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( @@ -186,7 +188,7 @@ describe("SwitchModeTool", () => { JSON.stringify({ tool: "switchMode", mode: "ask", reason: "" }), ) - expect(mockHandleModeSwitch).toHaveBeenCalledWith("ask") + expect(mockHandleModeSwitch).toHaveBeenCalledWith("ask", mockTask) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Successfully switched from Code mode to Ask mode.") }) @@ -246,7 +248,7 @@ describe("SwitchModeTool", () => { // Should have asked for approval first expect(mockCallbacks.askApproval).toHaveBeenCalled() // Should have called handleModeSwitch (which throws) - expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect") + expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect", mockTask) // Error should be caught and reported expect(mockCallbacks.handleError).toHaveBeenCalledWith("switching mode", switchError) }) @@ -305,7 +307,7 @@ describe("SwitchModeTool", () => { await switchModeTool.handle(mockTask, block, mockCallbacks) expect(mockCallbacks.askApproval).toHaveBeenCalled() - expect(mockHandleModeSwitch).toHaveBeenCalledWith("custom-mode") + expect(mockHandleModeSwitch).toHaveBeenCalledWith("custom-mode", mockTask) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( "Successfully switched from Code mode to Custom Mode mode because: testing custom modes.", ) @@ -327,17 +329,81 @@ describe("SwitchModeTool", () => { expect(mockCallbacks.askApproval).toHaveBeenCalledWith("tool", expectedMessage) }) - it("reads the current mode from the task when provider state differs", async () => { - vi.mocked(mockTask.getTaskMode).mockResolvedValue("architect") - mockGetState.mockResolvedValue({ mode: "orchestrator", customModes: [] }) + // ===== current mode source ===== + + it("should read the current mode from task.getTaskMode, not provider state", async () => { + // The provider state still reports the stale "code" mode while the task is actually in + // "architect". The switch report must use the task's own mode. + mockGetState.mockResolvedValue({ mode: "code", customModes: [] }) + mockGetTaskMode.mockResolvedValue("architect") const block = createBlock({ mode_slug: "code", reason: "switching back" }) await switchModeTool.handle(mockTask, block, mockCallbacks) - expect(mockHandleModeSwitch).toHaveBeenCalledWith("code") + expect(mockGetTaskMode).toHaveBeenCalledTimes(1) + expect(mockHandleModeSwitch).toHaveBeenCalledWith("code", mockTask) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( "Successfully switched from Architect mode to Code mode because: switching back.", ) }) + + it("should use the default slug reported by task.getTaskMode when no mode is active", async () => { + // task.getTaskMode applies the default slug when the task has no explicit mode, so the + // tool reports switching from the default (Code) mode. + mockGetState.mockResolvedValue({ customModes: [] }) + mockGetTaskMode.mockResolvedValue("code") + + const block = createBlock({ mode_slug: "ask", reason: "test" }) + + await switchModeTool.handle(mockTask, block, mockCallbacks) + + expect(mockGetTaskMode).toHaveBeenCalledTimes(1) + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + "Successfully switched from Code mode to Ask mode because: test.", + ) + }) + + // SwitchModeTool routes the switch through task.providerRef.deref(): when the + // provider was already disposed the deref is undefined, and the tool must report + // the failure instead of a false success (the provider cannot execute the switch). + it("should report an error when the provider reference is already released", async () => { + const toolTask = { + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), + ask: vi.fn().mockResolvedValue({}), + getTaskMode: vi.fn().mockResolvedValue("code"), + providerRef: { + deref: vi.fn().mockReturnValue(undefined), + }, + } as unknown as Task // structural double: the tool only reads the fields above + const callbacks: ToolCallbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), + } + const block = { + type: "tool_use" as const, + name: "switch_mode" as const, + params: { mode_slug: "architect", reason: "test" }, + partial: false, + nativeArgs: { mode_slug: "architect", reason: "test" }, + } as unknown as ToolUse<"switch_mode"> // mirrors the createBlock helper above + + await switchModeTool.handle(toolTask, block, callbacks) + + expect(callbacks.handleError).not.toHaveBeenCalled() + expect(toolTask.recordToolError).toHaveBeenCalledWith("switch_mode") + expect(toolTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + JSON.stringify({ + status: "error", + message: "The tool execution failed", + error: "Cannot switch mode: the provider is no longer available.", + }), + ) + expect(callbacks.pushToolResult).not.toHaveBeenCalledWith(expect.stringContaining("Successfully switched")) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index cdd0975c5d..f8585347bc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -57,6 +57,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -110,7 +111,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -137,6 +138,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 @@ -205,6 +214,10 @@ 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 readonly MAX_VIEW_STATE_ID_LENGTH = 64 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -324,6 +337,47 @@ 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 + + /** + * Whether this instance wrote an entry under its own session-local temporary id. + * Temporary ids restart from 0 on every extension host start, so an entry under the + * same name can belong to a different session. Only re-key entries this instance wrote. + */ + private wroteUnderTemporaryViewStateId = false + + /** + * 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 = {} + /** + * Fields mutated after loadViewState cleared the set: a completed mutation (including a + * clear) wins over the persisted values an in-flight load read, so a stale load can never + * re-apply a value the user changed while the load was in flight. + */ + private viewLocalStateMutatedFields = new Set<"mode" | "currentApiConfigName" | "apiConfiguration">() + + /** + * This view's pinned profile name from the view-local buffer. Exposed for + * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` + * is private and must not be reached through bracket access. + */ + get pinnedProfileName(): string | undefined { + return this.viewLocalState.currentApiConfigName + } + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.88.0-providers-tasks-speed" // v3.88.0 provider/model support, task reliability, faster editing and chat @@ -339,14 +393,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) @@ -371,6 +428,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) => { @@ -493,6 +553,445 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy with corrupt + * entries dropped. + * 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. + * A corrupt entry (null or a non-object) is dropped here rather than reaching the + * prune sort (which dereferences updatedAt) or the repoint destructuring, both of + * which would throw and reject the surrounding mutation. + */ + 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 Object.create(null) + } + + // Copy onto a null prototype while dropping invalid entries: a view id that names an + // Object.prototype key ("constructor", "toString", "valueOf", ...) must not resolve + // through the prototype chain and be mistaken for a persisted entry, and corrupt + // entries must be reported so a rejecting write is never silently poisoned. + const states: Record = Object.create(null) + let dropped = 0 + + for (const [viewId, entry] of Object.entries(viewStates)) { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + states[viewId] = entry + } else { + dropped++ + } + } + + if (dropped > 0) { + this.log( + `[getPersistedViewStates] dropped ${dropped} invalid viewStates ${dropped === 1 ? "entry" : "entries"}`, + ) + } + + return states + } + + /** + * 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 }) + // The first write to an unowned temporary id claims the slot instead of merging + // into it: temporary names restart from 0 on every host start, so an entry under + // this name can be a leftover from an earlier session, and merging would adopt its + // fields and later re-key them under the stable id. Entries under a stable id, or + // under the temporary id this instance already authored, keep the merge semantics. + const ownsTargetEntry = viewStateId !== this.viewId || this.wroteUnderTemporaryViewStateId + const current = ownsTargetEntry ? (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 + } + } + + let wroteEntry = false + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + wroteEntry = true + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + // Authorship is recorded only after the durable write lands: a failed persistence + // must not mark the temporary id as owned, or a later load would adopt an entry + // that was never actually stored. + if (wroteEntry && viewStateId === this.viewId) { + this.wroteUnderTemporaryViewStateId = true + } + }) + + 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) + .filter(([, entry]) => !!entry && typeof entry === "object") + .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 () => { + // An entry under this name can belong to a different session, because temporary + // ids restart from 0 on every extension host start. Only re-key entries this + // instance wrote, otherwise a stale entry from another session is adopted. + if (!this.wroteUnderTemporaryViewStateId) { + return + } + + 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)) + + // Only a successful write clears authorship: a failed attempt leaves the entry + // under the temporary name, and a retry must still be able to re-key it. + this.wroteUnderTemporaryViewStateId = false + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Resolves once this view's stable state id has been registered (setViewStateId) and + * its persisted view state loaded, or when the provider disposes. API-driven task + * creation awaits this (bounded by a timeout) so a task cannot consume shared + * default mode/profile state before the view's own persisted selections are loaded. + */ + private viewStateReadinessResolve!: () => void + + // Created eagerly in the field initializer rather than lazily in a getter: the + // resolver is consumed by the first setViewStateId registration or by dispose, + // so a promise created after that point would never settle and startNewTask's + // readiness race would fall through to its timeout every time. + public readonly viewStateReadiness: Promise = new Promise((resolve) => { + this.viewStateReadinessResolve = resolve + }) + + /** + * True once dispose has started. Dispose resolves viewStateReadiness to release + * waiters, so API-driven flows awaiting readiness must consult this flag to tell a real + * readiness apart from a teardown: a disposed provider must never create a task. + */ + public get isDisposed(): boolean { + return this._disposed + } + + /** + * 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 { + // The webview generates this id on purpose - it has to survive a webview reload, + // which recreates the view on both sides - so the host cannot mint it instead. + // What the host owns is which view may hold a given persisted key, so everything + // below validates the supplied value before it becomes one. + if (typeof viewStateId !== "string") { + // A crafted postMessage can carry any JSON value here even though the message + // type declares a string; the persisted key must stay a string. + this.log(`[setViewStateId] Ignoring a non-string viewStateId for view ${this.viewId}.`) + // The view falls back to shared state; do not leave API-driven task creation + // waiting on a registration that will not happen. + this.viewStateReadinessResolve() + return + } + + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject "__proto__": writing states["__proto__"] would go through the + // Object.prototype setter and be silently dropped by the later spread. + normalizedViewStateId === "__proto__" + ) { + // No stable id will be registered for this view (no usable id in the launch + // message, or this exact id was already registered): the view falls back to + // shared state, and API-driven task creation must not time out waiting on a + // view state that never registers. Resolve now; the promise is idempotent. + this.viewStateReadinessResolve() + return + } + + // Bound the key: the webview generates a UUID, so a much longer value is not a + // view id, and an unbounded key is unbounded growth in the shared viewStates map. + if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { + this.log( + `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, + ) + // The view falls back to shared state; do not leave API-driven task creation + // waiting on a registration that will not happen. + this.viewStateReadinessResolve() + return + } + + const claimant = ClineProvider.getAllInstances().find( + (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, + ) + if (claimant) { + // Ownership check: a key another live view holds must not be adopted. Doing so + // would load that view's pinned profile, nested overlay, and mode into this + // provider, and this webview could then overwrite that view's persisted entry. + // Staying on the temporary id keeps this view working against the shared state. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, + ) + // The view falls back to shared state; do not leave API-driven task creation + // waiting on a registration that will not happen. + this.viewStateReadinessResolve() + return + } + + const previousViewStateId = this.viewStateId + + this.viewStateId = normalizedViewStateId + + try { + // 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() + } catch (error) { + // A persistence failure must not leave the provider holding an id that was + // never registered: restore the previous id so a later launch retries the + // registration and the load instead of the guard above early-returning for + // the failed id. + this.viewStateId = previousViewStateId + throw error + } finally { + // The stable id is now registered (or restored): API-driven task creation + // can proceed against this view's loaded state. + this.viewStateReadinessResolve() + } + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + * Fields mutated while the async profile lookup is in flight are reapplied on top of the + * loaded state, field by field, so in-flight user selections are not clobbered by the load. + */ + 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 + + // While the stable id has not been registered yet, the key is this instance's + // session-local name. Temporary names restart from 0 on every host start, so an + // entry under the same name can belong to a different session. Load only entries + // this instance authored. + if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { + return + } + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + // Clear the mutation marker before the async section: only mutations that + // complete while this load is in flight may supersede the values it read. + this.viewLocalStateMutatedFields.clear() + + if (persisted?.mode) { + // A persisted mode may reference a custom mode that was deleted after it was + // pinned: restore it only when the slug still resolves, so a stale slug cannot + // shadow the shared mode from getState(). + const customModes = await this.customModesManager.getCustomModes() + if (getModeBySlug(persisted.mode, customModes)) { + loadedState.mode = persisted.mode as Mode + } else { + this.log(`[loadViewState] Ignoring unknown persisted mode "${persisted.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 + } + + // Reapply only the fields mutated while the load was in flight: untouched + // fields keep the persisted values authoritative, and a field cleared during + // the load cannot be resurrected by the stale persisted values this load read. + const postLoadBuffer = this.viewLocalState + const mergedState: Partial = { ...loadedState } + + if (this.viewLocalStateMutatedFields.has("mode")) { + if (postLoadBuffer.mode !== undefined) { + mergedState.mode = postLoadBuffer.mode + } else { + delete mergedState.mode + } + } + + if (this.viewLocalStateMutatedFields.has("currentApiConfigName")) { + if (postLoadBuffer.currentApiConfigName !== undefined) { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } else { + delete mergedState.currentApiConfigName + } + } + + if (this.viewLocalStateMutatedFields.has("apiConfiguration")) { + if (postLoadBuffer.apiConfiguration !== undefined) { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } else { + delete mergedState.apiConfiguration + } + } + + this.viewLocalState = mergedState + 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 */ @@ -827,6 +1326,10 @@ export class ClineProvider } this._disposed = true + + // A disposed provider will never register a stable view state id: release + // any API-driven task creation that is awaiting viewStateReadiness. + this.viewStateReadinessResolve() this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") @@ -904,6 +1407,16 @@ export class ClineProvider return Array.from(this.activeInstances) } + /** + * Returns the live instance whose current view is the given view or panel, + * if any. Title-bar commands on a specific surface use this to target the + * instance that owns that surface rather than the visible-instance + * heuristic (which picks whichever surface the user last focused). + */ + public static getInstanceForView(view: vscode.WebviewView | vscode.WebviewPanel): ClineProvider | undefined { + return Array.from(this.activeInstances).find((instance) => instance.view === view) + } + public static async getInstance(): Promise { let visibleProvider = ClineProvider.getVisibleInstance() @@ -1238,7 +1751,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, @@ -1448,11 +1964,25 @@ export class ClineProvider return } - try { - await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) - } catch { - // View disposed, drop message silently + const webview = this.view?.webview + if (!webview) { + return } + + // Dispatch without awaiting the renderer ack: VS Code settles postMessage only when the + // webview page acknowledges the message, and a page reload or view dispose in flight + // orphans that promise forever. Awaiting it could wedge every caller on the task critical + // path (e.g. the trailing postStateToWebview in handleModeSwitchUnlocked gates the next + // turn after a mode switch). Message ordering is enforced by the message seq, not the ack. + // Promise.resolve() normalizes non-promise returns (e.g. test doubles) before the catch. + void Promise.resolve(webview.postMessage(omitOriginalContentFromExtensionMessage(message))).catch((error) => { + // Swallow: postMessage rejects when the webview is disposed in flight. + // Log the dropped message type so a wedged webview channel is diagnosable + // instead of silently losing state updates. + this.log( + `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -1692,8 +2222,20 @@ 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. + * Unknown mode slugs are ignored (logged + no-op) so unvalidated callers (the webview + * "mode" message sends message.text as Mode) cannot persist invalid modes. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { + const targetMode = getModeBySlug(newMode, await this.customModesManager.getCustomModes()) + + if (!targetMode) { + this.log(`[ClineProvider#handleModeSwitch] ignoring unknown mode "${newMode}"`) + return + } + return this.enqueueProviderProfileMutation((signal) => this.handleModeSwitchUnlocked(newMode, targetTask, signal), ) @@ -1706,9 +2248,21 @@ export class ClineProvider ): Promise { const task = targetTask + // Captured for the pin-write rollback below: the mode this task held before + // the switch (bracket access: _taskMode is private to Task). + const previousTaskMode = task ? task["_taskMode"] : undefined + let rollbackTaskWrites: (() => Promise) | undefined + // A cancelled or timed-out switch must not be partially applied: bail out + // before the task history / _taskMode writes as well as the durable mode + // write below. Aborts that land while the history write is in flight are + // handled in flight (the landed write is rolled back); the pre-write check + // further down still covers the remaining gap before the durable write. + if (signal?.aborted) { + return + } + if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) - task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) try { // Update the task history with the new mode first. @@ -1716,10 +2270,48 @@ export class ClineProvider if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) + + // An abort that lands while the history write is in flight has + // already persisted the new mode: restore the pre-switch item and + // bail before the in-memory task write and the emit, so task + // history, task state, and provider mode cannot diverge. A failed + // rollback must not surface as the persistence error of the + // cancelled switch: log it and keep the cancellation result. + if (signal?.aborted) { + try { + // Restore only the field this switch changed: re-read the item so + // fields the running task persisted during the pending window + // (tokens, cost, status, apiConfigName) survive the rollback. + const latest = this.getTaskHistoryItem(task.taskId) ?? taskHistoryItem + await this.updateTaskHistory({ ...latest, mode: taskHistoryItem.mode }) + } catch (rollbackError) { + this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) + } + return + } + } else if (signal?.aborted) { + return } + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode + + // If the view's durable mode pin below fails after these task-side writes + // landed, restore them: the caller delivers the message on the caught + // error, and the new task mode must not run against the view's unchanged + // mode and mode-linked API configuration. + const historyItemBefore = taskHistoryItem + rollbackTaskWrites = async () => { + task["_taskMode"] = previousTaskMode + if (historyItemBefore) { + // Restore only the field this switch changed: re-read the item so + // fields the running task persisted during the write survive. + const latest = this.getTaskHistoryItem(task.taskId) ?? historyItemBefore + await this.updateTaskHistory({ ...latest, mode: historyItemBefore.mode }) + } + } } catch (error) { // If persistence fails, log the error but don't update the in-memory state. this.log( @@ -1732,13 +2324,74 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + // A cancelled or timed-out switch must not write the mode or emit + // ModeChanged: check the mutation signal right before the durable write. + if (signal?.aborted) { + return + } + + // 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 + if (viewScopedSwitch) { + // Pin the view's durable per-view mode first: the pin is this view's own + // selection and must not be undone if the shared write later fails. + // If the pin write itself fails, the task-side writes above are already + // applied: roll them back before rethrowing so the caller cannot deliver + // the message with the new task mode while the view's mode and mode-linked + // API configuration stayed old. + try { + await this.saveViewState("mode", newMode) + } catch (error) { + if (rollbackTaskWrites) { + try { + await rollbackTaskWrites() + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back task mode writes after the view mode pin write failed: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + throw error + } - this.emit(RooCodeEventName.ModeChanged, newMode) + // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState + // buffer stays in sync with the durable global write: getValues() merges + // viewLocalState on top of the ContextProxy values, so an unsynced stale + // restored mode would otherwise shadow the fresh switch for consumers. + // If the durable write fails, roll the shared write back so getValues() + // cannot mix a fresh shared mode with the stale pre-switch buffer. + const previousMode = this.getValue("mode") + try { + await this.setValue("mode", newMode) + } catch (error) { + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + + 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() } @@ -1746,6 +2399,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) @@ -1882,13 +2538,33 @@ 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() + + // Same sibling protection as the activation path: the shared write + // below replaces the shared store, so pin other profiles' settings + // into the views that reference them first. + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) + await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), + this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain global write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the updated profile's + // name with stale settings. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1896,7 +2572,25 @@ 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 }) + + // A save that does not activate still changes the profile's settings, and + // every view pinned to it buffers a full copy of them. Refresh those views + // (and this one when it is the pinned view) or their getState() keeps + // serving the settings captured before the save under the same name. + if (this.pinnedProfileName === name) { + await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + // Only rebuild when the task actually runs under the saved profile: a + // view pinned to this profile can hold a task that follows a different + // sticky profile, and the forced rebuild would silently switch that + // conversation to the saved profile's settings. + if (this.getCurrentTask()?.taskApiConfigName === name) { + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } + } + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } await this.postStateToWebview() @@ -1924,13 +2618,132 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // Commit the secret deletion before the compensating writes. If the deletion + // fails, nothing else runs: the durable pins still reference the profile that + // still exists and the shared list still contains it. Staging the re-point or + // the list prune before the deletion would let a failed deleteConfig leave a + // shared list missing a profile that is still stored, with the pins already + // moved to the replacement. + // A not-found rejection means the secret was already gone (e.g. pruned by an + // earlier run): branch on the typed ProviderSettingsNotFoundError so the stale + // list entry is still pruned as an idempotent success, while any other failure + // (e.g. refusing to delete the last remaining configuration) propagates before + // the compensating writes run. Matching message text instead would let a + // profile whose name contains "not found" swallow an unrelated failure. + try { + await this.providerSettingsManager.deleteConfig(profileToDelete.name) + } catch (error) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + throw error + } + this.log( + `deleteProviderProfile: settings for '${profileToDelete.name}' were not found; pruning the stale list entry only`, + ) + } - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) + // Only after the deletion has committed: re-point the durable view pins. A + // re-point failure now leaves at most a dangling pin naming a profile whose + // entry no longer exists, which the view load tolerates, instead of corrupting + // the shared list when the deletion itself failed. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + + const viewPinsDeletedProfile = + this.viewLocalState.currentApiConfigName === profileToDelete.name || + // No view-local pin: the view follows the shared selection, so it only needs the + // replacement activation when that shared selection referenced the deleted profile; + // an unrelated deletion must not rebuild this view's task handler. + (this.viewLocalState.currentApiConfigName === undefined && + globalSettings.currentApiConfigName === profileToDelete.name) + + // Prune the profile from the shared list after the deletion has committed when + // this view keeps an unrelated pin: the activation path below writes the list + // itself, because it re-reads the profile entries from the settings store. + if (!viewPinsDeletedProfile) { + const survivingEntries = this.getProviderProfileEntries().filter( + ({ name }) => name !== profileToDelete.name, + ) + await this.contextProxy.setValue("listApiConfigMeta", survivingEntries) + } + + if (viewPinsDeletedProfile) { + // Apply the replacement through the activation path so this view's + // viewLocalState.apiConfiguration and the current task's api handler are + // refreshed; a name-only update would leave the deleted profile's settings + // behind in both. + await this.activateProviderProfile({ name: profileToActivate }) + + // The return below would otherwise skip the sibling re-pin further down: + // the activation's pinned-name refresh only selects siblings pinned to + // the replacement, so live views still buffered on the deleted profile + // must be re-pointed here as well, with the replacement's settings when + // they can be resolved (a name-only re-pin is still better than leaving + // the deleted name behind). + let replacementSettings: ProviderSettings | undefined + try { + const { name: _replacementName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + replacementSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + await this.rePinViewLocalStateForDeletedProfile( + profileToDelete.name, + profileToActivate, + replacementSettings, + ) + return + } + + // This view pins an unrelated profile, which must survive the deletion. The shared + // list was already staged above the secret deletion (see the staging block at the + // top of this method), so only the replacement wiring remains here: resolve the + // surviving profile's settings so the shared provider keys and any other live + // view still pinned to the deleted profile can be updated with a matching + // configuration. + + let survivingSettings: ProviderSettings | undefined + try { + const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + // This view does not pin the deleted profile here (that case returned + // above), so only the shared selection can dangle: when the deleted profile + // was the globally selected one, the shared slot and the shared provider + // settings still carry its configuration. + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName + + if (deletedWasGlobal) { + // The shared selection changed, but this view's own pin (if any) still + // names a surviving profile: update the shared store only, leaving the + // view-local pin untouched. + await this.contextProxy.setValue("currentApiConfigName", profileToActivate) + } + + if (deletedWasGlobal && survivingSettings) { + // The deleted profile was the globally active one, so the shared + // provider keys still carry its settings; replace them so getState() + // reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + } + + // Re-pin other live views still buffered on the deleted profile: their + // buffer and durable viewStates entry would otherwise keep serving the + // deleted profile's name and configuration. + await this.rePinViewLocalStateForDeletedProfile(profileToDelete.name, profileToActivate, survivingSettings) await this.postStateToWebview() } @@ -1965,12 +2778,62 @@ export class ClineProvider } } + /** + * Snapshot the settings of sibling views pinned to a different profile into + * their view-local overlays before an activation or upsert overwrites the + * shared provider-settings store. `getState()` merges the overlay over the + * shared store, so a sibling pinned to another profile with no overlay would + * otherwise start reporting its pin's name together with the newly activated + * profile's settings - and a task created from that view would run against + * the wrong provider, model, and credentials. Siblings pinned to the + * activated profile are refreshed by `refreshViewLocalStateForUpdatedProfile`; + * unpinned siblings follow the shared slot, where name and settings move + * together. + */ + private async snapshotSiblingOverlaysBeforeSharedSettingsWrite(activatedProfileName: string): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => + instance !== this && + instance.pinnedProfileName !== undefined && + instance.pinnedProfileName !== activatedProfileName && + instance.viewLocalState.apiConfiguration === undefined, + ) + + await Promise.all( + affected.map(async (instance) => { + const pinName = instance.pinnedProfileName + if (pinName === undefined) { + return + } + + try { + const { name: _siblingPinName, ...settings } = await this.providerSettingsManager.getProfile({ + name: pinName, + }) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: settings as ProviderSettings }) + await instance.postStateToWebview() + } catch (error) { + // If the pinned profile cannot be resolved, leave the overlay as is: + // the deletion path re-points such views, and a wrong snapshot is + // worse than the pre-write shared values. + this.log( + `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }), + ) + } + async activateProviderProfile( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + preserveForeignTask?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => @@ -1984,6 +2847,7 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + preserveForeignTask?: boolean }, signal?: AbortSignal, ): Promise { @@ -1997,11 +2861,31 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + + // Siblings pinned to other profiles must keep their own settings: the + // shared write below replaces the shared store, so pin their profile's + // settings into their overlays first. + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) + await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), + this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain ContextProxy write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) + + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the activated profile's + // name with stale settings. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } const { mode } = await this.getState() @@ -2010,8 +2894,18 @@ export class ClineProvider await this.providerSettingsManager.setModeConfig(mode, id) } - // Change the provider for the current task. - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild }) + // Change the provider for the current task. A launch-time pin repair + // (preserveForeignTask) must not switch a task that follows a different + // sticky profile: the repair deliberately leaves the task's sticky profile + // untouched, so its handler and credentials must stay on that profile too. + const foreignTaskFollowed = + options?.preserveForeignTask === true && + this.getCurrentTask()?.taskApiConfigName !== undefined && + this.getCurrentTask()?.taskApiConfigName !== name + this.updateTaskApiHandlerIfNeeded(providerSettings, { + forceRebuild: true, + skipCurrentTaskRebuild: skipCurrentTaskRebuild || foreignTaskFollowed, + }) // Update the current task's sticky provider profile, unless this activation is // being used purely as a non-persisting restoration (e.g., reopening a task from history). @@ -2028,6 +2922,99 @@ export class ClineProvider } } + /** + * Refresh the view-local apiConfiguration buffer of the other live views + * pinned to the given profile. An upsert/activation rewrites the profile's + * settings in the shared store and the store-backed manager, but a view + * whose buffer loaded the profile earlier keeps shadowing the stale + * settings in its getState() until its own next mutation. The originating + * view refreshes its buffer at the mutation site itself. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === name, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + + // The active task of a pinned view holds its own API handler built from + // the profile's settings: rebuild it with the saved settings or the + // running conversation keeps sending the old token even though the + // view's buffer has caught up. Only rebuild when the task actually runs + // under the saved profile: a task that follows a different sticky + // profile must keep its own settings. Direct private access across + // sibling instances of the same class is compile-time safe. + if (instance.getCurrentTask()?.taskApiConfigName === name) { + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } + await instance.postStateToWebview() + }), + ) + } + + /** + * Re-pin the other live views whose buffer still names a deleted profile: + * without this their in-memory buffer and durable viewStates entry keep + * serving the deleted profile's name and configuration on top of the + * surviving shared state. Each affected view's durable entry is re-pinned + * through the serialized write queue, so the rename survives reloads. + */ + private async rePinViewLocalStateForDeletedProfile( + deletedProfileName: string, + replacementName: string, + replacementSettings: ProviderSettings | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + if (replacementSettings) { + values.apiConfiguration = replacementSettings + } + + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation(values) + // The active task of a view pinned to the deleted profile holds its own API + // handler and sticky profile name built from the deleted profile: rebuild + // the handler with the replacement's settings and re-point the sticky name, + // or the running conversation keeps sending the deleted profile's + // configuration even though the view's buffer was re-pointed (the posted + // state prefers the task's own apiConfiguration). Only re-point when the + // task actually runs under the deleted profile: a view pinned to it can + // hold a task that follows a different sticky profile, and forcing the + // replacement would silently switch that conversation and rewrite its + // persisted profile name without the user asking. + if (instance.getCurrentTask()?.taskApiConfigName === deletedProfileName) { + if (replacementSettings) { + instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(replacementName) + } + await instance.postStateToWebview() + }), + ) + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) @@ -2163,8 +3150,10 @@ export class ClineProvider // picks up the new token immediately for the current task. await this.upsertProviderProfile(entry.name, updated, true) } else { - // Non-active profiles just need the token saved to disk. - await this.providerSettingsManager.saveConfig(entry.name, updated) + // Non-active profiles need the token saved to disk and the views pinned + // to them refreshed: a direct saveConfig leaves a pinned view serving the + // old token until it reloads. + await this.upsertProviderProfile(entry.name, updated, false) } } } @@ -2904,12 +3893,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 : providerIdentifiers.anthropic // Build the apiConfiguration object combining state values and secrets. @@ -2970,122 +3965,137 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // A view-local profile is a complete snapshot, not a patch over the shared store. + // Spreading it on top of providerSettings would let a key the profile never defined + // leak in from another profile, which ContextProxy.setProviderSettings deliberately + // prevents by clearing omitted keys. Use the view's own settings in place and take + // only apiProvider from the shared state when the profile does not carry one. + // The overlay is read from the buffer itself: the raw apiConfiguration state key + // is a partial patch from shared-store paths, not a view snapshot. + const viewApiConfiguration = this.viewLocalState.apiConfiguration + const resolvedApiConfiguration = viewApiConfiguration + ? { + ...viewApiConfiguration, + apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, + } + : providerSettings + 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: resolvedApiConfiguration, + 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, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysDenyUnapprovedCommands: - stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, - 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.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, + 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, - 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, + 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 ?? providerIdentifiers.openai, - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.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, } } @@ -3149,7 +4159,45 @@ export class ClineProvider } public async setValue(key: K, value: RooCodeSettings[K]) { + if (key === "mode" && value !== undefined) { + // The generic signature keeps the nominal RooCodeSettings[K] even after K is + // narrowed to "mode" (runtime type string | undefined); one assertion to the + // narrowed shape lets the shared guard type-check, mirroring setValues. + const modeValue = value as string | undefined + if ( + typeof modeValue !== "string" || + !getModeBySlug(modeValue, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValue] Ignoring invalid mode "${String(value)}"`) + return + } + } + + const previousSharedValues = { [key]: this.contextProxy.getValue(key) } as RooCodeSettings await this.contextProxy.setValue(key, value) + try { + await this._saveViewLocalStateFromMutation({ [key]: value }) + } catch (error) { + // The per-view record did not land: restore the prior shared value so the two + // persistence layers cannot diverge, then surface the original failure. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues) + throw error + } + } + + /** + * Restore shared values captured before a setter wrote them, used when the paired + * per-view persistence failed so the shared store cannot move ahead of the per-view + * record. A failed rollback is logged; the original failure is what callers see. + */ + private async _restoreSharedValuesAfterFailedViewWrite(previous: RooCodeSettings): Promise { + try { + await this.contextProxy.setValues(previous) + } catch (rollbackError) { + this.log( + `[ClineProvider] Shared-state rollback after failed per-view write failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } } public getValue(key: K) { @@ -3157,11 +4205,178 @@ 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) + const sanitizedValues = { ...values } + + if (sanitizedValues.mode !== undefined) { + // An unknown or non-string mode (e.g. from an API payload) must not be persisted: + // a new Task would read it from getState() and persist it into task history. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } + } + + const previousSharedValues = Object.fromEntries( + Object.keys(sanitizedValues).map((key) => [key, this.contextProxy.getValue(key as keyof RooCodeSettings)]), + ) as RooCodeSettings + await this.contextProxy.setValues(sanitizedValues) + try { + await this._saveViewLocalStateFromMutation(sanitizedValues) + } catch (error) { + // The per-view record did not land: restore the prior shared values so the two + // persistence layers cannot diverge, then surface the original failure. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues) + throw error + } + } + + /** + * 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) { + this.viewLocalStateMutatedFields.add("mode") + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val + } + } + + if ("currentApiConfigName" in values) { + this.viewLocalStateMutatedFields.add("currentApiConfigName") + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val + } + } + + if ("apiConfiguration" in values) { + this.viewLocalStateMutatedFields.add("apiConfiguration") + const val = values.apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } + + // Flat provider-settings keys (PROVIDER_SETTINGS_KEYS) are shared settings: + // they are written through the ContextProxy above and must NOT be merged + // into viewLocalState.apiConfiguration, which would turn them into a + // per-view override masking later shared updates from other views. If the + // mutation carries such a key, drop this view's overlay instead so it cannot + // mask the just-written shared values in getState() (the overlay, seeded by + // a persisted pin or a sibling refresh, is older than this shared write). + if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + delete this.viewLocalState.apiConfiguration + } + } + + /** + * 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 = {} + } + + /** + * Broadcast a reset/import invalidation to all live ClineProvider instances, clearing + * both in-memory view-local caches and durable per-view selections so stale view state + * cannot mask imported/reset shared state after reload. + * + * The durable clear runs on the serialized viewStates write queue so it is ordered + * against every in-flight savePersistedViewState: a queued save that ran after a + * direct clear would re-read the emptied map and re-create its captured per-view pin, + * leaving a stale selection that rehydrates after a reload. The in-memory buffer is + * cleared only after the queued clear has run, because a save enqueued before it + * writes its captured value back into the buffer when its durable write completes. + */ + async broadcastResetToAllInstances(): Promise { + const allInstances = ClineProvider.getAllInstances() + + // The durable per-view selections live in one shared global-state slot, so + // the reset needs exactly one durable clear. Route it through the persisted + // write queue (and run it before the in-memory clears): a per-instance clear + // outside the queue could interleave with a queued view-state write and + // either clobber that write or leave a re-created entry behind. + const clearWrite = ClineProvider.persistedViewStateWriteQueue.then(async () => { + await this.contextProxy.setValue("viewStates", undefined) + }) + ClineProvider.persistedViewStateWriteQueue = clearWrite.catch(() => {}) + await clearWrite + + for (const instance of allInstances) { + // Clear the in-memory buffer only after the queued durable clear has run: + // a save enqueued before the clear writes its captured value back into the + // buffer when its durable write completes, so an immediate clear would let + // that stale pin survive in the live state. + instance._clearViewLocalState() + + if (instance !== this) { + // A sibling's post can throw mid-reset (state generation reaches the + // settings file through customModesManager.getCustomModes): the failure + // must not stop the reset from reaching the remaining instances, whose + // buffers are already cleared above. The originator is deliberately not + // posted here: resetState posts its own state immediately after the + // broadcast, and a second post would refresh its webview twice. + try { + await instance.postStateToWebview() + } catch (error) { + this.log( + `[broadcastResetToAllInstances] failed to post reset state to a sibling view: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + } } // dev @@ -3190,9 +4405,21 @@ 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() + + // Clear durable and in-memory per-view state across live instances so parallel tabs don't keep stale state. + await this.broadcastResetToAllInstances() + await this.postStateToWebview() await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } 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..db5ad8639f --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,2259 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.parallelMode.spec.ts + +import * as vscode from "vscode" + +import { + type ProviderSettingsEntry, + type ProviderSettingsWithId, + type RooCodeSettings, + PROVIDER_SETTINGS_KEYS, + RooCodeEventName, + providerIdentifiers, +} from "@roo-code/types" + +import { ContextProxy } from "../../config/ContextProxy" +import { WebviewFocusTracker } from "../WebviewFocusTracker" +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 + } + } + } + + // Mirrors the real ContextProxy contract: every key in the state cache is + // served as-is, so values written via setValue round-trip — including the + // durable viewStates map that ClineProvider rewrites directly (a canned key + // list here would let a stale-snapshot regression hide from every test). + getValues = vi.fn().mockImplementation(() => ({ + ...defaultState, + ...this.stateCache, + })) + getValue = vi.fn().mockImplementation((key: string) => this.stateCache[key]) + // Mirrors the real ContextProxy contract: the flat provider-settings keys are served + // from the state cache, so values written via setProviderSettings round-trip. + // The apiProvider default mirrors the schema default for an unwritten store. + getProviderSettings = vi.fn().mockImplementation(() => { + const flat: Record = {} + for (const key of PROVIDER_SETTINGS_KEYS) { + const value = this.stateCache[key] + if (value !== undefined) { + flat[key] = value + } + } + return { apiProvider: providerIdentifiers.anthropic, ...flat } + }) + 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() + const 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"], + }, + ] + return { + ...actual, + modes, + // Resolve against the mocked mode list above (not the real module modes) so the + // lookup matches exactly what the tests set up. + getModeBySlug: vi.fn().mockImplementation((slug: string) => { + return 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), + deleteConfig: 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 = Object.assign({} as vscode.ExtensionContext, { + 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, + }) + + mockOutputChannel = Object.assign({} as vscode.OutputChannel, { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + }) + }) + + const createMockWebviewView = (postMessage = vi.fn()) => + Object.assign({} as vscode.WebviewView, { + 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() })), + }) + + describe("persisted view state pruning edge cases", () => { + it("should drop the entry without updatedAt first when the cap is exceeded", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // An entry written before updatedAt existed ranks below every timestamped entry + // (updatedAt ?? 0) and is the first to fall off the cap. + const states = { + ...Object.fromEntries( + Array.from({ length: 50 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index + 1 }, + ]), + ), + "view-missing": { mode: "mode-legacy" }, + } + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-missing"]).toBeUndefined() + // Surviving entries must retain their complete persisted state, not just + // exist: pin the exact fields of the oldest and newest survivors. + expect(pruned["view-0"]).toEqual({ mode: "mode-0", updatedAt: 1 }) + expect(pruned["view-49"]).toEqual({ mode: "mode-49", updatedAt: 50 }) + + await provider.dispose() + }) + + it("should keep the earliest inserted entries when updatedAt values tie", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Equal timestamps preserve insertion order (stable sort), so the first 50 + // registered views survive and the last 5 fall off the cap. + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [`view-${index}`, { mode: `mode-${index}`, updatedAt: 1 }]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + // Surviving entries must retain their complete persisted state, not just + // exist: a prune that mutates a surviving entry must fail the test. + expect(pruned["view-0"]).toEqual({ mode: "mode-0", updatedAt: 1 }) + expect(pruned["view-49"]).toEqual({ mode: "mode-49", updatedAt: 1 }) + expect(pruned["view-50"]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop corrupt viewStates entries on read so subsequent writes do not reject", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // Corrupt entries (null, or a torn non-object value) can land in the durable map: + // they must be dropped at the read boundary, not throw in the prune sort when a + // mode/profile write re-reads the map fresh. + await mockContext.globalState.update("viewStates", { + "view-null": null, + "view-valid": { mode: "code", updatedAt: 1 }, + }) + + await provider["setViewStateId"]("view-writer") + await provider.saveViewState("mode", "architect") + + // One drop: the exact singular message (a substring assertion cannot tell + // "entry" from "entries"), so the wording is pinned verbatim. + expect(logSpy).toHaveBeenCalledWith("[getPersistedViewStates] dropped 1 invalid viewStates entry") + + // The first fresh read already removed the null entry, so seed two new corrupt + // entries (a torn non-object value and a null) for the next fresh read. + const states = mockContext.globalState.get("viewStates") as Record + // Write through the proxy so the provider's cache and the durable map stay + // in step for the next fresh read. + // Simulate a torn/corrupt durable write: the map type forbids such entries, + // which is exactly what a torn write can leave behind (double assertion). + await provider.contextProxy.setValue("viewStates", { + ...states, + "view-torn": "torn-write", + "view-null2": null, + } as unknown as RooCodeSettings["viewStates"]) + await provider.saveViewState("mode", "build") + + // Two drops: the exact plural message must survive the second fresh read. + expect(logSpy).toHaveBeenCalledWith("[getPersistedViewStates] dropped 2 invalid viewStates entries") + + const statesAfter = mockContext.globalState.get("viewStates") as Record + expect(statesAfter["view-null"]).toBeUndefined() + expect(statesAfter["view-torn"]).toBeUndefined() + expect(statesAfter["view-null2"]).toBeUndefined() + expect(statesAfter["view-valid"]).toEqual({ mode: "code", updatedAt: 1 }) + expect(statesAfter["view-writer"]).toEqual({ mode: "build", updatedAt: expect.any(Number) }) + + // A clean re-read must not log a drop: the log must stay tied to dropped > 0. + await provider.saveViewState("mode", "debugger") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("dropped 0")) + + // Release the provider from the static instance registry so later tests' cross- + // instance broadcasts do not iterate a provider bound to this test's context. + await provider.dispose() + }) + + it("should drop malformed stored entries before pruning", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Storage can hold corrupted entries (undefined holes or non-object values) after + // partial writes: the guard keeps them out of the sorted map so the prune cannot + // read updatedAt off a missing entry. + const states = Object.assign({} as Parameters[0], { + "view-void": undefined, + "view-corrupt": "not-an-object", + "view-good": { mode: "ask", updatedAt: 1 }, + }) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toEqual(["view-good"]) + expect(pruned["view-good"]).toEqual({ mode: "ask", updatedAt: 1 }) + + await provider.dispose() + }) + }) + + describe("durable editor view state retention (#1065)", () => { + 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), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "tab-to-preserve": { mode: "architect" }, + }) + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "tab-to-preserve": { mode: "architect" }, + }) + }) + }) + + describe("profile mutations", () => { + it("should synchronize viewLocalState when activateProviderProfile mutates ContextProxy", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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", + }) + // The activation path clears (not seeds) the view-local overlay: the shared + // store serves the activated settings, so no per-view snapshot can mask + // later shared edits. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + + it("should synchronize viewLocalState when upsertProviderProfile activates a saved profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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", + }) + // Same A-semantics contract: the upsert/activation path clears the overlay + // instead of snapshotting the activated settings into it. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + + it("should synchronize viewLocalState when deleteProviderProfile selects a replacement profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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 }, + } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Structural cast: the env mock shapes activateProfile results as getProfile results. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + } as unknown as Awaited>) + + 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 }, + ]) + // The replacement's settings are served from the shared store through + // getState(); the view-local overlay is cleared on the activation path, so + // it cannot keep serving the deleted profile's configuration. + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + }) + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(vi.mocked(provider.providerSettingsManager.deleteConfig)).toHaveBeenCalledWith("deleted-profile") + + 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), + new WebviewFocusTracker(), + ) + 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. + await 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 keep the re-pointed viewStates entry when the deleting view pins a surviving profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // This view pins a surviving profile (unrelated pin: the fallback branch), + // while the shared selection names the profile about to be deleted. + provider["viewLocalState"] = { + currentApiConfigName: "keeper-profile", + } + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // A sibling's durable pin names the deleted profile: the re-point pass runs + // before the fallback's shared writes, and a stale full-snapshot replay would + // undo it by rewriting the pre-re-point map back into the store. + await mockContext.globalState.update("viewStates", { + "view-deleted": { mode: "architect", currentApiConfigName: "doomed-profile", updatedAt: 2 }, + }) + // Pin the branch itself: a view following a shared selection that names the + // deleted profile would take the activation path, so a pinned view must take + // the fallback. The fallback resolves the survivor's settings through the + // profile manager and writes them to the shared provider settings. + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation( + async (_args: { name?: string; id?: string }) => + ({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "keeper-key", + }) as unknown as Awaited>, + ) + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // The re-pointed sibling entry survives the fallback's targeted writes ... + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "view-deleted": { mode: "architect", currentApiConfigName: "keeper-profile" }, + }) + // ... the shared selection moves to the replacement ... + expect(mockContext.globalState.get("currentApiConfigName")).toBe("keeper-profile") + // ... and this view's own pin is left untouched. + expect(provider["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + // The view took the fallback, not the activation path ... + expect(activateSpy).not.toHaveBeenCalled() + // ... and the shared provider settings carry the survivor's configuration + // instead of the deleted profile's. + expect(provider.contextProxy.getProviderSettings()).toMatchObject({ + openRouterApiKey: "keeper-key", + }) + + await provider.dispose() + }) + + it("should not write viewStates when re-pointing skips a corrupt stored entry", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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 }, + ]) + // The stored map pins the keeper profile and holds a corrupt (null) entry: no pin + // references the doomed profile, so the re-point pass must skip every entry and + // leave the map untouched. + const corruptFixture = { + "view-keeps": { mode: "code", currentApiConfigName: "keeper-profile", updatedAt: 1 }, + "view-null": null, + } + await mockContext.globalState.update("viewStates", corruptFixture) + provider["viewLocalState"] = { + currentApiConfigName: "keeper-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + expect(setValueSpy).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(mockContext.globalState.get("viewStates")).toEqual(corruptFixture) + expect(activateSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should re-point a mode-less legacy pin to a fresh two-field pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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 }, + ]) + // A pre-mode-pinning legacy entry pins the doomed profile and carries an unrelated + // field: the re-pointed entry must be a fresh two-field pin, not a spread of the old one. + await mockContext.globalState.update("viewStates", { + "view-legacy": { currentApiConfigName: "doomed-profile", updatedAt: 2, legacyFlag: true }, + }) + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // The empty viewLocalState takes the unrelated-pin branch (the shared selection + // does not reference the deleted profile), so assert on the re-pointed entry + // rather than the whole map. + expect(mockContext.globalState.get("viewStates")).toEqual( + expect.objectContaining({ + "view-legacy": { currentApiConfigName: "keeper-profile", updatedAt: expect.any(Number) }, + }), + ) + + 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), + new WebviewFocusTracker(), + ) + provider["viewLocalState"] = { + mode: "architect", + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + } + + await provider.resetState() + + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("should refresh the shared profile list when upsertProviderProfile saves without activating", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const quietEntry: ProviderSettingsEntry = { + id: "quiet-id", + name: "quiet-profile", + apiProvider: providerIdentifiers.openrouter, + } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([quietEntry]) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + const savedId = await provider.upsertProviderProfile( + "quiet-profile", + { apiProvider: providerIdentifiers.openrouter }, + false, + ) + + // The non-activating path persists the refreshed shared profile list under the durable + // key while leaving the current selection and profile activation untouched. + expect(savedId).toBe("test-id") + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual([quietEntry]) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("default") + + await provider.dispose() + }) + + it("should not activate for an unrelated deletion when the deleting view has no profile pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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 }, + ]) + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + const setValuesSpy = vi.spyOn(provider.contextProxy, "setValues") + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // A view without its own profile pin follows the shared selection, which does not + // reference the deleted profile: no activation (an unrelated deletion must not + // rebuild this view's task handler), and only a targeted shared-list write that + // leaves the shared selection and every other key untouched. + expect(activateSpy).not.toHaveBeenCalled() + expect(setValuesSpy).not.toHaveBeenCalled() + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual([ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + ]) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("keeper-profile") + + await provider.dispose() + }) + + it("should activate the replacement when the shared selection references the deleted profile and the view has no pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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 }, + ]) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Structural cast: the env mock shapes activateProfile results as getProfile results. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + } as unknown as Awaited>) + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // The shared selection referenced the deleted profile, so the unpinned view takes + // the activation path and adopts the replacement profile. + expect(activateSpy).toHaveBeenCalledWith({ name: "replacement-profile" }) + expect(await provider.getState()).toMatchObject({ currentApiConfigName: "replacement-profile" }) + + await provider.dispose() + }) + + it("should refresh the pinned view through activation when it pinned the deleted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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 }, + } + const listConfigSpy = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Structural cast: the env mock shapes activateProfile results as getProfile results. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + } as unknown as Awaited>) + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // The pinning view must take the activation path with the replacement profile... + expect(activateSpy).toHaveBeenCalledWith({ name: "replacement-profile" }) + // ...and the shared store serves the replacement settings through + // getState(), while the cleared view-local overlay cannot mask them... + expect((await provider.getState()).apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + }) + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + // ...and the unrelated-pin fallback is ruled out by the activation list + // lookup: the fallback reads the surviving entries from the store instead + // of resolving the activation list. + expect(listConfigSpy).toHaveBeenCalled() + + await provider.dispose() + }) + + it("should re-pin a live sibling view that pins the deleted profile when the deleting view has no pin", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // B pins the doomed profile without an overlay (a fresh pin via setValue). + await providerB.setValue("currentApiConfigName", "doomed-profile") + + // A has no profile pin of its own; the shared selection names the doomed profile. + await providerA.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await providerA.contextProxy.setValue("listApiConfigMeta", [ + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + + // Structural cast: the env mock shapes activateProfile/getProfile results uniformly. + vi.spyOn(providerA.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + } as unknown as Awaited>) + vi.spyOn(providerA.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // B has an active task built from the doomed profile's settings: the posted + // state prefers the task's own apiConfiguration, so the re-pin must rebuild + // the task's handler and re-point its sticky profile name too. + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "doomed-key" }, + taskApiConfigName: "doomed-profile", + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + } + vi.spyOn(providerB, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + + // The pre-return re-pin resolves the replacement's settings through the manager. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation( + async (_args: { name?: string; id?: string }) => + ({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + }) as unknown as Awaited>, + ) + + await providerA.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // B must be re-pointed at the replacement with the replacement's settings, + // not left buffered on the deleted profile's name and configuration. + expect(providerB["viewLocalState"].currentApiConfigName).toBe("replacement-profile") + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + }) + expect((await providerB.getState()).currentApiConfigName).toBe("replacement-profile") + // The active task's handler is rebuilt with the replacement's settings and + // its sticky profile name re-points to the replacement: without this the + // running conversation keeps sending the deleted profile's configuration. + // objectContaining: the replacement's settings carry the profile id alongside + // the provider fields (the name is stripped, the id is not). + expect(siblingTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + }), + ) + expect(siblingTask.setTaskApiConfigName).toHaveBeenCalledWith("replacement-profile") + + await providerA.dispose() + await providerB.dispose() + }) + }) + + describe("provider profile activation", () => { + it("should serve the activated profile settings through getState when activating an upserted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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") + // The upsert/activation path clears the view-local overlay rather than + // snapshotting the activated settings into it, so the shared store remains + // the single source getState() serves. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + + it("should keep a sibling pinned to another profile on its own settings after a different profile activates", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // B pins profile-y without an overlay (a fresh pin via setValue). + await providerB.setValue("currentApiConfigName", "profile-y") + + // A activates profile-x: the shared store takes x's settings. + vi.spyOn(providerA.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "profile-x", + id: "x-id", + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + } as unknown as Awaited>) + vi.spyOn(providerA.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "y-id", name: "profile-y", apiProvider: providerIdentifiers.openrouter }, + { id: "x-id", name: "profile-x", apiProvider: providerIdentifiers.anthropic }, + ]) + // The snapshot resolves each sibling's pinned profile through A's manager. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => + args.name === "profile-y" + ? ({ + name: "profile-y", + id: "y-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "y-key", + } as unknown as Awaited>) + : ({ + name: "profile-x", + id: "x-id", + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + } as unknown as Awaited>), + ) + + await providerA.activateProviderProfile({ name: "profile-x" }) + + // A serves x's settings under its own pin... + const aState = await providerA.getState() + expect(aState.currentApiConfigName).toBe("profile-x") + // anthropicBaseUrl is a non-secret PROVIDER_SETTINGS_KEYS key, so the mock shared store serves + // it back through getProviderSettings(); the api key itself is a SECRET_STATE_KEYS entry and + // would not round-trip through the mock, so the fixture avoids it. + expect(aState.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + }) + + // ...and B keeps its own pin on its own profile's settings: the snapshot + // written before the shared write must shield B from A's activation. + const bState = await providerB.getState() + expect(bState.currentApiConfigName).toBe("profile-y") + expect(bState.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "y-key", + }) + expect(providerB["viewLocalState"].apiConfiguration).toBeDefined() + + await providerA.dispose() + await providerB.dispose() + }) + + it("should drop the view-local overlay when flat provider settings are written through setValues", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed a pinned overlay (as loadViewState would after a reload). + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + expect(provider["viewLocalState"].apiConfiguration).toMatchObject({ openRouterApiKey: "stale-key" }) + + // A flat provider-settings write reaches the shared store and must not be + // masked by the pinned overlay in getState(). + await provider.setValues({ apiProvider: providerIdentifiers.zai }) + + const state = await provider.getState() + expect(state.apiConfiguration.apiProvider).toBe("zai") + expect(state.apiConfiguration).not.toHaveProperty("openRouterApiKey") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + const getModeConfigIdSpy = vi.spyOn(provider.providerSettingsManager, "getModeConfigId") + + await provider.resolveWebviewView(createMockWebviewView(postMessage)) + postMessage.mockClear() + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + // The locked path still posts the switched state — assert the payload so a + // regression that posts nothing (or a stale pre-switch state) fails here. + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ mode: "architect" }), + }), + ) + + await provider.dispose() + }) + + it("should activate configured mode profile when switching modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + 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), + new WebviewFocusTracker(), + ) + 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), + new WebviewFocusTracker(), + ) + 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), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + // Minimal Task double: handleModeSwitch and addClineToStack only touch the fields above. + 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() + }) + + it("should log and no-op when handleModeSwitch receives an unknown mode slug", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const logSpy = vi.spyOn(provider, "log") + + await provider.handleModeSwitch("bogus-slug") + + // Unknown slugs are rejected before any durable write: nothing is persisted under + // the view and no profile mutation is queued. + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ignoring unknown mode "bogus-slug"')) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + // K1: a no-task switch captures its target before any task is focused; a task gains + // focus before the queued mutation runs. The view-level durable write and the + // ModeChanged broadcast belong to the view and must still happen. + it("should keep the view-level effects when a no-task switch is captured before a task gains focus", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + await provider["setViewStateId"]("k1-view") + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + // Minimal Task double: addClineToStack only touches the fields above, so the + // structural cast stands in for the omitted Task internals. + vi.spyOn(provider.customModesManager, "getCustomModes").mockImplementationOnce(async () => { + await provider.addClineToStack(makeTask("late-focus-task") as unknown as Task) + return [] + }) + + await provider.handleModeSwitch("architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toEqual( + expect.objectContaining({ + "k1-view": expect.objectContaining({ mode: "architect" }), + }), + ) + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + await provider.dispose() + }) + + // K2: a non-focused switch with a saved mode config in the mocks must not load it: + // the task-scoped switch applies to the task only, and the view-level profile load + // stays behind the view-scoped guard. + it("should skip the mode profile load for a non-focused switch even when a saved config exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + // Minimal Task double: handleModeSwitch and addClineToStack only touch the fields above. + await provider.addClineToStack(makeTask("focused-task") as unknown as Task) + const backgroundTask = makeTask("background-task") + await provider["setViewStateId"]("k2-view") + await provider.saveViewState("mode", "code") + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + const k2Entry: ProviderSettingsEntry = { + id: "k2-profile-id", + name: "k2-profile", + apiProvider: providerIdentifiers.openrouter, + } + const k2Settings: ProviderSettingsWithId & { name: string } = { + id: "k2-profile-id", + name: "k2-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "k2-key", + } + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("k2-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([k2Entry]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce(k2Settings) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + await provider.handleModeSwitch("architect", backgroundTask as unknown as Task) + + // The task-scoped switch still lands on the background task... + expect(backgroundTask.emit).toHaveBeenCalledWith( + RooCodeEventName.TaskModeSwitched, + "background-task", + "architect", + ) + // ...but the view-level profile load stays untouched: no mode profile lookup, no + // activation, no durable list rewrite, no viewStates write, and an unchanged buffer. + expect(provider.providerSettingsManager.getModeConfigId).not.toHaveBeenCalled() + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([]) + expect(modeChangedSpy).not.toHaveBeenCalled() + expect(mockContext.globalState.get("viewStates")).toEqual( + expect.objectContaining({ + "k2-view": expect.objectContaining({ mode: "code" }), + }), + ) + expect(provider["viewLocalState"]).toEqual({ mode: "code" }) + + await provider.dispose() + }) + + it("should roll back the shared mode write when the durable write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The shared store already carries a mode, so the rollback has a concrete value + // to restore. + await provider.contextProxy.setValue("mode", "code") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + // Only the fresh durable mode write fails; the rollback write succeeds. + if (key === "mode" && value !== "code") throw new Error("durable write failed") + }) + // The provider's async initialization can record stray writes on the spy + // before this test's mutation starts; start the assertion window clean. + setValueSpy.mockClear() + + await expect(provider.handleModeSwitch("ask")).rejects.toThrow("durable write failed") + + // The failed write was rolled back to the previous shared mode, so the shared + // store cannot be left with the new mode's half-applied state. + const modeWrites = setValueSpy.mock.calls.filter(([key]) => key === "mode") + expect(modeWrites).toEqual([ + ["mode", "ask"], + ["mode", "code"], + ]) + + await provider.dispose() + }) + + it("should log the rollback failure and rethrow the original error when the rollback also fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "code") + const appendLine = vi.spyOn(mockOutputChannel, "appendLine") + const durableError = new Error("durable write failed") + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key) => { + if (key === "mode") throw durableError + }) + + await expect(provider.handleModeSwitch("ask")).rejects.toThrow("durable write failed") + + // The rollback failure is logged with its own message and the original error + // still propagates: a failed switch must not be swallowed into a logged no-op. + expect(appendLine).toHaveBeenCalledWith(expect.stringContaining("Failed to roll back shared mode")) + expect(appendLine).toHaveBeenCalledWith(expect.stringContaining('Failed to persist mode "ask"')) + + 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), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider3 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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() + }) + + it("should keep resetting siblings when one sibling's state post fails", async () => { + vi.mocked(vscode.window.showInformationMessage).mockImplementationOnce( + async (_message: string, _options: unknown, ...items: vscode.MessageItem[]) => items[0], + ) + const caller = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const failingSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const laterSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await caller.resolveWebviewView(createMockWebviewView()) + await laterSibling.resolveWebviewView(createMockWebviewView()) + await failingSibling.saveViewState("mode", "architect") + await laterSibling.saveViewState("mode", "debugger") + // State generation throws for the failing sibling mid-reset (e.g. through a + // settings-file write in customModesManager.getCustomModes): the broadcast must + // still reach the later sibling instead of aborting. + vi.spyOn(failingSibling, "postStateToWebview").mockRejectedValue(new Error("state generation failed")) + const logSpy = vi.spyOn(caller, "log") + + await caller.resetState() + + // The failing sibling's buffer was cleared before its post failed ... + expect(failingSibling["viewLocalState"]).toEqual({}) + // ... the broadcast continued to the later sibling and cleared it too ... + expect(laterSibling["viewLocalState"]).toEqual({}) + // ... and the failure was logged by the caller. + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("failed to post reset state to a sibling view")) + + await caller.dispose() + await failingSibling.dispose() + await laterSibling.dispose() + }) + + it("should order the broadcast clear after an in-flight view-state save", async () => { + const caller = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await caller.resolveWebviewView(createMockWebviewView()) + await saver.resolveWebviewView(createMockWebviewView()) + + // Stall the saver's durable write so the broadcast's clear lands while it is + // still in the serialized write queue: a direct (un-queued) clear would let + // the save run after the clear and re-create its captured per-view pin. The + // stalled write still reaches storage once the gate opens, so the storage + // assertion below only passes if the clear actually ran after the save. + let resolveWrite: (value: undefined) => void = () => {} + const writeGate = new Promise((resolve) => (resolveWrite = resolve)) + // The proxy's setValue is already a vi.fn: vi.spyOn on it would return the same + // function, so a delegating spy would recurse into itself. Wrap the mock's + // existing implementation instead: gate the non-clear write, then delegate to + // the original store write so the stalled save still reaches storage. + const setValueMock = vi.mocked(saver.contextProxy.setValue) + const originalSetValueImplementation = setValueMock.getMockImplementation() + setValueMock.mockImplementation(async (key, value): Promise => { + if (key === "viewStates" && value !== undefined) { + await writeGate + } + await originalSetValueImplementation?.(key, value) + }) + const setValueSpy = setValueMock + + const save = saver.saveViewState("mode", "architect") + await vi.waitFor(() => expect(setValueSpy).toHaveBeenCalledWith("viewStates", expect.anything())) + + const broadcast = caller.broadcastResetToAllInstances() + + // The clear is queued behind the in-flight save, so the broadcast only settles + // once the stalled write completes. + resolveWrite(undefined) + await save + await broadcast + + // The clear won: the save's captured pin was not resurrected after the reset. + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(saver["viewLocalState"]).toEqual({}) + + await caller.dispose() + await saver.dispose() + }) + + it("clears the buffer of a first-constructed saving provider after a queued reset", async () => { + // The saver is constructed first so the broadcast reaches its buffer before the + // caller does: with the clear queued behind the in-flight save, the buffer must + // still end empty and the durable map cleared even though the save writes its + // captured pin back into the buffer when its durable write completes. + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const caller = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await saver.resolveWebviewView(createMockWebviewView()) + await caller.resolveWebviewView(createMockWebviewView()) + + let resolveWrite: (value: undefined) => void = () => {} + const writeGate = new Promise((resolve) => (resolveWrite = resolve)) + // The proxy's setValue is already a vi.fn: vi.spyOn on it would return the same + // function, so a delegating spy would recurse into itself. Wrap the mock's + // existing implementation instead: gate the non-clear write, then delegate to + // the original store write so the stalled save still reaches storage. + const setValueMock = vi.mocked(saver.contextProxy.setValue) + const originalSetValueImplementation = setValueMock.getMockImplementation() + setValueMock.mockImplementation(async (key, value): Promise => { + if (key === "viewStates" && value !== undefined) { + await writeGate + } + await originalSetValueImplementation?.(key, value) + }) + const setValueSpy = setValueMock + + const save = saver.saveViewState("mode", "architect") + await vi.waitFor(() => expect(setValueSpy).toHaveBeenCalledWith("viewStates", expect.anything())) + + const broadcast = caller.broadcastResetToAllInstances() + + resolveWrite(undefined) + await save + await broadcast + + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(saver["viewLocalState"]).toEqual({}) + + await caller.dispose() + await saver.dispose() + }) + }) + + describe("_clearViewLocalState", () => { + it("should clear all view-local state values", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + // 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 29b823cf50..e325b2a27c 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -14,6 +14,7 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, + type RooCodeSettings, type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -26,11 +27,15 @@ import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" +import { EMBEDDING_MODEL_PROFILES } from "../../../shared/embeddingModels" import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" +import { ProviderSettingsNotFoundError } from "../../config/ProviderSettingsManager" import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" +import { t } from "../../../i18n" +import type { MdmService } from "../../../services/mdm/MdmService" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -419,6 +424,45 @@ afterAll(() => { vi.restoreAllMocks() }) +/** + * Minimal profile shape the stalled getProfile double resolves to. The + * settings fields are optional so a lookup that resolves name-only (a + * profile with no configured provider) also type-checks. + */ +type StalledProfile = { + name: string + apiProvider?: string + openRouterModelId?: string +} + +/** + * Swap the provider's ProviderSettingsManager for a double whose getProfile + * stalls until the test resolves it, so tests can mutate view state while + * loadViewState is in flight. The double only backs getProfile, the member + * loadViewState awaits; the documented @ts-ignore replaces the per-test + * suppressions the inlined copies used. + */ +function stallProviderSettingsProfile(provider: ClineProvider) { + let resolveProfile: (value: StalledProfile) => void = () => {} + const getProfileSpy = vi.fn(() => { + // Only one lookup is resolvable: resolveProfile is bound to the first + // promise's resolver, so a second getProfile would strand its promise. + // Fail loudly instead of hanging. + if (getProfileSpy.mock.calls.length > 1) { + throw new Error( + "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", + ) + } + return new Promise((resolve) => (resolveProfile = resolve)) + }) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // Return a stable wrapper around the closure binding: resolveProfile is + // reassigned to the pending promise's resolver once getProfile is called, + // so returning the variable directly would hand the test the initial no-op. + return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } +} + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -456,6 +500,13 @@ describe("ClineProvider", () => { let mockPostMessage: any let updateGlobalStateSpy: any + afterEach(() => { + // Tests construct providers directly and many never dispose them; clear the + // static registry so a leaked instance cannot claim stable view state ids + // ahead of a later test. + ClineProvider["activeInstances"] = new Set() + }) + beforeEach(() => { vi.clearAllMocks() @@ -593,6 +644,18 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) + }) + + it("returns undefined when no live instance owns the view", () => { + expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined() + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -971,7 +1034,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: { @@ -1218,6 +1281,26 @@ describe("ClineProvider", () => { await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() }) + test("postMessageToWebview does not await the webview ack", async () => { + await provider.resolveWebviewView(mockWebviewView) + + let releaseAck!: () => void + const ack = new Promise((resolve) => { + releaseAck = resolve + }) + mockPostMessage.mockImplementationOnce(() => ack) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // The caller must not wait for the renderer ack: a webview page remounted or disposed + // while the post is in flight never acknowledges it, and awaiting that promise would + // wedge every caller on the task critical path. + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledWith(message) + releaseAck() + }) + test("postMessageToWebview leaves originalContent of file-edit tool messages out of the state it posts", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1337,10 +1420,13 @@ describe("ClineProvider", () => { }) test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { - const mdmService = { + // Structural double: the post path only reads these two members, and + // MdmService cannot be constructed as a plain object; Object.assign + // keeps this a single structural assertion. + const mdmService = Object.assign({} as MdmService, { requiresCloudAuth: vi.fn().mockReturnValue(true), isCompliant: vi.fn().mockReturnValue({ compliant: false, reason: "auth required" }), - } as any + }) provider = new ClineProvider( mockContext, @@ -1352,7 +1438,11 @@ describe("ClineProvider", () => { ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider as any, "getStateToPostToWebview").mockResolvedValue({ version: "1.0.0" }) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + version: "1.0.0", + }), + ) await provider.postStateToWebview() @@ -1367,113 +1457,3099 @@ describe("ClineProvider", () => { }) let statePostSettled = false - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - taskHistory: [], - } as unknown as ExtensionState) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + taskHistory: [], + }), + ) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) + + const statePost = provider.postStateToWebviewWithoutTaskHistory() + void statePost.then(() => { + statePostSettled = true + }) + await Promise.resolve() + + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(statePostSettled).toBe(false) + + releasePost() + await statePost + expect(statePostSettled).toBe(true) + }) + + test.each([ + ["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()], + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { + let releaseOlderSnapshot!: (state: ExtensionState) => void + const olderSnapshot = new Promise((resolve) => { + releaseOlderSnapshot = resolve + }) + const baseState = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + const emptyState: ExtensionState = { ...baseState, taskHistory: [], clineMessages: [] } + const readyState: ExtensionState = { + ...baseState, + taskHistory: [], + clineMessages: [{ ts: 1, type: "say", say: "text", text: "child ready" }], + } + + vi.spyOn(provider, "getStateToPostToWebview") + .mockReturnValueOnce(olderSnapshot) + .mockResolvedValueOnce(readyState) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + const olderPost = postState(provider) + await Promise.resolve() + const newerPost = postState(provider) + await newerPost + releaseOlderSnapshot(emptyState) + await olderPost + + expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ + readyState.clineMessages, + emptyState.clineMessages, + ]) + expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) + }) + + test.each([ + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + [ + "postStateToWebviewWithoutClineMessages", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), + ], + ])("%s skips task history computation", async (_methodName, postState) => { + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await postState(provider) + + expect(getAllSpy).not.toHaveBeenCalled() + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") + }) + + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { + const historyItem = { + id: "history-task", + number: 1, + ts: 1, + task: "History task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const originalGetState = provider.getState.bind(provider) + let baseStateResolved = false + const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { + const state = await originalGetState(options) + baseStateResolved = true + return state + }) + const historyReadPhases: boolean[] = [] + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { + historyReadPhases.push(baseStateResolved) + return [historyItem] + }) + + const state = await provider.getStateToPostToWebview() + + expect(getStateSpy).toHaveBeenCalledOnce() + expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) + expect(getAllSpy).toHaveBeenCalledOnce() + expect(historyReadPhases).toEqual([true]) + expect(state.taskHistory).toEqual([historyItem]) + }) + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // 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("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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 the currentApiConfigName override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await provider["setViewStateId"]("stable-sidebar-view") + + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("rolls the shared value back when the durable view-state write fails on setValue", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("rollback-view") + + await provider.setValue("mode", "code") + expect(provider.contextProxy.getValue("mode")).toBe("code") + + // Fail only the durable viewStates write: rejecting the first contextProxy.setValue + // call would fail the shared write itself, so the rollback branch would never run. + const originalSetValue = provider.contextProxy.setValue.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + if (key === "viewStates") throw new Error("persist failed") + return originalSetValue(key, value) + }) + + await expect(provider.setValue("mode", "architect")).rejects.toThrow("persist failed") + + // The shared write landed before the durable viewStates write failed: it must be + // rolled back so the cache cannot run ahead of storage. + expect(provider.contextProxy.getValue("mode")).toBe("code") + + await provider.dispose() + }) + + it("rolls the shared values back when the durable view-state write fails on setValues", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("rollback-view") + + await provider.setValues({ mode: "code" }) + expect(provider.contextProxy.getValue("mode")).toBe("code") + + // Fail only the durable viewStates write: rejecting the first contextProxy.setValue + // call would fail the shared write itself, so the rollback branch would never run. + const originalSetValue = provider.contextProxy.setValue.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + if (key === "viewStates") throw new Error("persist failed") + return originalSetValue(key, value) + }) + + await expect(provider.setValues({ mode: "architect" })).rejects.toThrow("persist failed") + + expect(provider.contextProxy.getValue("mode")).toBe("code") + + 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), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + // The stock mock commits globalState.update synchronously, so a broken + // serialization queue can still pass: the second read sees the first + // entry before it is "in flight". Replace it with an async update that + // holds the first durable write pending until the test releases it. + const originalUpdate = mockContext.globalState.update.bind(mockContext.globalState) + const viewStatesUpdates: unknown[] = [] + let releaseFirstWrite: (() => void) | undefined + mockContext.globalState.update = (key: string, value: unknown) => { + if (key !== "viewStates") { + return originalUpdate(key, value) + } + viewStatesUpdates.push(value) + if (!releaseFirstWrite) { + // First durable write: hold the commit until the test releases it, so the + // serialized queue must wait for it before the next write reads a fresh copy. + return new Promise((resolve) => { + releaseFirstWrite = () => { + originalUpdate(key, value) + resolve() + } + }) + } + + // Subsequent durable writes commit immediately. + originalUpdate(key, value) + return Promise.resolve() + } + + try { + const saving = Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + // The first durable write is in flight. The serialized queue must + // hold the second write until it settles; a broken queue issues it + // in parallel (a second update call while the first is still + // pending) and the final map loses the first entry. + await vi.waitFor(() => { + expect(viewStatesUpdates).toHaveLength(1) + }) + const release = releaseFirstWrite + if (!release) { + throw new Error("Expected the first durable viewStates write to be in flight") + } + + release() + await saving + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + } finally { + mockContext.globalState.update = originalUpdate + } + + 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), + new WebviewFocusTracker(), + ) + + await vi.waitFor(() => { + expect(provider["viewLocalState"]).toEqual({}) + }) + + const state = await provider.getState() + // No per-view entry exists and the proxy's global-state cache is empty + // (initialize() is never called in this fixture; only "taskHistory" passes + // through to the context store), so getState() falls back to the shared + // defaults: mode "code" (defaultModeSlug) and currentApiConfigName "default". + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + + // Register a stable id so the load is not skipped by the ownership guard: + // only entries this instance authored are loaded under a temporary name. + await provider["setViewStateId"]("stable-sidebar-view") + + 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() + }) + + it("does not resurrect a field cleared mid-load from the stale persisted value the load read", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("clear-view") + + // A previous session pinned a profile for this view. Write through the + // context proxy so the cache the load reads from sees the entry (a raw + // globalState write bypasses it). + await provider.contextProxy.setValue("viewStates", { + "clear-view": { currentApiConfigName: "stale-profile", updatedAt: 1 }, + }) + + // Stall the load's profile lookup so a view-local mutation can complete + // while the load is in flight. + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + + const loading = provider["loadViewState"]() + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // The user clears the view's profile selection while the load is stalled; + // the clear is a durable write and must win over the stale value. + await provider.saveViewState("currentApiConfigName", undefined) + + // The same view also clears its pinned configuration while the load is stalled. + await provider.saveViewState("apiConfiguration", undefined) + + resolveProfile({ + name: "stale-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await loading + + // The load resolved the stale profile it read before the clear: without the + // mutation tracking it would re-pin the cleared selection. The cleared field + // must stay absent so the view keeps following the shared selection. + expect(provider["viewLocalState"]).not.toHaveProperty("currentApiConfigName") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + // The cleared configuration must not be re-applied from the resolved profile + // either: without the guard the load would restore the profile's config. + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + + await provider.dispose() + }) + + it("keeps a mid-load flat provider-settings write shared instead of overlaying the pinned profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("midload-settings-view") + + // A previous session pinned a profile for this view (written through the + // context proxy so the load's cache read sees the entry). + await provider.contextProxy.setValue("viewStates", { + "midload-settings-view": { currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + + const loading = provider["loadViewState"]() + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // A flat provider-settings change (not a full apiConfiguration write) + // completes while the load is stalled: it is a SHARED write and must land in + // the ContextProxy, but it is not a view-local mutation, so the settling + // load still applies the pinned profile's resolved configuration to this + // view's buffer (A semantics: flat keys never overlay viewLocalState). + await provider.setValue("openRouterModelId", "model-z") + + resolveProfile({ + name: "profile-a", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await loading + + // The shared write landed in the ContextProxy ... + expect(provider.contextProxy.getValue("openRouterModelId")).toBe("model-z") + + // ... but the view-local buffer keeps the pinned profile's resolved + // configuration: merging the flat key into viewLocalState.apiConfiguration + // would turn it into a per-view override masking later shared updates. + expect(provider["viewLocalState"].apiConfiguration?.openRouterModelId).toBe("model-x") + + 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), + new WebviewFocusTracker(), + ) + 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"]).toEqual({ mode: "mode-54", updatedAt: 54 }) + expect(pruned["view-5"]).toEqual({ mode: "mode-5", updatedAt: 5 }) + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("__proto__") + + // "__proto__" is rejected before assignment so a per-view entry can never be + // keyed through the Object.prototype setter: the temporary id stays active and + // nothing is persisted under the reserved name. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("restores the previous view id when the registration write fails so a later launch retries", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id so the re-key has real work to do. + // Author the pre-launch entry through the real write path so this instance owns it. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) + + await expect(provider["setViewStateId"]("stable-sidebar-view")).rejects.toThrow("storage down") + + // The failed id must not stick: the provider keeps its previous (temporary) + // id so a later launch retries registration and the load instead of the + // guard early-returning for an id that was never persisted. + expect(provider["viewStateId"]).toBe(provider.viewId) + + // A later retry succeeds once the storage write works again, and the + // pre-launch entry lands under the registered id. + setValueSpy.mockRestore() + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewStateId"]).toBe("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + + await provider.dispose() + }) + + it("resolves viewStateReadiness once the stable id is registered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const readiness = provider.viewStateReadiness + + const settled = readiness.then( + () => true, + () => false, + ) + + await provider["setViewStateId"]("ready-view") + + expect(await settled).toBe(true) + + await provider.dispose() + }) + + it("resolves viewStateReadiness when the provider disposes before the id is registered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const readiness = provider.viewStateReadiness + + const settled = readiness.then( + () => true, + () => false, + ) + + await provider.dispose() + + expect(await settled).toBe(true) + }) + + it("settles viewStateReadiness when the launch carries no usable view id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const readiness = provider.viewStateReadiness + + const settled = readiness.then( + () => true, + () => false, + ) + + // The CLI launch sends the registration message without a view-state id: the + // early return must still settle readiness so API-driven task creation does + // not fall through to its timeout waiting for a state that never registers. + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + + expect(await settled).toBe(true) + expect(provider["viewStateId"]).toBe(provider.viewId) + + await provider.dispose() + }) + + it("settles viewStateReadiness even when first read after the id is registered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("late-reader-view") + + // First read happens after registration: a lazily created promise would hold a + // fresh, never-resolving resolver here (the registration's resolve call was a + // no-op), so settling proves the promise was created eagerly in the constructor. + const settled = provider.viewStateReadiness.then( + () => true, + () => false, + ) + expect(await settled).toBe(true) + + await provider.dispose() + }) + }) + + describe("view state persistence edge cases", () => { + it("should read viewStates from the ContextProxy cache when not fresh", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + expect(provider["getPersistedViewStates"]()).toEqual({ "stable-sidebar-view": { mode: "architect" } }) + await provider.dispose() + }) + + it("should treat a corrupted non-object viewStates value as an empty map", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A string in storage is corrupt: the fresh-read guard must not spread it. + await mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + }) + await save("mode", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("mode") + await provider.saveViewState("mode", "architect") + await save("currentApiConfigName", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + mode: "architect", + updatedAt: expect.any(Number), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("should rekey a pre-launch entry under the temporary id to the registered stable id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed through the write path so this instance is the author of the entry under + // its temporary id; an entry authored by another session must not be adopted. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + await provider.dispose() + }) + + it("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Author the temporary entry through the write path so this instance owns it, + // then add a stable entry that already exists in storage. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + await mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("a b/c") + expect(provider["viewStateId"]).toBe("a_b_c") + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + expect(provider["viewStateId"]).toBe("a_b_c") + logSpy.mockClear() + await provider["setViewStateId"]("a_b_c") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should reapply fields mutated while the load is in flight and keep persisted values for untouched fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutations and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // Selections made while the profile lookup is in flight must survive the load. + await provider.saveViewState("mode", "architect") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // Dirty fields win over the loaded state; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-y" }, + }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted mode authoritative when the pre-load buffer is untouched", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "code") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should not restore a persisted mode whose custom mode no longer exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // Persist a mode too: without it the load has no mode to resurrect, so the + // cleared-field assertion below would pass even if the loaded value clobbered + // the in-flight clear. + await provider.saveViewState("currentApiConfigName", "cfg-a") + await provider.saveViewState("mode", "code") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight clear and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // The user clears the mode while the load is in flight. + await provider.saveViewState("mode", undefined) + resolveProfile({ name: "cfg-a" }) + await load + + // The cleared field must stay absent rather than keeping the persisted or + // pre-load value; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the mode is mutated while the profile lookup is in flight. + await provider.saveViewState("mode", "architect") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should reapply an independently mutated profile name when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // The in-flight selection wins over the loaded state. + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-b") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.dispose() + }) + + it("should reapply an independently mutated apiConfiguration when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the apiConfiguration is mutated while the profile lookup is in flight. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should keep every persisted field authoritative when the load is untouched", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + await provider.saveViewState("mode", "code") + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // No mutation while the lookup is in flight: the persisted values must win. + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "code", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should persist known modes and reject unknown or non-string modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the slugs under test. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "refactor" ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValues({ mode: "refactor" }) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + await provider.setValues({ mode: "bogus-mode" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode must be rejected before persistence; Object.assign + // keeps this a single structural assertion, so it must not reach global + // state or the buffer. + await provider.setValues(Object.assign({} as RooCodeSettings, { mode: 42 })) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should reject unknown or non-string modes written through setValue", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the values under + // test. 42 resolves on purpose: the non-string case must be rejected by the typeof + // check alone, not by a failed slug lookup. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: unknown) => + slug === "refactor" || slug === 42 ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValue("mode", "refactor") + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // The updateSettings webview handler routes every key through setValue: + // an unknown mode must be rejected there too, not only in setValues. + await provider.setValue("mode", "bogus-mode") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode must be rejected before persistence (double assertion: the + // type excludes non-strings), so it must not reach global state or the buffer — + // even though the slug lookup above resolves it. + await provider.setValue("mode", 42 as unknown as RooCodeSettings["mode"]) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + it("should apply setValue mutations to global state and keep or clear the right buffer fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const apiConfiguration = { apiProvider: providerIdentifiers.openrouter } + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider.saveViewState("apiConfiguration", apiConfiguration) + // A mutation of an unrelated key reaches global state without dropping buffered fields. + await provider.setValue("writeDelayMs", 500) + expect(mockContext.globalState.get("writeDelayMs")).toBe(500) + expect(provider.getValues().writeDelayMs).toBe(500) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toBe(apiConfiguration) + await provider.setValue("mode", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.setValue("currentApiConfigName", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("currentApiConfigName") + await provider.dispose() + }) + + it("should remove the buffered apiConfiguration when it is cleared", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await provider.saveViewState("apiConfiguration", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await save("apiConfiguration", null) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.dispose() + }) + + it("should clear viewLocalState and the persisted entry when resetting state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace customModesManager with a test double (the real reset writes to disk). + provider.customModesManager = { resetCustomModes: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } + // The modal answer is a string label; the last-typed vscode overload expects a + // MessageItem. A double assertion is the last-resort cast here (AGENTS.md): the + // production path compares the answer against the string label directly, so the + // runtime value must stay a string and cannot be a structural MessageItem double. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + t("common:answers.yes") as unknown as vscode.MessageItem, + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.resetState() + expect(provider["viewLocalState"]).toEqual({}) + // F4 cross-instance broadcast clears the entire durable viewStates map, so the + // key is removed (undefined) rather than left as an empty object. + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + await provider.dispose() + }) + + it("should clear a sibling's view-local state and post sibling state during a cross-instance resetState", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const post1 = vi.spyOn(provider1, "postStateToWebview").mockResolvedValue(undefined) + const post2 = vi.spyOn(provider2, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace customModesManager with a test double (the real reset writes to disk). + provider1.customModesManager = { resetCustomModes: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } + // The modal answer is a string label; the last-typed vscode overload expects a MessageItem. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + t("common:answers.yes") as unknown as vscode.MessageItem, + ) + + await provider2.saveViewState("mode", "architect") + await provider1.resetState() + + // The sibling's in-memory view-local cache must be cleared by the broadcast. + expect(provider2["viewLocalState"]).toEqual({}) + // The sibling receives exactly one posted state (from the broadcast); the caller + // receives exactly one (its final reset post), never a double post from the broadcast. + expect(post2).toHaveBeenCalledTimes(1) + expect(post1).toHaveBeenCalledTimes(1) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should roll back the shared write when setValue per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValue("mode", "code")).rejects.toThrow("storage unavailable") + // The shared store must not move ahead of the per-view record that failed. + expect(provider.getValue("mode")).toBe("architect") + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + it("should roll back the shared writes when setValues per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + await provider.contextProxy.setValue("currentApiConfigName", "current-config") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValues({ mode: "code", currentApiConfigName: "other-config" })).rejects.toThrow( + "storage unavailable", + ) + expect(provider.getValue("mode")).toBe("architect") + expect(provider.getValue("currentApiConfigName")).toBe("current-config") + expect(provider["viewLocalState"].mode).toBeUndefined() + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + }) + + describe("provider profile mutations", () => { + it("should sync the view-local buffer when activating a profile over a loaded view state", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("currentApiConfigName", "old-profile") + + const profile: ProviderSettingsEntry = { + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openrouter, + } + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(profile), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + getProfile: vi.fn().mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider.getValues().currentApiConfigName).toBe("old-profile") + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The buffer must track the activated profile so getValues() agrees with the proxy. + expect(provider.getValues().currentApiConfigName).toBe("new-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") + await provider.dispose() + }) + + it("should report the activated profile's settings over a stale view-local buffer in getState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const freshSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ name: "new-profile", id: "new-id", ...freshSettings }), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // A stale view-local apiConfiguration (as loaded from a view state) that keeps + // shadowing the activated profile's settings in getState() unless the mutation + // path clears this view's buffer overlay. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-key", + }) + expect(provider.getValues().apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-key", + }) + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The constructed state must serve the activated profile's fresh shared + // settings, not the stale view-local overlay. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.apiConfiguration.apiKey).toBe("fresh-key") + + // A later shared settings edit must not be masked by a buffer copy taken at + // mutation time: the e2e flow edits settings through the shared path after + // configuring the profile, and a wrapped buffer snapshot would keep serving + // the pre-edit values in getState(). + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "edited-key", + }) + const stateAfterEdit = await provider.getState({ includeTaskHistory: false }) + expect(stateAfterEdit.apiConfiguration.apiKey).toBe("edited-key") + await provider.dispose() + }) + + it("should sync the view-local buffer when creating and activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profile: ProviderSettingsEntry = { + name: "fresh-profile", + id: "fresh-id", + apiProvider: providerIdentifiers.openrouter, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("fresh-id"), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.setValue("currentApiConfigName", "stale-profile") + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + expect(provider.getValues().currentApiConfigName).toBe("fresh-profile") + await provider.dispose() + }) + + it("should report the fresh profile's settings over a stale view-local buffer in getState after upserting and activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profile: ProviderSettingsEntry = { + name: "fresh-profile", + id: "fresh-id", + apiProvider: providerIdentifiers.openrouter, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("fresh-id"), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // A stale view-local apiConfiguration (as loaded from a view state) that keeps + // shadowing the fresh profile's settings in getState() unless the mutation + // path clears this view's buffer overlay. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + expect(provider.getValues().apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + // The constructed state must serve the fresh profile's shared settings, not + // the stale view-local overlay. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + // Same masking guard as the activation case: later shared edits stay visible. + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "edited-key", + }) + const stateAfterEdit = await provider.getState({ includeTaskHistory: false }) + expect(stateAfterEdit.apiConfiguration.apiKey).toBe("edited-key") + await provider.dispose() + }) + + it("should sync the view-local buffer when deleting the current profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + // @ts-ignore - Replace providerSettingsManager with a test double: deleting the + // current profile now activates the fallback, which reads its settings. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + + await provider.deleteProviderProfile(oldProfile) + + // This view pinned the deleted profile, so the deletion routes through the + // activation path, which writes the profile list itself (re-reading the + // entries from the settings store): exactly one list write may occur, and a + // stray compensating pre-write for a view that pins the deleted profile would + // be a second one. + const listWrites = setValueSpy.mock.calls.filter(([key]) => key === "listApiConfigMeta") + expect(listWrites).toHaveLength(1) + + // The fallback profile must replace the deleted one in both the proxy and the buffer. + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + await provider.dispose() + }) + + it("should keep an unrelated view-local pin when the shared selection points at the deleted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const deletedProfile: ProviderSettingsEntry = { + name: "shared-victim", + id: "victim-id", + apiProvider: providerIdentifiers.openrouter, + } + const replacementProfile: ProviderSettingsEntry = { + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.anthropic, + } + const pinnedProfile: ProviderSettingsEntry = { + name: "view-pin", + id: "pin-id", + apiProvider: providerIdentifiers.zai, + } + // The shared selection points at the profile being deleted while this view pins + // an unrelated profile that must survive the deletion. + await provider.contextProxy.setValue("listApiConfigMeta", [ + deletedProfile, + replacementProfile, + pinnedProfile, + ]) + await provider.contextProxy.setValue("currentApiConfigName", "shared-victim") + // Persist this view's pin through the durable map so the load applies it to the + // buffer without touching the shared slot. + await provider.contextProxy.setValue("viewStates", { + "pinning-view": { currentApiConfigName: "view-pin", updatedAt: Date.now() }, + }) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + activateProfile: vi.fn().mockResolvedValue(replacementProfile), + listConfig: vi.fn().mockResolvedValue([replacementProfile, pinnedProfile]), + setModeConfig: vi.fn(), + getProfile: vi.fn().mockResolvedValue(pinnedProfile), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("pinning-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("view-pin") + + await provider.deleteProviderProfile(deletedProfile) + + // The pin survives in the buffer: the deletion must not route through the + // replacement activation for an unrelated view pin. The shared slot is + // repaired to the replacement so sibling views stop reading the deleted + // name, while this view's buffer keeps its pin. + expect(provider.getValues().currentApiConfigName).toBe("view-pin") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("replacement-profile") + // The shared list sync drops the deleted entry. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([replacementProfile, pinnedProfile]) + await provider.dispose() + }) + + it("leaves the durable pin and the shared list untouched when the settings deletion fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + // Persist this view's pin under the profile being deleted so a failed deletion + // must leave it intact. + await provider.contextProxy.setValue("viewStates", { + "pinning-view": { currentApiConfigName: "old-profile", updatedAt: Date.now() }, + }) + // @ts-ignore - Replace providerSettingsManager with a double whose deletion fails. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockRejectedValue(new Error("storage down")), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("pinning-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("old-profile") + + await expect(provider.deleteProviderProfile(oldProfile)).rejects.toThrow("storage down") + + // The deletion rejected, so no compensating write may have landed: the durable + // pin still names the profile that still exists, the shared list is intact, and + // the shared selection is untouched. + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "pinning-view": { currentApiConfigName: "old-profile" }, + }) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([oldProfile, keeperProfile]) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + + await provider.dispose() + }) + + it("prunes the stale list entry and re-points the pin when the secret is already gone", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("viewStates", { + "pinning-view": { currentApiConfigName: "old-profile", updatedAt: Date.now() }, + }) + // @ts-ignore - Replace providerSettingsManager with a double whose deletion + // reports the secret as already gone: an idempotent no-op that must still prune + // the stale list entry and re-point the pin. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockRejectedValue(new ProviderSettingsNotFoundError("missing")), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("pinning-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("old-profile") + + await provider.deleteProviderProfile(oldProfile) + + // The not-found rejection is an idempotent success: the compensating writes run, + // and the log records that the stale list entry was pruned only. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keeperProfile]) + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "pinning-view": { currentApiConfigName: "keeper-profile" }, + }) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining("not found")) + + await provider.dispose() + }) + + it("reconfigures a view pinned to the deleted profile even when the global selection points elsewhere", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + // The global selection points at the keeper profile, but this view's buffer + // is still pinned to the profile being deleted (it loaded it earlier): + // the nested overlay still carries the deleted profile's configuration. + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + provider["viewLocalState"].currentApiConfigName = "old-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double: the pinned + // view takes the activation path, so the survivor's settings are read through + // activateProfile. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await provider.deleteProviderProfile(oldProfile) + + // The pin still names the deleted profile, so the view is reconfigured through + // the activation path: the shared provider keys take the surviving profile's + // settings and the view's stale overlay is cleared, so no key of the deleted + // profile survives in getState(). + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("does not re-point a pinned sibling's task that follows another profile when its profile is deleted", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // The sibling pins the profile being deleted, but its active task + // follows a different sticky profile: the deletion must re-point the + // sibling's buffer to the surviving profile without switching that + // conversation's handler or rewriting its persisted profile name. + sibling["viewLocalState"].currentApiConfigName = "old-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + taskId: "sibling-task", + setTaskApiConfigName: vi.fn(), + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The sibling's view buffer is still re-pointed to the surviving + // profile... + expect(sibling["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + // ...but the task follows another profile: neither the handler rebuild + // nor the sticky-name rewrite may touch it. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(siblingTask.setTaskApiConfigName).not.toHaveBeenCalled() + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) + // The global selection and this view's pin both name surviving profiles: + // the deletion must not reconfigure this view's settings, and the + // pinned view's nested overlay must survive byte-for-byte. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // No reconfiguration: the guard must stay false when neither the global + // selection nor the view pin names the deleted profile, so the view + // keeps its own pin and its nested overlay while the shared store + // reports the global selection. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + }) + await provider.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // 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), + new WebviewFocusTracker(), + ) + + 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: the fixture's + // vscode.env.language is "en" and no custom modes are persisted. + expect(state.language).toBe("en") + expect(state.customModes).toEqual([]) + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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("reports the fresh global apiConfiguration after a profile activation followed by a global settings write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("activated-id"), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile( + "activated-profile", + { apiProvider: providerIdentifiers.anthropic }, + true, + ) + + // Simulate api.setConfiguration (src/extension/api.ts): a global-only + // write that does not refresh the view-local buffer. + await provider.contextProxy.setValues({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + }) + + const state = await provider.getState({ includeTaskHistory: false }) + + // The fresh global selection must win: the activation's buffer write must + // not mask the later shared update (regression guard for the e2e + // provider-probe suites, which start tasks after a profile activation). + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when activating a different profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("profile-b-id"), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-b", { apiProvider: providerIdentifiers.anthropic }, true) + + const state = await provider.getState({ includeTaskHistory: false }) + + // Activating profile B must clear profile A's buffered overlay so the shared + // settings (written by setProviderSettings) win; otherwise getState would + // report profile B's name with profile A's provider. + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when directly activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The direct-activation path (activateProviderProfileUnlocked) carries its own + // overlay-clearing call, distinct from upsertProviderProfile's. + await provider.activateProviderProfile({ name: "profile-b" }) + + const state = await provider.getState({ includeTaskHistory: false }) + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + 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), + new WebviewFocusTracker(), + ) + const contextProxyAccess = provider.contextProxy 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 provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "view-profile") + await provider.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 keep flat provider settings out of the view-local buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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, + }) + + // Flat provider-settings keys are shared settings: they must flow through + // the ContextProxy only and must not be merged into the view-local buffer, + // which would turn them into a per-view override masking later shared + // updates from other views. The pre-existing overlay is older than this + // shared write, so the flat write drops it instead of being masked by it. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.bedrock) + expect(provider.contextProxy.getValue("awsBedrockEndpoint")).toBe("http://127.0.0.1:4567") + + await provider.dispose() + }) + + it("drops the view-local apiConfiguration overlay when setValues receives flat provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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() + + // Flat provider-settings keys are shared settings: they reach the shared store, + // where other views and new tasks read them ... + const shared = provider.contextProxy.getValues() + expect(shared.apiProvider).toBe("bedrock") + expect(shared.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + // ... and they take effect in this view as well: the pinned profile's + // overlay is older than the shared write, so the flat write drops it and + // getState() serves the new shared values instead of the stale pin. + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("persisted view state", () => { + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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 drop an unknown mode from setValues while keeping valid modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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), + new WebviewFocusTracker(), + ) + + 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")) ?? {} + await 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), + new WebviewFocusTracker(), + ) + + // 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), + new WebviewFocusTracker(), + ) + + // A stable entry already exists (e.g. persisted by an earlier registration); + // it must win over this instance's temporary entry. + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + // Author the temporary entry through the real write path so this instance + // owns it: unowned entries are never re-keyed. + await provider.saveViewState("mode", "architect") + + 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), + new WebviewFocusTracker(), + ) + + // 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", { + [provider.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(() => + Object.assign({} as Awaited>, { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }), + ), + ) + + const staleLoad = provider["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 = provider["setViewStateId"]("stable-sidebar-view") + await register + + releaseGhost() + await staleLoad + + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) + + await provider.dispose() + }) + }) + + describe("getState default values", () => { + it("should fall back to defaults for unset state values", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const state = await provider.getState() + + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.alwaysAllowReadOnly).toBe(false) + expect(state.alwaysAllowReadOnlyOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWrite).toBe(false) + expect(state.alwaysAllowWriteOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWriteProtected).toBe(false) + expect(state.alwaysAllowExecute).toBe(false) + expect(state.alwaysAllowMcp).toBe(false) + expect(state.alwaysAllowModeSwitch).toBe(false) + expect(state.alwaysAllowSubtasks).toBe(false) + expect(state.alwaysAllowFollowupQuestions).toBe(false) + expect(state.followupAutoApproveTimeoutMs).toBe(60000) + expect(state.diagnosticsEnabled).toBe(true) + expect(state.soundEnabled).toBe(false) + expect(state.ttsEnabled).toBe(false) + expect(state.ttsSpeed).toBe(1) + expect(state.enableCheckpoints).toBe(true) + expect(state.checkpointTimeout).toBe(DEFAULT_CHECKPOINT_TIMEOUT_SECONDS) + expect(state.terminalPowershellCounter).toBe(false) + expect(state.terminalZshClearEolMark).toBe(true) + expect(state.terminalZshOhMy).toBe(false) + expect(state.terminalZshP10k).toBe(false) + expect(state.terminalZdotdir).toBe(false) + expect(state.mcpEnabled).toBe(true) + expect(state.listApiConfigMeta).toEqual([]) + expect(state.pinnedApiConfigs).toEqual({}) + expect(state.modeApiConfigs).toEqual({}) + expect(state.customSupportPrompts).toEqual({}) + expect(state.experiments).toEqual(experimentDefault) + expect(state.autoApprovalEnabled).toBe(false) + expect(state.maxOpenTabsContext).toBe(20) + expect(state.maxWorkspaceFiles).toBe(200) + expect(state.telemetrySetting).toBe("unset") + expect(state.enableSubfolderRules).toBe(false) + expect(state.maxImageFileSize).toBe(5) + expect(state.maxTotalImageSize).toBe(20) + expect(state.historyPreviewCollapsed).toBe(false) + expect(state.reasoningBlockCollapsed).toBe(true) + expect(state.enterBehavior).toBe("send") + expect(state.codebaseIndexModels).toEqual(EMBEDDING_MODEL_PROFILES) + expect(state.codebaseIndexConfig).toEqual({ + codebaseIndexEnabled: false, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "", + }) + expect(state.profileThresholds).toEqual({}) + expect(state.includeDiagnosticMessages).toBe(true) + expect(state.maxDiagnosticMessages).toBe(50) + expect(state.includeTaskHistoryInEnhance).toBe(true) + expect(state.includeCurrentTime).toBe(true) + expect(state.includeCurrentCost).toBe(true) + expect(state.maxGitStatusFiles).toBe(0) + expect(state.language).toBe("en") + + await provider.dispose() + }) + + it("should report a non-retired apiProvider from state instead of the anthropic fallback", async () => { + const contextProxy = new ContextProxy(mockContext) + await contextProxy.setValues({ apiProvider: providerIdentifiers.openrouter }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + + const state = await provider.getState() - const statePost = provider.postStateToWebviewWithoutTaskHistory() - void statePost.then(() => { - statePostSettled = true + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + await provider.dispose() }) - await Promise.resolve() - expect(postMessageSpy).toHaveBeenCalledOnce() - expect(statePostSettled).toBe(false) + it("should fill the apiConfiguration apiProvider from the raw state value when provider settings sanitize it away", async () => { + // "bogus-provider" is neither an active nor a retired provider, so + // ContextProxy.sanitizeProviderValues drops it from the provider + // settings; the raw state value still reaches apiConfiguration via + // the getState fill-in, which is what this assertion pins. + const contextProxy = new ContextProxy(mockContext) + // A single structural cast: the raw-state write must carry an + // un-sanitizable apiProvider value, which the typed setValues(RooCodeSettings) + // signature deliberately rejects. + const contextProxyAccess = contextProxy as { + setValues: (values: Record) => Promise + } + await contextProxyAccess.setValues({ apiProvider: "bogus-provider" }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) - releasePost() - await statePost - expect(statePostSettled).toBe(true) - }) + const state = await provider.getState() - test.each([ - ["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()], - [ - "postStateToWebviewWithoutTaskHistory", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), - ], - ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { - let releaseOlderSnapshot!: (state: ExtensionState) => void - const olderSnapshot = new Promise((resolve) => { - releaseOlderSnapshot = resolve + expect(state.apiConfiguration.apiProvider).toBe("bogus-provider") + + await provider.dispose() }) - const baseState = await provider.getStateToPostToWebview({ includeTaskHistory: false }) - const emptyState: ExtensionState = { ...baseState, taskHistory: [], clineMessages: [] } - const readyState: ExtensionState = { - ...baseState, - taskHistory: [], - clineMessages: [{ ts: 1, type: "say", say: "text", text: "child ready" }], - } - vi.spyOn(provider, "getStateToPostToWebview") - .mockReturnValueOnce(olderSnapshot) - .mockResolvedValueOnce(readyState) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + it("should serve the embedding model profiles default when the stored value is cleared", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) - const olderPost = postState(provider) - await Promise.resolve() - const newerPost = postState(provider) - await newerPost - releaseOlderSnapshot(emptyState) - await olderPost + // The constructor seeds codebaseIndexModels into the context; with a truthy + // stored value the ?? default is unobservable (both ?? and && forms return + // the same profiles object). Clear the stored value so the read-time default + // is the one under test. + await provider.contextProxy.setValue("codebaseIndexModels", undefined) - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ - readyState.clineMessages, - emptyState.clineMessages, - ]) - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) - }) + const state = await provider.getState() - test.each([ - [ - "postStateToWebviewWithoutTaskHistory", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), - ], - [ - "postStateToWebviewWithoutClineMessages", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), - ], - ])("%s skips task history computation", async (_methodName, postState) => { - const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + expect(state.codebaseIndexModels).toBe(EMBEDDING_MODEL_PROFILES) - await postState(provider) + await provider.dispose() + }) - expect(getAllSpy).not.toHaveBeenCalled() - expect(postMessageSpy).toHaveBeenCalledOnce() - expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") - }) + it("should not activate a replacement when an unrelated profile is deleted with no view pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const deletedProfile: ProviderSettingsEntry = { + name: "unrelated-victim", + id: "victim-id", + apiProvider: providerIdentifiers.openrouter, + } + const keptProfile: ProviderSettingsEntry = { + name: "kept-profile", + id: "kept-id", + apiProvider: providerIdentifiers.anthropic, + } + // A fresh view (no view-local pin) whose shared selection points at a profile + // that survives the deletion: the deletion is unrelated to this view. + await provider.contextProxy.setValue("listApiConfigMeta", [deletedProfile, keptProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "kept-profile") + const activateProfile = vi.fn().mockResolvedValue(keptProfile) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + activateProfile, + listConfig: vi.fn().mockResolvedValue([keptProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) - test("getStateToPostToWebview computes task history once after its base state resolves", async () => { - const historyItem = { - id: "history-task", - number: 1, - ts: 1, - task: "History task", - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - } - const originalGetState = provider.getState.bind(provider) - let baseStateResolved = false - const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { - const state = await originalGetState(options) - baseStateResolved = true - return state + await provider.deleteProviderProfile(deletedProfile) + + // The activation path must not run for an unrelated deletion (it would + // rebuild this view's task handler) and the shared slot is left untouched. + expect(activateProfile).not.toHaveBeenCalled() + expect(provider.getValues().currentApiConfigName).toBe("kept-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("kept-profile") + // The shared list sync drops the deleted entry. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keptProfile]) + await provider.dispose() }) - const historyReadPhases: boolean[] = [] - const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { - historyReadPhases.push(baseStateResolved) - return [historyItem] + + it("should prune a stale entry when deleteConfig reports the typed not-found signal", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const ghostProfile: ProviderSettingsEntry = { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + } + const keptProfile: ProviderSettingsEntry = { + name: "kept-profile", + id: "kept-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [ghostProfile, keptProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "ghost-profile") + // @ts-ignore - Replace providerSettingsManager with a test double: the store + // reports the profile's secret as already gone via the typed signal. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockRejectedValue(new ProviderSettingsNotFoundError("ghost-profile")), + activateProfile: vi.fn().mockResolvedValue(keptProfile), + listConfig: vi.fn().mockResolvedValue([keptProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The typed not-found outcome is an idempotent success ... (no throw) ... + await expect(provider.deleteProviderProfile(ghostProfile)).resolves.toBeUndefined() + // ... so the stale entry is still pruned and the selection repointed. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("kept-profile") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keptProfile]) + await provider.dispose() }) - const state = await provider.getStateToPostToWebview() + it("should propagate a wrapped delete failure whose message merely mentions not found", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const victimProfile: ProviderSettingsEntry = { + name: "victim-profile", + id: "victim-id", + apiProvider: providerIdentifiers.openrouter, + } + const keptProfile: ProviderSettingsEntry = { + name: "kept-profile", + id: "kept-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [victimProfile, keptProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "victim-profile") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + // A non-not-found failure whose wrapped message happens to contain the + // "not found" substring: the former message-matching check would have + // swallowed it as an idempotent delete. + deleteConfig: vi + .fn() + .mockRejectedValue( + new Error("Failed to delete config: vault entry for 'victim-profile' not found"), + ), + activateProfile: vi.fn().mockResolvedValue(keptProfile), + listConfig: vi.fn().mockResolvedValue([keptProfile]), + setModeConfig: vi.fn(), + } - expect(getStateSpy).toHaveBeenCalledOnce() - expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) - expect(getAllSpy).toHaveBeenCalledOnce() - expect(historyReadPhases).toEqual([true]) - expect(state.taskHistory).toEqual([historyItem]) + // The typed-signal check must let any non-not-found rejection propagate. + await expect(provider.deleteProviderProfile(victimProfile)).rejects.toThrow("Failed to delete config") + }) }) describe("postStateToWebviewThrottled", () => { @@ -2989,11 +6065,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") @@ -3023,11 +6107,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") @@ -3094,8 +6186,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'.", ) @@ -3167,8 +6261,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 @@ -3215,8 +6310,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") @@ -4281,6 +7377,11 @@ describe("getTelemetryProperties", () => { const { CloudService } = await import("@roo-code/cloud") const mockCloudService = { isAuthenticated: vi.fn().mockReturnValue(true), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -4299,6 +7400,11 @@ describe("getTelemetryProperties", () => { const { CloudService } = await import("@roo-code/cloud") const mockCloudService = { isAuthenticated: vi.fn().mockReturnValue(false), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -4340,6 +7446,11 @@ describe("getTelemetryProperties", () => { isAuthenticated: vi.fn().mockImplementation(() => { throw new Error("Authentication check error") }), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -5910,12 +9021,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }), true, ) - expect(saveConfig).toHaveBeenCalledWith( + // The non-active profile now goes through upsertProviderProfile so the views + // pinned to it are refreshed, not just the file on disk. + expect(upsertSpy).toHaveBeenCalledWith( "Backup Zoo", expect.objectContaining({ zooSessionToken: "new-token", zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", }), + false, ) }) @@ -6012,4 +9126,255 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) }) }) + describe("profile save without activation and overlay completeness", () => { + it("refreshes views pinned to a profile saved without activation", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling loaded the profile earlier: its buffer pins the name and + // carries the settings snapshot taken at that load. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + // The sibling has an active task built from the pre-save settings. + // Task is a class; the double assertion is the only way to attach a + // minimal double without the full provider/services graph. + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "old-key" }, + taskApiConfigName: "saved-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + // A save that does not activate still rewrites the profile; the pinned + // sibling must not keep serving the pre-save snapshot. + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + + // The active task's handler must be rebuilt with the saved settings, + // not just the view buffer. + expect(siblingTask.updateApiConfiguration).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + } + }) + + it("does not rebuild a pinned sibling's task that runs under another profile", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling pins the saved profile, but its active task follows a + // different sticky profile: the save must refresh the view overlay + // without switching that conversation to the saved profile's settings. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + // The pinned view's overlay still catches up with the saved settings... + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + // ...but the task runs under another profile: its handler must not be + // switched to the saved profile's settings. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + } + }) + + it("does not rebuild the acting view's task when it runs under another profile", async () => { + // The acting view pins the saved profile while its own task follows a + // different sticky profile: the non-activating save refreshes the view + // overlay but must leave the task's handler alone. + const task = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + task as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["viewLocalState"].currentApiConfigName = "saved-profile" + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + expect(task.updateApiConfiguration).not.toHaveBeenCalled() + }) + + it("does not leak shared provider keys into a pinned view's api configuration", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + // The shared store sits on a profile with keys the pinned profile never + // sets; spreading the overlay over the shared keys would report a mixed + // configuration (e.g. another profile's model id and region). + await provider.contextProxy.setValue("apiProvider", providerIdentifiers.bedrock) + await provider.contextProxy.setValue("apiModelId", "anthropic.claude-v1") + await provider.contextProxy.setValue("awsRegion", "us-east-1") + provider["viewLocalState"].currentApiConfigName = "pinned-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "k", + } + + // The cloud doubles in this suite make getState() log its defensive catch + // branches; silence them so teardown has no pending console writes. + vi.spyOn(console, "error").mockImplementation(() => {}) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.apiConfiguration.apiKey).toBe("k") + expect(state.apiConfiguration.apiModelId).toBeUndefined() + expect(state.apiConfiguration.awsRegion).toBeUndefined() + + // Let any deferred logging settle while the spies are installed. + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + it("leaves a task following another sticky profile untouched when an activation preserves foreign tasks", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + + // The current task follows a different sticky profile than the one the + // launch-time repair activates. + const task = { + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiKey: "task-key" }, + taskApiConfigName: "task-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + task as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "shared-profile", + id: "id-shared", + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.activateProviderProfile( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + + // The repair wrote the shared selection through the activation path but + // left the foreign task's handler and captured settings alone. + expect(task.updateApiConfiguration).not.toHaveBeenCalled() + expect(task.apiConfiguration.apiProvider).toBe(providerIdentifiers.openai) + + // Control: the same activation without preserveForeignTask does switch + // the task, so the skip above is attributable to the flag. + await provider.activateProviderProfile( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false }, + ) + expect(task.updateApiConfiguration).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }) + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 22aa4d7e97..eba36425be 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -121,6 +121,8 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + on: vi.fn(), + off: vi.fn(), } }, }, @@ -221,9 +223,32 @@ describe("ClineProvider - Sticky Mode", () => { } } + afterEach(async () => { + // Tests construct providers directly and the suite never disposes most of + // them; dispose the per-test provider first (dispose unregisters it from + // static registries such as McpServerManager's provider set, which the + // reset below does not clear), then clear the instance registry so a + // leaked instance cannot claim a stable view state id ahead of a later + // test. + await provider.dispose() + ClineProvider["activeInstances"] = new Set() + }) + beforeEach(async () => { vi.clearAllMocks() + // The "mode deletion between sessions" test overrides the module-level + // getModeBySlug mock to return undefined; vi.clearAllMocks() does not clear + // mock implementations, so restore the factory default per-test. handleModeSwitch + // validates slugs through getModeBySlug, so later tests rely on the default. + const { getModeBySlug } = await import("../../../shared/modes") + vi.mocked(getModeBySlug).mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }) + if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) } @@ -363,11 +388,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( @@ -382,7 +415,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a mock task with initial mode const mockTask = { taskId: "test-task-id", - taskMode: "code", // Initial mode + _taskMode: "code", // Initial mode emit: vi.fn(), saveClineMessages: vi.fn(), clineMessages: [], @@ -416,8 +449,8 @@ describe("ClineProvider - Sticky Mode", () => { // Switch mode await provider.handleModeSwitch("architect") - // Verify task's _taskMode property was updated (using private property) - expect((mockTask as any)._taskMode).toBe("architect") + // Verify task's _taskMode property was updated (accessed via bracket notation) + expect(mockTask["_taskMode"]).toBe("architect") // Verify emit was called with taskModeSwitched event expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") @@ -467,6 +500,348 @@ describe("ClineProvider - Sticky Mode", () => { }), ) }) + + it("should sync the view-local mode buffer when switching modes after a restored view state", async () => { + // Simulate the history-restore path: saveViewState is what + // createTaskWithHistoryItem uses to pin a saved mode into the + // view-local buffer, leaving a stale mode there until the next mutation. + await provider.saveViewState("mode", "code") + + // Global-only mode switch with no active task. + await provider.handleModeSwitch("architect") + + // The durable global write still happens... + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + + // ...and the in-memory buffer must not keep serving the stale restored + // mode: getValues() merges viewLocalState on top of the ContextProxy + // values, so an unsynced buffer would hide the fresh mode from consumers. + expect(provider["viewLocalState"].mode).toBe("architect") + + // The durable per-view write must land too: a regression that left the + // persisted entry on the stale restored mode would reload it on restart. + // setValue awaits the serialized write queue, so the entry is settled here. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] + expect(persisted.mode).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + }) + + it("bails out before any task write when the mutation signal is already aborted", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + + const abortedController = new AbortController() + abortedController.abort() + await provider["handleModeSwitchUnlocked"]("architect", mockTask, abortedController.signal) + + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(mockTask["_taskMode"]).toBeUndefined() + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + + it("rolls back the landed history write and leaves no partial mode state when the signal aborts in flight", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The history write settles only after the abort lands: the first call is + // controlled, the rollback call resolves immediately. + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await switchPromise + + // The persisted new mode is rolled back to the pre-switch item. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: "test-task-id", mode: "architect" }), + ) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith(2, historyItem) + // No partial mode state: the task keeps its previous mode, nothing was + // emitted, and the durable provider mode write never happened. + expect(mockTask["_taskMode"]).toBe("code") + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + + it("rolls back the task mode and history when the view mode pin write fails", async () => { + // Same typed double as the abort rollback test: the private members stay + // out of the Object.assign intersection type. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The task-history write lands; the view's durable mode pin write then + // rejects. The caller (submitUserMessage) logs the failure and delivers the + // message, so the task-side writes must not stand. + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + vi.spyOn(provider, "saveViewState").mockRejectedValueOnce(new Error("pin write failed")) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "pin write failed", + ) + + // No partial application: the task keeps its previous mode and the history + // entry is restored, so the delivered message cannot run the new task mode + // against the view's unchanged mode and mode-linked API configuration. + expect(mockTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).toHaveBeenLastCalledWith(historyItem) + }) + + it("keeps the cancellation result when the abort rollback write itself fails", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The first write settles after the abort lands; the rollback write rejects: + // the failure is logged with its own message and the cancelled switch still + // resolves (it must not surface as the switch's persistence error). + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockRejectedValueOnce(new Error("rollback write failed")) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("Failed to roll back mode switch"), + ) + }) + + it("preserves fields persisted during the pending window when rolling back the mode", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const preSwitchItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + // While the history write is in flight the running task persists other fields + // of the same item; the store now returns that updated item. + const updatedItem: HistoryItem = { + ...preSwitchItem, + mode: "architect", + tokensIn: 42, + totalCost: 0.5, + } + const storeGet = vi + .spyOn(provider.taskHistoryStore, "get") + .mockReturnValueOnce(preSwitchItem) + .mockReturnValue(updatedItem) + + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + // The rollback restores only the mode: the fields persisted during the + // pending window (tokensIn, totalCost) survive the rollback write. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(storeGet).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: "test-task-id", mode: "code", tokensIn: 42, totalCost: 0.5 }), + ) + }) + + it("proceeds normally when no mutation signal is provided", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, undefined) + + expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + }) }) describe("createTaskWithHistoryItem", () => { @@ -487,14 +862,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 () => { @@ -691,11 +1066,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 () => { @@ -771,7 +1154,9 @@ describe("ClineProvider - Sticky Mode", () => { // Restore the task from history await provider.createTaskWithHistoryItem(historyItem) - // Verify that the mode was restored + // Verify that history restoration reaches both the view-local pin and public state. + expect(provider["viewLocalState"].mode).toBe("architect") + const state = await provider.getState() expect(state.mode).toBe("architect") @@ -859,6 +1244,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() @@ -871,12 +1259,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] @@ -937,16 +1329,14 @@ describe("ClineProvider - Sticky Mode", () => { await savePromise // Task should have the new mode - expect((mockTask as any)._taskMode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") }) }) describe("Mode switch failure scenarios", () => { - it("should handle invalid mode gracefully", async () => { + it("should ignore invalid modes", async () => { await provider.resolveWebviewView(mockWebviewView) - // The provider actually does switch to invalid modes - // This test should verify that behavior const mockTask = { taskId: "test-task-id", _taskMode: "code", @@ -960,14 +1350,23 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) + // Register a stable view id so a durable write would be observable + await provider["setViewStateId"]("stable-test-view") + // Clear previous calls vi.mocked(mockContext.globalState.update).mockClear() - // Try to switch to invalid mode - it will actually switch - await provider.handleModeSwitch("invalid-mode" as any) + // Simulate an unknown slug: the module mock resolves nothing for it. + // (The outer beforeEach restores the mock's default return per test, so + // this override does not leak into later tests.) + const { getModeBySlug } = await import("../../../shared/modes") + vi.mocked(getModeBySlug).mockReturnValue(undefined) + + // An unknown mode slug is ignored: no durable write and no in-memory change + await provider.handleModeSwitch("invalid-mode") - // 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).not.toHaveBeenCalled() + expect(mockTask["_taskMode"]).toBe("code") }) it("should handle errors during mode switch gracefully", async () => { @@ -1220,22 +1619,35 @@ describe("ClineProvider - Sticky Mode", () => { // Clear any previous calls vi.clearAllMocks() + // Deterministically wait for the restore's early durable mode write: await the + // write itself (delegating to the real implementation) instead of guessing a + // timer, so the mid-init switch is issued only after the restore's early write. + const originalSaveViewState = provider.saveViewState.bind(provider) + const earlyModeWriteSettled = new Promise((resolve) => { + vi.spyOn(provider, "saveViewState").mockImplementation(async (key, value) => { + await originalSaveViewState(key, value) + if (key === "mode") { + resolve() + } + }) + }) + // Start initialization const initPromise = provider.createTaskWithHistoryItem(historyItem) + // In production the user's switch is issued after the restore's early write, and + // both durable writes then land in that order, so the mid-init switch wins. + await earlyModeWriteSettled + // Try to switch mode during initialization await provider.handleModeSwitch("code") // 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 () => { @@ -1276,4 +1688,172 @@ describe("ClineProvider - Sticky Mode", () => { }) }) }) + + describe("persisted view state keys", () => { + it("does not resolve a view id that names an Object.prototype key through the chain", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed through the durable write path so later writes land in the same + // backing map the assertions read: a frozen get() literal would make every + // post-write assertion read the seed no matter what the provider wrote. + await provider.contextProxy.setValue("viewStates", { "stable-test-view": { mode: "architect" } }) + + const states = provider["getPersistedViewStates"]({ fresh: true }) + expect(Object.getPrototypeOf(states)).toBeNull() + // A view id such as "constructor" must read as an absent entry, not as the + // inherited Object function, otherwise the rekey deletes the temporary entry + // and never writes it under the stable key. + expect(states["constructor"]).toBeUndefined() + expect(states["stable-test-view"]).toEqual({ mode: "architect" }) + + // Registering such an id must not make an inherited member look like a + // persisted entry: the lookup still reports an absent entry, so the rekey keeps the + // temporary entry instead of deleting it. + await provider["setViewStateId"]("constructor") + expect(provider["getPersistedViewStates"]({ fresh: true })["constructor"]).toBeUndefined() + }) + + it("does not load a temporary-id entry that an earlier session wrote", async () => { + // Temporary names restart from 0 on every host start, so storage can still hold + // an entry under this instance's name from a previous session. Seed the entry + // through the ContextProxy before the provider is constructed so the ordinary + // loadViewState read (which goes through the proxy cache) actually sees it. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + await proxy.setValue("viewStates", { + [tempId]: { mode: "architect", currentApiConfigName: "stale-profile" }, + }) + + // The constructor fires loadViewState without awaiting it. Capture the in-flight + // promise so the assertions below run against a settled buffer instead of racing + // a fixed delay that only passes by luck on a fast machine. + // Private member: no typed API exposes the load promise, hence the double assertion. + const proto = ClineProvider.prototype as unknown as { loadViewState: () => Promise } + const originalLoad = proto.loadViewState + let loadDone: Promise = Promise.resolve() + proto.loadViewState = function (this: unknown) { + loadDone = originalLoad.call(this) + return loadDone + } + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + try { + expect(fresh.viewId).toBe(tempId) + await loadDone + + // The unowned temporary-id entry must not be adopted... + expect(fresh["viewLocalState"]).toStrictEqual({}) + + // ...and the shared values still win, so the stale entry cannot reach getState(). + const state = await fresh.getState() + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).not.toBe("stale-profile") + } finally { + proto.loadViewState = originalLoad + // The constructor registers the instance in ClineProvider.activeInstances; + // leaving it there would leak it into every later test in this worker. + await fresh.dispose() + } + }) + + it("does not merge a stale temporary-id entry into this instance's first durable write", async () => { + // A first write under an unowned temporary id claims the slot: fields left by an + // earlier session under the same name must not be adopted (and later re-keyed to + // the stable id) by merging into the colliding entry. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + await proxy.setValue("viewStates", { [tempId]: { currentApiConfigName: "old-profile", updatedAt: 1 } }) + + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + try { + expect(fresh.viewId).toBe(tempId) + + await fresh.saveViewState("mode", "code") + + const states = fresh["getPersistedViewStates"]({ fresh: true }) + expect(states[tempId]).toEqual({ mode: "code", updatedAt: expect.any(Number) }) + expect(fresh["viewLocalState"].currentApiConfigName).toBeUndefined() + } finally { + await fresh.dispose() + } + }) + + it("does not mark the temporary id as authored when the durable write fails", async () => { + // Authorship must be recorded only after the write lands: a failed persistence + // that still marked the id owned would let a later load adopt an entry that was + // never actually stored. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string, _value: unknown) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + try { + await expect(fresh.saveViewState("mode", "code")).rejects.toThrow("storage unavailable") + expect(fresh["wroteUnderTemporaryViewStateId"]).toBe(false) + } finally { + // Restore even when an assertion fails: a rejecting stub left in place makes + // every later test in this worker fail for an unrelated reason. + mockContext.globalState.update = originalUpdate + await fresh.dispose() + } + }) + + it("does not adopt an entry under the temporary name that this instance never wrote", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Temporary ids restart from 0 on every host start, so the same name can + // belong to a different session. An entry this instance never wrote must not + // be adopted as this view's state. + // Seed through the durable write path so the assertions below read the + // same backing map a re-key adoption would write to: a frozen get() + // literal made the negative assertion unable to fail. + await provider.contextProxy.setValue("viewStates", { [provider.viewId]: { mode: "architect" } }) + + await provider["setViewStateId"]("stable-test-view") + + const states = provider["getPersistedViewStates"]({ fresh: true }) + expect(states[provider.viewId]).toEqual({ mode: "architect" }) + expect(states["stable-test-view"]).toBeUndefined() + }) + }) + + describe("setViewStateId input validation", () => { + it("ignores a non-string viewStateId from a crafted message", async () => { + const before = provider["viewStateId"] + // A crafted postMessage can carry any JSON value despite the declared type. + await provider.setViewStateId(42 as unknown as string) + expect(provider["viewStateId"]).toBe(before) + }) + + it("ignores an oversized viewStateId", async () => { + const before = provider["viewStateId"] + await provider.setViewStateId("x".repeat(200)) + expect(provider["viewStateId"]).toBe(before) + }) + + it("refuses a viewStateId already claimed by another live view", async () => { + const other = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await other.setViewStateId("claimed-by-other-view") + await provider.setViewStateId("claimed-by-other-view") + expect(provider["viewStateId"]).not.toBe("claimed-by-other-view") + expect(other["viewStateId"]).toBe("claimed-by-other-view") + } finally { + await other.dispose() + } + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index ccf243f1e1..242837b7fe 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1010,4 +1010,186 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) }) + + describe("deleteProviderProfile", () => { + it("removes the stored profile so a dangling mode mapping can no longer re-activate it", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed the stored profile settings: the default profile plus a child + // profile (mirroring the cross-profile subtasks e2e scenario), with the + // "ask" mode mapped to the child profile. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.providerSettingsManager.setModeConfig("ask", childId) + + // The UI-facing list mirrors the store, as maintained by upsert/activate. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The deleted profile's settings are gone from the manager store. + const remaining = await provider.providerSettingsManager.listConfig() + expect(remaining.some((config) => config.name === "subtask-child-profile")).toBe(false) + await expect( + provider.providerSettingsManager.getProfile({ name: "subtask-child-profile" }), + ).rejects.toThrow(/subtask-child-profile.*not found/) + + // The mode mapping still points at the deleted id, but it no longer + // resolves to a stored profile, so handleModeSwitch falls through to the + // current configuration instead of re-activating the deleted profile. + const savedConfigId = await provider.providerSettingsManager.getModeConfigId("ask") + expect(savedConfigId).toBe(childId) + expect(remaining.find(({ id }) => id === savedConfigId)).toBeUndefined() + + // The context was repointed at the surviving profile. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("treats an already-gone secret as success so the stale list entry is still pruned", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Only the default profile exists in the store: the ghost profile's secret + // was already gone (e.g. pruned by an earlier run) but its list entry + // survived. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "ghost-profile", + }) + + // The manager's "not found" rejection must not surface to the caller ... + await expect( + provider.deleteProviderProfile({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.not.toThrow() + + // ... it prunes the stale list entry and repoints the selection. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("still refuses to delete the last stored profile when a stale list entry remains", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + // A ghost list entry survives next to the only real profile, so the + // provider-level "last profile" guard does not fire: the refusal must come + // from the settings store itself. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + await expect( + provider.deleteProviderProfile({ + name: "default", + id: defaultId, + apiProvider: providerIdentifiers.openrouter, + }), + ).rejects.toThrow("Cannot delete the last remaining configuration") + + // Nothing was repointed or pruned. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "ghost-profile"]) + }) + + it("keeps an unrelated view-local pin when a different profile is deleted", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Three stored profiles: the view pins the keeper (unrelated to the + // deletion) and the shared selection also points at the keeper. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const keeperId = await provider.providerSettingsManager.saveConfig("keeper-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + const victimId = await provider.providerSettingsManager.saveConfig("victim-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-nano", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "keeper-profile", id: keeperId, apiProvider: providerIdentifiers.openrouter }, + { name: "victim-profile", id: victimId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "keeper-profile", + }) + + // This view pins the surviving keeper profile, while a different view's + // persisted pin references the profile about to be deleted. + await provider.saveViewState("currentApiConfigName", "keeper-profile") + await provider.contextProxy.setValue("viewStates", { + "other-view": { currentApiConfigName: "victim-profile", updatedAt: 1 }, + }) + + await provider.deleteProviderProfile({ + name: "victim-profile", + id: victimId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The unrelated pin survives: the fallback branch syncs the shared list + // only, so neither this view's buffer nor the shared selection changes. + expect(provider["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("keeper-profile") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "keeper-profile"]) + + // The other view's persisted pin was re-pointed at the replacement profile + // (the shared selection) instead of rehydrating the deleted name. + const viewStates = mockContext.globalState.get("viewStates") as Record< + string, + { currentApiConfigName?: string } + > + expect(viewStates["other-view"]).toEqual( + expect.objectContaining({ currentApiConfigName: "keeper-profile" }), + ) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index c57e9f3ac3..959c04aa7d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -72,12 +72,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" @@ -107,8 +109,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: { @@ -123,6 +127,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(), @@ -130,6 +144,7 @@ const mockClineProvider = { getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -269,6 +284,204 @@ import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" +describe("webviewMessageHandler - webviewDidLaunch", () => { + // Single structural view of the provider members this suite reassigns at runtime: + // the class type declares several of them as getters / readonly, so the fixture + // type is the writable view of the same object (no cast through unknown needed). + type LaunchProviderFixture = { + setViewStateId: (viewStateId: string) => Promise + workspaceTracker: { initializeFilePaths: () => Promise } + providerSettingsManager: { + listConfig: () => Promise + hasConfig: (name: string) => Promise + } + activateProviderProfile: ( + options: { name: string }, + activateOptions?: { + persistModeConfig?: boolean + persistTaskHistory?: boolean + preserveForeignTask?: boolean + }, + ) => Promise + getMcpHub: () => unknown + getStateToPostToWebview: () => Promise<{ telemetrySetting: string }> + } + const double = mockClineProvider as LaunchProviderFixture + + beforeEach(() => { + vi.clearAllMocks() + // The launch handler sets this plain property on the double; vi.clearAllMocks() + // does not reset it, so clear a leak from a preceding test to keep the + // mark-launched assertion below meaningful. + mockClineProvider.isViewLaunched = false + + vi.mocked(mockClineProvider.getState).mockResolvedValue( + Object.assign({} as Awaited>, { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + currentApiConfigName: "view-local-profile", + }), + ) + 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([]) + // Key-aware so a mutated global-state key (e.g. "") resolves to nothing instead + // of the canned value, keeping the re-pin branch's global lookup observable. + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "currentApiConfigName" ? "shared-profile" : undefined, + ) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + // The provider double is module-level and shared with the later describe + // blocks: the fixture members assigned above (and the isViewLaunched flag + // the launch handler sets) must not leak into them — vi.clearAllMocks() in + // a later suite clears call history but neither removes the assigned + // members nor resets isViewLaunched. Snapshot the originals (absent on the + // double literal) and restore them after every test in this suite. + const originalLaunchMembers: Record = { + setViewStateId: double.setViewStateId, + workspaceTracker: double.workspaceTracker, + providerSettingsManager: double.providerSettingsManager, + activateProviderProfile: double.activateProviderProfile, + getMcpHub: double.getMcpHub, + getStateToPostToWebview: double.getStateToPostToWebview, + } + const originalIsViewLaunched = mockClineProvider.isViewLaunched + + afterEach(() => { + const mutable = double as unknown as Record + for (const [key, value] of Object.entries(originalLaunchMembers)) { + if (value === undefined) { + delete mutable[key] + } else { + mutable[key] = value + } + } + mockClineProvider.isViewLaunched = originalIsViewLaunched + }) + + 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. toHaveBeenCalledWith proves both + // lookups happened in any order; the call order below proves the order itself. + expect(double.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(mockClineProvider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("shared-profile") + const hasConfigCalls = vi + .mocked(mockClineProvider.providerSettingsManager.hasConfig) + .mock.calls.map((call) => call[0]) + expect(hasConfigCalls.indexOf("view-local-profile")).toBeLessThan(hasConfigCalls.indexOf("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 the view through activation 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 re-pin goes through the activation path so the view adopts the shared + // choice's provider settings, not the invalid profile's stale settings; the + // handler itself performs no global write. The launch-time repair must skip + // both persistence steps so it cannot rewrite the current mode's saved + // profile or the task's sticky profile. + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledTimes(1) + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "currentApiConfigName", + "shared-profile", + ) + expect(mockClineProvider.saveViewState).not.toHaveBeenCalled() + }) + + it("re-pins the view to the shared global profile rather than the first listed profile", async () => { + double.providerSettingsManager.listConfig = vi.fn().mockResolvedValue([ + { name: "first-listed", apiProvider: providerIdentifiers.anthropic }, + { name: "shared-profile", apiProvider: providerIdentifiers.anthropic }, + ]) + 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 follows the still-valid shared global selection, not the first + // profile in the list; the global selection is left untouched. + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledTimes(1) + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalledWith({ name: "first-listed" }) + expect(mockClineProvider.saveViewState).not.toHaveBeenCalled() + }) + + it("keeps the shared global selection when the first listed profile has no name", async () => { + // Legacy profile shape: the first listed entry carries no name. The still-valid + // shared global selection must be re-pinned through activation, not cleared by + // the repair branch. + double.providerSettingsManager.listConfig = vi + .fn() + .mockResolvedValue([{ apiProvider: providerIdentifiers.anthropic }]) + 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)) + + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledTimes(1) + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + // The repair branch would have cleared the selection with undefined. + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("currentApiConfigName", undefined) + }) + + it("records the legacy repair without activating a profile when no name is listed", async () => { + double.providerSettingsManager.listConfig = vi + .fn() + .mockResolvedValue([{ apiProvider: providerIdentifiers.anthropic }]) + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockResolvedValue(false) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The legacy repair still records the (empty) selection, but does not activate a + // profile that has no name. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", undefined) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("logs and continues launch when view-state registration fails", async () => { + double.setViewStateId = vi.fn().mockRejectedValue(new Error("storage down")) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + // The failed registration is logged ... + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("view-state registration failed")) + // ... launch handling still posts the initial state ... + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + // ... and marks the view as launched. + expect(mockClineProvider.isViewLaunched).toBe(true) + }) +}) + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -1301,6 +1514,27 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - updateSettings persistence", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + it("routes a changed key through provider.setValue so the view-local mutation path is exercised", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { autoApprovalEnabled: true }, + }) + + // The changed key must go through the provider-level mutation path (which keeps + // the view-local buffer/pin in sync), not a direct contextProxy write: the + // double's setValue forwards to contextProxy.setValue, so this assertion is + // only satisfiable when the handler calls the provider method itself. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("autoApprovalEnabled", true) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) +}) + describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { beforeEach(() => { vi.clearAllMocks() @@ -1315,6 +1549,9 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { expect(ensureDcgInstalled).toHaveBeenCalledWith("/mock/global/storage") expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true) + // The updateSettings flow must route through the provider-level mutation path so the + // durable view pin write stays ordered with the shared write. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true) expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() }) @@ -1369,6 +1606,20 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { expect(ensureDcgInstalled).not.toHaveBeenCalled() expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) }) + + it("routes the write through provider.setValue so view-local state stays in sync", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: false }, + }) + + // The provider-level call is the write path under test. The mock forwards to + // contextProxy.setValue, so an assertion on the proxy alone would also pass + // if the handler bypassed the provider and skipped the view-local sync. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) }) // A plain boolean needs no normalization branch in the updateSettings loop, @@ -2199,6 +2450,25 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(calls.at(-1)).toEqual([true]) }) + // The webviewDidLaunch tests below replace these mockClineProvider members with + // per-test doubles. Snapshot the module-level originals at collection time and + // restore them in the afterEach below so the launch stubs never leak into other + // tests of this file. + // Single structural cast: the class types these members as a method / a + // readonly property, which cannot be re-assigned to swap in a per-test double. + const launchSuiteSnapshot = (() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + return { + getMcpHub: view.getMcpHub, + providerSettingsManager: view.providerSettingsManager, + getStateToPostToWebview: view.getStateToPostToWebview, + } + })() + // CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state // via an async provider.getStateToPostToWebview().then(...) continuation, outside // telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message @@ -2367,4 +2637,15 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) + + afterEach(() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 13af5fc731..83b00bab8d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -584,7 +584,20 @@ export const webviewMessageHandler = async ( provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) } break - case "webviewDidLaunch": + case "webviewDidLaunch": { + // A failed view-state registration must not abort launch handling: the + // initial state, theme and API-configuration sync below still run, and the + // provider restores its previous viewStateId on failure (setViewStateId) so + // a later launch retries registration and loadViewState instead of + // treating the failed id as already handled. + try { + await provider.setViewStateId(message.viewStateId) + } catch (error) { + provider.log( + `[webviewDidLaunch] view-state registration failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) @@ -633,17 +646,53 @@ 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 && globalConfigName) { + // Re-pin this view to the still-valid shared global selection (not the + // first listed profile) through the activation path so the view adopts + // the shared choice's settings: a name-only re-pin would leave the + // invalid profile's stale apiConfiguration in place. The activation + // writes the shared slot back with the same value, so the global + // selection itself is left untouched, and the first listed profile's + // name (absent on legacy shapes) is irrelevant to this branch. + // Skip both persistence steps: this is a launch-time repair of this + // view's own selection, and persisting would silently rewrite the + // current mode's saved profile (setModeConfig) and the task's sticky + // profile, which every other view reads. Also leave a current task that follows + // a different sticky profile alone: this repair must not swap that task's API + // handler (preserveForeignTask). + await provider.activateProviderProfile( + { name: globalConfigName }, + { + persistModeConfig: false, + persistTaskHistory: false, + preserveForeignTask: true, + }, + ) + // 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 + } } } } @@ -693,6 +742,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 @@ -860,7 +910,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() @@ -1570,9 +1622,7 @@ export const webviewMessageHandler = async ( // containment as a literal and escape only after that later decode. if (fromMarkdown) { const decoded = decodeUntrustedPathToStable(rawPath) - // Stryker disable next-line ConditionalExpression,BlockStatement: hostile non-stabilizing encodings are unreachable from the webview (its posts are plain link targets); the bound is defensive if (decoded === null) { - // Stryker disable next-line CallExpression: hostile non-stabilizing encodings are pinned by the direct decodeUntrustedPathToStable bound test; the webview never posts such values rejectOutsideWorkspace() break } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f1b2590f7f..99f33e27e3 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -64,11 +64,6 @@ "count": 14 } }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "activate/registerCodeActions.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -1026,12 +1021,12 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 196 + "count": 194 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 27 + "count": 23 } }, "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 f4ed8af469..857b810006 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,9 +1,18 @@ 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" import { makeClineProviderFactory } from "../../test-utils/provider" +// The merged API constructor requires a ClineProviderFactory; this spec never +// triggers tab creation, so the factory must not be called. Cast structurally: +// the class type carries private members. +const makeUnusedTabFactory = () => + ({ + createInNewTab: vi.fn().mockRejectedValue(new Error("Unexpected editor-tab creation in this test")), + }) as unknown as ConstructorParameters[2] vi.mock("@roo-code/ipc", () => ({ IpcServer: class {}, @@ -18,6 +27,7 @@ describe("API - configuration", () => { const provider = { context: {}, on: vi.fn(), + setValues, contextProxy: { setValues }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, @@ -51,6 +61,7 @@ describe("API - configuration", () => { const provider = { context: {}, on: vi.fn(), + setValues, contextProxy: { setValues }, providerSettingsManager: { saveConfig, setModeConfig }, postStateToWebview, @@ -63,4 +74,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, makeUnusedTabFactory()) + + 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..8ce83fb9c4 --- /dev/null +++ b/src/extension/__tests__/api-set-configuration.spec.ts @@ -0,0 +1,70 @@ +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" +// The merged API constructor requires a ClineProviderFactory; these specs drive +// tab creation through the mocked createClineTabPanel, so the factory must never +// be called. Cast structurally: the class type carries private members. +const makeUnusedTabFactory = () => + ({ + createInNewTab: vi.fn().mockRejectedValue(new Error("Unexpected editor-tab creation in this test")), + }) as unknown as ConstructorParameters[2] + +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 = Object.assign({} as ClineProvider, { + 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), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeUnusedTabFactory(), + ) + 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) + + // Exact side-effect counts: a regression that repeats any of these operations + // (duplicate settings write, duplicate profile save, duplicate webview post) fails. + expect(provider.setValues).toHaveBeenCalledTimes(1) + expect(provider.setValues).toHaveBeenCalledWith(configuration) + expect(provider.contextProxy.setValues).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledTimes(1) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", configuration) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/extension/__tests__/api-start-new-task.spec.ts b/src/extension/__tests__/api-start-new-task.spec.ts index e33f2b3553..8e7185a10e 100644 --- a/src/extension/__tests__/api-start-new-task.spec.ts +++ b/src/extension/__tests__/api-start-new-task.spec.ts @@ -7,10 +7,16 @@ import type { ClineProviderFactory } from "../../core/webview/ClineProviderFacto import type { Task } from "../../core/task/Task" import { Package } from "../../shared/package" import { makeClineProviderFactory } from "../../test-utils/provider" +import { createClineTabPanel } from "../../activate/registerCommands" +import type { WebviewFocusTracker } from "../../core/webview/WebviewFocusTracker" vi.mock("vscode", () => ({ commands: { executeCommand: vi.fn().mockResolvedValue(undefined) } })) vi.mock("@roo-code/ipc", () => ({ IpcServer: class {} })) vi.mock("../../core/webview/ClineProvider", () => ({ ClineProvider: class {} })) +// F6 design: startNewTask({ newTab: true }) opens a fresh tab through +// createClineTabPanel (never reusing the tracked tab), not through the +// provider factory, so the module function is the seam under test here. +vi.mock("../../activate/registerCommands", () => ({ createClineTabPanel: vi.fn() })) function createProvider(taskId: string) { // Only the task identity is consumed by this API path; task execution is outside this test's scope. @@ -21,6 +27,8 @@ function createProvider(taskId: string) { postStateToWebview: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), createTask: vi.fn().mockResolvedValue(task), + context: { extensionUri: undefined } as unknown as vscode.ExtensionContext, + webviewFocusTracker: {} as WebviewFocusTracker, }) } @@ -47,6 +55,7 @@ describe("API - startNewTask routing", () => { } providerFactory = makeClineProviderFactory() vi.mocked(providerFactory.createInNewTab).mockResolvedValue(editor) + vi.mocked(createClineTabPanel).mockResolvedValue(editor as unknown as ClineProvider) api = new API(outputChannel, sidebar, providerFactory) }) @@ -62,8 +71,14 @@ describe("API - startNewTask routing", () => { expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "workbench.action.files.revert") expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(2, "workbench.action.closeAllEditors") expect(vscode.commands.executeCommand).toHaveBeenCalledTimes(2) - expect(providerFactory.createInNewTab).toHaveBeenCalledExactlyOnceWith() - expect(vi.mocked(providerFactory.createInNewTab).mock.contexts[0]).toBe(providerFactory) + // The fresh tab is created through createClineTabPanel with the sidebar + // provider's context/output channel/tracker, never through the factory. + expect(createClineTabPanel).toHaveBeenCalledExactlyOnceWith({ + context: sidebar.context, + outputChannel, + webviewFocusTracker: sidebar.webviewFocusTracker, + }) + expect(providerFactory.createInNewTab).not.toHaveBeenCalled() expect(editor.on).toHaveBeenCalledWith(RooCodeEventName.TaskCreated, expect.any(Function)) expect(editor.on).toHaveBeenCalledWith(RooCodeEventName.TaskCompleted, expect.any(Function)) } else { @@ -98,7 +113,7 @@ describe("API - startNewTask routing", () => { it("propagates tab-opening failures without starting a task in another chat", async () => { const error = new Error("Cannot open editor chat") - vi.mocked(providerFactory.createInNewTab).mockRejectedValueOnce(error) + vi.mocked(createClineTabPanel).mockRejectedValueOnce(error) await expect(api.startNewTask({ configuration: {}, newTab: true })).rejects.toBe(error) 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..9a4731d6f9 --- /dev/null +++ b/src/extension/__tests__/api-task-control.spec.ts @@ -0,0 +1,611 @@ +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" +// The merged API constructor requires a ClineProviderFactory; these specs drive +// tab creation through the mocked createClineTabPanel, so the factory must never +// be called. Cast structurally: the class type carries private members. +const makeUnusedTabFactory = () => + ({ + createInNewTab: vi.fn().mockRejectedValue(new Error("Unexpected editor-tab creation in this test")), + }) as unknown as ConstructorParameters[2] + +const { createClineTabPanelMock } = vi.hoisted(() => ({ + createClineTabPanelMock: vi.fn(), +})) + +vi.mock("vscode", () => ({ + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + ExtensionMode: { + Development: 1, + ExtensionDevelopment: 2, + Test: 3, + Production: 4, + }, +})) + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +vi.mock("../../activate/registerCommands", () => ({ + createClineTabPanel: createClineTabPanelMock, +})) + +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 & { + // Minimal context surface exercised by the task-control tests: the mode guard + // reads extensionMode and getGlobalState/setGlobalState read/update globalState. + context: { + extensionMode: vscode.ExtensionMode + globalState: { + get: (key: string) => unknown + update?: (key: string, value: unknown) => Promise + } + } + 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 + isDisposed: boolean + viewStateReadiness: Promise + taskHistoryStore: Map +} + +type TaskDouble = EventEmitter & { + taskId: string + parentTaskId?: string + approveAsk: Mock<() => void> + denyAsk: 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 + // The default double leaves extensionMode unset (never Production), so the test-only + // guard stays inert for the task-control tests. + provider.context = {} as ProviderDouble["context"] + 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 + provider.isDisposed = false + provider.viewStateReadiness = Promise.resolve() + provider.taskHistoryStore = new Map() + return provider +} + +function createTask(taskId: string): TaskDouble { + const task = new EventEmitter() as TaskDouble + task.taskId = taskId + task.approveAsk = vi.fn() + task.denyAsk = 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), makeUnusedTabFactory()) + }) + + describe("startNewTask", () => { + it("reverts and closes existing editors before opening a new tab unless preserveOpenTabs is true", async () => { + const newTabProvider = createProvider("new-tab-task") + createClineTabPanelMock.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(createClineTabPanelMock).toHaveBeenCalledWith({ + context: sidebarProvider.context, + outputChannel, + }) + expect(newTabProvider.evictCurrentTask).toHaveBeenCalledOnce() + expect(newTabProvider.createTask).toHaveBeenCalledWith( + "new task", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 0 }, + configuration, + ) + }) + + it("opens a new tab without revert or close commands when preserveOpenTabs is true", async () => { + const newTabProvider = createProvider("preserved-tab-task") + createClineTabPanelMock.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(createClineTabPanelMock).toHaveBeenCalledWith({ + context: sidebarProvider.context, + outputChannel, + }) + expect(newTabProvider.createTask).toHaveBeenCalledWith( + "keep editors", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 0 }, + configuration, + ) + }) + + it("fails task creation when the view state is not ready within the readiness bound", async () => { + // A readiness promise that never settles: the bound must reject task creation + // instead of letting createTask run against stale shared defaults. + sidebarProvider.viewStateReadiness = new Promise(() => {}) + + await expect(api.startNewTask({ configuration, text: "new task" })).rejects.toThrow( + "Timed out waiting for the view state to become ready", + ) + expect(sidebarProvider.createTask).not.toHaveBeenCalled() + }) + + it("cancels the readiness timeout when the view becomes ready within the bound", async () => { + // Fake timers pin the exact timer surface: a global clearTimeout spy would + // also fire for the flush sleep and library internals, so it could not + // prove the readiness bound's own timer was cancelled. + vi.useFakeTimers() + let resolveReady!: () => void + sidebarProvider.viewStateReadiness = new Promise((resolve) => { + resolveReady = resolve + }) + + const started = api.startNewTask({ configuration, text: "ready task" }) + await vi.advanceTimersByTimeAsync(50) + resolveReady() + + try { + await expect(started).resolves.toBe("sidebar-task") + // The readiness bound's timer must be gone once readiness wins: a live + // timer would later reject an unawaited promise and surface as an + // unhandled rejection. + expect(vi.getTimerCount()).toBe(0) + // No late rejection can fire after the bound either. + await vi.advanceTimersByTimeAsync(3000) + } finally { + vi.useRealTimers() + } + }) + + it("continues with the shared-state fallback when the webview never launches", async () => { + // Headless API callers create tasks without ever launching a webview: the + // readiness bound expiring must not reject them - the pre-existing + // shared-state fallback still applies. + vi.useFakeTimers() + sidebarProvider.viewLaunched = false + sidebarProvider.viewStateReadiness = new Promise(() => {}) + + const started = api.startNewTask({ configuration, text: "headless task" }) + await vi.advanceTimersByTimeAsync(3000) + + try { + await expect(started).resolves.toBe("sidebar-task") + } finally { + vi.useRealTimers() + } + }) + + it("still rejects when the webview launched but the readiness bound expires", async () => { + // A launched view whose readiness never settles is a real failure: the + // task must not consume shared defaults behind a live view. + vi.useFakeTimers() + sidebarProvider.viewStateReadiness = new Promise(() => {}) + + const started = api.startNewTask({ configuration, text: "stuck view task" }) + // Attach the rejection handler before advancing: with fake timers the + // bound fires during the advance, and a handler attached afterwards would + // see the rejection briefly unhandled. + const settled = expect(started).rejects.toThrow("Timed out waiting for the view state to become ready") + await vi.advanceTimersByTimeAsync(3000) + + try { + await settled + } finally { + vi.useRealTimers() + } + }) + + it("fails task creation when the provider is disposed while the readiness wait is pending", async () => { + // Disposal resolves viewStateReadiness to release waiters (see ClineProvider.dispose) + // and marks the provider disposed first: the wait must then fail instead of + // creating a task against the disposed view. + let resolveReady!: () => void + sidebarProvider.viewStateReadiness = new Promise((resolve) => { + resolveReady = resolve + }) + + const started = api.startNewTask({ configuration, text: "new task" }) + await new Promise((resolve) => setTimeout(resolve, 50)) + sidebarProvider.isDisposed = true + resolveReady() + + await expect(started).rejects.toThrow( + "The provider was disposed while waiting for the view state to become ready", + ) + expect(sidebarProvider.createTask).not.toHaveBeenCalled() + }) + }) + + 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("denies a registered task by id", async () => { + const task = createTask("task-to-deny") + + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect(api.denyTaskAsk(task.taskId)).resolves.toBe(true) + expect(task.denyAsk).toHaveBeenCalledOnce() + }) + + it("returns false when denying an unknown or de-registered task", async () => { + const task = createTask("task-denied-lifecycle") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, {}, {}) + + // Unknown id, and an id whose registry entry was removed on completion. + await expect(api.denyTaskAsk("missing-task")).resolves.toBe(false) + await expect(api.denyTaskAsk(task.taskId)).resolves.toBe(false) + }) + + 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) + }) + + it("ignores a late teardown event after the registry entry was already removed", async () => { + const task = createTask("late-teardown-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, {}, {}) + + // The completed event already removed the entry. A late duplicate teardown event + // must be a silent no-op, not a crash on the missing entry. + expect(() => task.emit(RooCodeEventName.TaskAborted)).not.toThrow() + await expect(api.approveTaskAsk(task.taskId)).resolves.toBe(false) + }) + + it("re-emits TaskStarted and records it in the message log for a registered task", async () => { + // The per-task TaskStarted listener re-emits the event on the API and records it + // through fileLog: spy on the prototype so the message content is observable + // (fileLog is a no-op without the enableLogging flag). + const fileLogSpy = vi.spyOn(API.prototype, "fileLog" as keyof API).mockResolvedValue(undefined) + const task = createTask("task-to-start") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + const started = new Promise((resolve) => { + api.once(RooCodeEventName.TaskStarted, (taskId: string) => resolve(taskId)) + }) + task.emit(RooCodeEventName.TaskStarted) + + await expect(started).resolves.toBe("task-to-start") + expect(fileLogSpy).toHaveBeenCalledWith(expect.stringContaining("taskStarted -> task-to-start")) + fileLogSpy.mockRestore() + }) + }) + + 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") + // Deferred mode switch: the follow-up answer must stay pending while the + // switch is, so the assertion below can observe the in-between state. + let releaseModeSwitch: () => void = () => {} + const modeSwitchSettled = new Promise((resolve) => { + releaseModeSwitch = resolve + }) + sidebarProvider.handleModeSwitch.mockImplementation(() => modeSwitchSettled) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + const selecting = api.selectTaskFollowupSuggestion({ + taskId: task.taskId, + answer: "Use architect", + mode: "architect", + }) + + // Flush microtasks so the flow reaches the (still pending) mode-switch await. + for (let i = 0; i < 8; i++) { + await Promise.resolve() + } + + expect(sidebarProvider.getState).toHaveBeenCalledOnce() + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + // The mode switch must be awaited before answering: while it is still + // pending, the task's ask stays unanswered. + expect(task.handleWebviewAskResponse).not.toHaveBeenCalled() + + releaseModeSwitch() + await expect(selecting).resolves.toBe(true) + 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), makeUnusedTabFactory(), 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 - registry identity, listener wiring, and mode-switch failure handling", () => { + 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), makeUnusedTabFactory()) + }) + + 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") + api = new API(outputChannel, asClineProvider(sidebarProvider), makeUnusedTabFactory(), undefined, true) + 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") + expect(outputChannel.appendLine).toHaveBeenCalledWith( + "[API#selectTaskFollowupSuggestion] mode switch failed for task task-failing-switch: persist failed", + ) + }) + + it("wires a provider's task events exactly once when the same provider is reused for a new tab", async () => { + // A second new-tab task resolving the SAME provider (an existing tab panel returns + // its live provider) must not re-register its listeners: a duplicate copy would + // re-emit every task event once per registered handler. + const newTabProvider = createProvider("reused-tab-task") + createClineTabPanelMock.mockResolvedValue(newTabProvider) + + await api.startNewTask({ configuration, text: "first", newTab: true }) + await api.startNewTask({ configuration, text: "second", newTab: true }) + + const seen: string[] = [] + api.on(RooCodeEventName.TaskCompleted, (taskId: string) => { + seen.push(taskId) + }) + + newTabProvider.emit(RooCodeEventName.TaskCompleted, "reused-tab-task", {}, {}) + // The provider-side handler is async (file logging after the re-emit); let it settle + // before asserting on the emission count. + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(seen).toEqual(["reused-tab-task"]) + }) + + describe("test-only API production guard", () => { + function withProductionContext() { + return { + extensionMode: vscode.ExtensionMode.Production, + globalState: { get: vi.fn() }, + } + } + + it("rejects the task ask and global-state surface in production mode", async () => { + sidebarProvider.context = withProductionContext() + api = new API(outputChannel, asClineProvider(sidebarProvider), makeUnusedTabFactory()) + + // Pin the method name in each message: the guard builds the error from the + // method argument, and a partial match would leave the argument untested. + await expect(api.approveTaskAsk("task-1")).rejects.toThrow("approveTaskAsk is a test-only API") + await expect(api.denyTaskAsk("task-1")).rejects.toThrow("denyTaskAsk is a test-only API") + await expect(api.selectTaskFollowupSuggestion({ taskId: "task-1", answer: "yes" })).rejects.toThrow( + "selectTaskFollowupSuggestion is a test-only API", + ) + expect(() => api.getGlobalState("mode")).toThrow("getGlobalState is a test-only API") + // setGlobalState is async: the guard rejects instead of throwing synchronously. + await expect(api.setGlobalState("mode", "ask")).rejects.toThrow("setGlobalState is a test-only API") + }) + + it("keeps the task ask and global-state surface available outside production mode", async () => { + const get = vi.fn().mockReturnValue("code") + const update = vi.fn().mockResolvedValue(undefined) + sidebarProvider.context = { + extensionMode: vscode.ExtensionMode.Test, + globalState: { get, update }, + } + api = new API(outputChannel, asClineProvider(sidebarProvider), makeUnusedTabFactory()) + + expect(api.getGlobalState("mode")).toBe("code") + expect(get).toHaveBeenCalledWith("mode") + await api.setGlobalState("mode", "ask") + expect(update).toHaveBeenCalledWith("mode", "ask") + await expect(api.approveTaskAsk("missing-task")).resolves.toBe(false) + }) + }) + + describe("startNewTask view-state readiness", () => { + it("waits for the provider view state to load before creating the task", async () => { + let resolveReadiness: () => void = () => {} + const readiness = new Promise((resolve) => { + resolveReadiness = resolve + }) + const newTabProvider = createProvider("new-tab-task") + newTabProvider.viewStateReadiness = readiness + createClineTabPanelMock.mockResolvedValue(newTabProvider) + + const started = api.startNewTask({ configuration, text: "wait for view state", newTab: true }) + + // The task must not start while the view persisted state is still loading. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(newTabProvider.createTask).not.toHaveBeenCalled() + + resolveReadiness() + + await expect(started).resolves.toBe("new-tab-task") + expect(newTabProvider.createTask).toHaveBeenCalled() + }) + }) + + describe("startNewTask listener idempotency", () => { + it("registers provider listeners once when the same provider is reused", async () => { + const sharedProvider = createProvider("shared-task") + createClineTabPanelMock.mockResolvedValue(sharedProvider) + + await api.startNewTask({ configuration, text: "one", newTab: true }) + await api.startNewTask({ configuration, text: "two", newTab: true }) + + // Re-registering the same provider must not stack duplicate listeners. + expect(sharedProvider.listenerCount(RooCodeEventName.TaskCompleted)).toBe(1) + + let completions = 0 + api.on(RooCodeEventName.TaskCompleted, () => { + completions++ + }) + sharedProvider.emit(RooCodeEventName.TaskCompleted, "shared-task") + + expect(completions).toBe(1) + }) + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 78a9496a3f..c15cc706e6 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -8,11 +8,14 @@ import pWaitFor from "p-wait-for" import { type RooCodeAPI, + type RooCodeTestOnlyApi, + type GlobalState, type RooCodeSettings, type RooCodeEvents, type ProviderSettings, type ProviderSettingsEntry, type TaskEvent, + type TaskLike, type CreateTaskOptions, type TaskApiConversationHistorySequence, type WebviewThemeFixture, @@ -25,17 +28,35 @@ import { import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" -import type { Mode } from "../shared/modes" -import type { ClineProvider } from "../core/webview/ClineProvider" +import { getAllModes, type Mode } from "../shared/modes" +import { ClineProvider } from "../core/webview/ClineProvider" import type { ClineProviderFactory } from "../core/webview/ClineProviderFactory" +import type { Task } from "../core/task/Task" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" +import { createClineTabPanel } from "../activate/registerCommands" import { getCommands } from "../services/command/commands" -export class API extends EventEmitter implements RooCodeAPI { +type TaskAskController = { + approveAsk(): void + denyAsk(): void + handleWebviewAskResponse(response: "messageResponse", text?: string, images?: string[]): void +} + +type RegisteredTask = { + task: TaskAskController + provider: ClineProvider +} + +export class API extends EventEmitter implements RooCodeAPI, RooCodeTestOnlyApi { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider + private readonly context: vscode.ExtensionContext private readonly ipc?: IpcServer + private readonly tasksById = new Map() + + // Providers whose listeners are already wired (startNewTask is idempotent per provider). + private readonly registeredProviders = new WeakSet() private readonly log: (...args: unknown[]) => void private logfile?: string @@ -50,6 +71,7 @@ export class API extends EventEmitter implements RooCodeAPI { this.outputChannel = outputChannel this.sidebarProvider = provider + this.context = provider.context if (enableLogging) { this.log = (...args: unknown[]) => { @@ -84,7 +106,15 @@ export class API extends EventEmitter implements RooCodeAPI { this.log( `[API] StartNewTask -> ${command.data.text}, ${JSON.stringify(command.data.configuration)}`, ) - await this.startNewTask(command.data) + try { + await this.startNewTask(command.data) + } catch (error) { + // IpcServer does not await async listeners: an uncaught rejection here + // would surface as an unhandled rejection in the extension host. + this.log( + `[API] StartNewTask failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } break case TaskCommandName.CancelTask: this.log(`[API] CancelTask`) @@ -173,19 +203,28 @@ export class API extends EventEmitter implements RooCodeAPI { text, images, newTab, + preserveOpenTabs, }: { - configuration: RooCodeSettings + 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 this.providerFactory.createInNewTab() + // A fresh tab: reusing the tracked tab would evict the task it is already serving. + provider = await createClineTabPanel({ + context: this.context, + outputChannel: this.outputChannel, + webviewFocusTracker: this.sidebarProvider.webviewFocusTracker, + }) this.registerListeners(provider) } else { await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) @@ -193,6 +232,44 @@ export class API extends EventEmitter implements RooCodeAPI { provider = this.sidebarProvider } + // A new task must not consume shared default mode/profile state before the view's + // stable state id is registered and its persisted view state loaded: webviewDidLaunch + // may not have arrived yet. Bound the wait so a view that never launches cannot + // hang task creation; if the bound expires, fail task creation rather than start + // the task against stale shared defaults. + let readinessTimer: ReturnType | undefined + try { + await Promise.race([ + provider.viewStateReadiness, + new Promise((_, reject) => { + readinessTimer = setTimeout( + () => reject(new Error("Timed out waiting for the view state to become ready")), + 3000, + ) + }), + ]).finally(() => { + clearTimeout(readinessTimer) + }) + } catch (error) { + if ( + provider.viewLaunched || + !(error instanceof Error) || + error.message !== "Timed out waiting for the view state to become ready" + ) { + throw error + } + // Headless API callers create tasks without ever launching a webview: the + // readiness bound expiring keeps the pre-existing shared-state fallback. + // A launched view whose readiness failed is a real failure and rethrows. + this.log("[API#startNewTask] webview not launched; continuing in headless mode") + } + + // Disposal resolves viewStateReadiness to release waiters: a provider torn down while + // the wait was pending must fail task creation rather than create a task against the + // disposed view (its posts and task registration would be orphaned). + if (provider.isDisposed) { + throw new Error("The provider was disposed while waiting for the view state to become ready") + } await provider.evictCurrentTask() await provider.postStateToWebview() await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) @@ -365,6 +442,99 @@ export class API extends EventEmitter implements RooCodeAPI { this.sidebarProvider.getCurrentTask()?.approveAsk() } + /** + * The test-only surface (task ask control by ID and raw global-state reads) must not + * be reachable from production callers: VS Code only reports ExtensionMode.Production on the extension context for installed extensions, so test hosts and the e2e + * harness keep access. + */ + private assertTestOnlyApi(method: string): void { + if (this.context.extensionMode === vscode.ExtensionMode.Production) { + throw new Error(`${method} is a test-only API and is unavailable in production mode`) + } + } + + /** + * 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 { + this.assertTestOnlyApi("approveTaskAsk") + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + entry.task.approveAsk() + return true + } + + /** + * Denies the pending ask for a specific task by its ID. + * + * @returns Whether a registered task with the given ID was found and denied. + */ + public async denyTaskAsk(taskId: string): Promise { + this.assertTestOnlyApi("denyTaskAsk") + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + entry.task.denyAsk() + 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 { + this.assertTestOnlyApi("selectTaskFollowupSuggestion") + 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 } @@ -391,7 +561,29 @@ 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) { + // Idempotent per provider: repeated or concurrent startNewTask calls against a + // reused tab provider must not stack duplicate listeners (duplicate API events, + // duplicate file logging, and duplicate per-task listener sets). + if (this.registeredProviders.has(provider)) { + return + } + provider.on(RooCodeEventName.TaskCompleted, async (taskId, tokenUsage, toolUsage) => { const historyItem = provider.taskHistoryStore.get(taskId) this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage, { @@ -404,6 +596,8 @@ export class API extends EventEmitter implements RooCodeAPI { }) provider.on(RooCodeEventName.TaskCreated, (task) => { + this.tasksById.set(task.taskId, { task: task as unknown as TaskAskController, provider }) + // Task Lifecycle task.on(RooCodeEventName.TaskStarted, async () => { @@ -411,8 +605,16 @@ export class API extends EventEmitter implements RooCodeAPI { await this.fileLog(`[${new Date().toISOString()}] taskStarted -> ${task.taskId}\n`) }) + // #1452 moved the TaskCompleted emit and file-log to the provider-level + // listener above. This per-task listener remains only for the reference-safe + // registry removal: the provider-level handler receives the taskId but not the + // task instance, so it cannot verify the entry still belongs to this instance. + task.on(RooCodeEventName.TaskCompleted, () => { + this.removeRegisteredTask(task) + }) task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) + this.removeRegisteredTask(task) }) task.on(RooCodeEventName.TaskFocused, () => { @@ -421,6 +623,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, () => { @@ -512,6 +715,7 @@ export class API extends EventEmitter implements RooCodeAPI { provider.on(RooCodeEventName.TaskDelegationResumed, (parentTaskId, childTaskId) => { ;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) }) + this.registeredProviders.add(provider) } // Logging @@ -562,13 +766,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( @@ -580,6 +789,16 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postStateToWebview() } + public getGlobalState(key: K): GlobalState[K] { + this.assertTestOnlyApi("getGlobalState") + return this.context.globalState.get(key) + } + + public async setGlobalState(key: K, value: GlobalState[K]): Promise { + this.assertTestOnlyApi("setGlobalState") + await this.context.globalState.update(key, value) + } + public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) diff --git a/src/package.json b/src/package.json index fb4cb26abc..2cd58ba573 100644 --- a/src/package.json +++ b/src/package.json @@ -95,6 +95,26 @@ "title": "%command.settings.title%", "icon": "$(settings-gear)" }, + { + "command": "zoo-code.plusButtonClickedInTab", + "title": "%command.newTask.title%", + "icon": "$(edit)" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "title": "%command.settings.title%", + "icon": "$(settings-gear)" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "title": "%command.marketplace.title%", + "icon": "$(extensions)" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "title": "%command.history.title%", + "icon": "$(history)" + }, { "command": "zoo-code.openInNewTab", "title": "%command.openInNewTab.title%", @@ -241,22 +261,22 @@ ], "editor/title": [ { - "command": "zoo-code.plusButtonClicked", + "command": "zoo-code.plusButtonClickedInTab", "group": "navigation@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.settingsButtonClicked", + "command": "zoo-code.settingsButtonClickedInTab", "group": "navigation@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.marketplaceButtonClicked", + "command": "zoo-code.marketplaceButtonClickedInTab", "group": "navigation@3", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.historyButtonClicked", + "command": "zoo-code.historyButtonClickedInTab", "group": "overflow@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, @@ -265,6 +285,24 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "false" + } ] }, "keybindings": [ diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 3e05ce64da..7ef4a7e12c 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -203,11 +203,10 @@ const App = () => { } }, [telemetrySetting, telemetryKey, machineId, vscodeTelemetryEnabled, didHydrateState]) - // Tell the extension that we are ready to receive messages. - useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) - + // Window activation and programmatic focus do not identify a chat interaction. + // (webviewDidLaunch is posted by ExtensionStateContext once the view state is + // hydrated; re-posting it here would double the launch message.) useEffect(() => { - // Window activation and programmatic focus do not identify a chat interaction. const onInteraction = () => vscode.postMessage({ type: "webviewDidFocus" }) document.addEventListener("pointerdown", onInteraction, true) document.addEventListener("keydown", onInteraction, true) @@ -216,7 +215,6 @@ const App = () => { document.removeEventListener("keydown", onInteraction, true) } }, []) - // 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 4b4cbcd5fe..920ced2ae0 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -9,6 +9,7 @@ import { vscode } from "../utils/vscode" vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), + getViewStateId: vi.fn(() => "test-view-state-id"), }, })) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 2459d559b6..5ae612432a 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -77,6 +77,7 @@ const ModesView = () => { setCustomInstructions, customModes, mcpServers, + viewStateLoaded, } = useExtensionState() // Use a local state to track the visually active mode @@ -613,6 +614,17 @@ const ModesView = () => { }) } + if (viewStateLoaded === false) { + return ( +
+
+
+
+
+
+ ) + } + return (
diff --git a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx b/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx index 25dc7fb8e1..5a2e99e6a6 100644 --- a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx +++ b/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx @@ -26,6 +26,8 @@ const mockExtensionState = { currentApiConfigName: "", customInstructions: "Initial instructions", setCustomInstructions: vitest.fn(), + viewStateLoaded: true, + setViewStateLoaded: vitest.fn(), } const renderPromptsView = (props = {}) => { @@ -43,6 +45,19 @@ describe("PromptsView", () => { vitest.clearAllMocks() }) + it("shows a loading skeleton while view-local state is initializing", () => { + renderPromptsView({ + viewStateLoaded: false, + mode: "debug", + currentApiConfigName: "stale-global-profile", + }) + + expect(screen.getByTestId("modes-view-loading-skeleton")).toBeInTheDocument() + expect(screen.queryByTestId("mode-select-trigger")).not.toBeInTheDocument() + expect(screen.queryByText("Debug")).not.toBeInTheDocument() + expect(screen.queryByText("stale-global-profile")).not.toBeInTheDocument() + }) + it("displays the current mode name in the select trigger", () => { renderPromptsView({ mode: "code" }) const selectTrigger = screen.getByTestId("mode-select-trigger") diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index c4fa00f479..6596419c3e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -39,6 +39,8 @@ import { convertTextMateToHljs } from "@src/utils/textMateToHljs" export interface ExtensionStateContextType extends ExtensionState { historyPreviewCollapsed?: boolean // Add the new state property didHydrateState: boolean + viewStateLoaded: boolean + setViewStateLoaded: (value: boolean) => void showWelcome: boolean theme: any mcpServers: McpServer[] @@ -293,6 +295,7 @@ export const ExtensionStateContextProvider: React.FC<{ ) const [didHydrateState, setDidHydrateState] = useState(false) + const [viewStateLoaded, setViewStateLoaded] = useState(false) const [showWelcome, setShowWelcome] = useState(false) const [theme, setTheme] = useState(undefined) const [filePaths, setFilePaths] = useState([]) @@ -349,6 +352,7 @@ export const ExtensionStateContextProvider: React.FC<{ setState((prevState) => mergeExtensionState(prevState, newState)) setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) + setViewStateLoaded(true) // Update alwaysAllowFollowupQuestions if present in state message if ((newState as any).alwaysAllowFollowupQuestions !== undefined) { setAlwaysAllowFollowupQuestions((newState as any).alwaysAllowFollowupQuestions) @@ -519,7 +523,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 @@ -540,6 +547,8 @@ export const ExtensionStateContextProvider: React.FC<{ chatFontSize: state.chatFontSize ?? undefined, reasoningBlockCollapsed: state.reasoningBlockCollapsed ?? true, didHydrateState, + viewStateLoaded, + setViewStateLoaded, showWelcome, theme, mcpServers, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index dc33af38d1..48099e5016 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -21,22 +21,41 @@ 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 } = - useExtensionState() + const { + allowedCommands, + setAllowedCommands, + soundEnabled, + showRooIgnoredFiles, + setShowRooIgnoredFiles, + viewStateLoaded, + setViewStateLoaded, + } = useExtensionState() return (
{JSON.stringify(allowedCommands)}
{JSON.stringify(soundEnabled)}
{JSON.stringify(showRooIgnoredFiles)}
+
{JSON.stringify(viewStateLoaded)}
+
) } @@ -111,7 +130,96 @@ 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("posts webviewDidLaunch without a viewStateId when getViewStateId is unavailable", () => { + const savedGetViewStateId = vscode.getViewStateId + Object.defineProperty(vscode, "getViewStateId", { configurable: true, value: undefined }) + try { + render( + + + , + ) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch", viewStateId: undefined }) + } finally { + Object.defineProperty(vscode, "getViewStateId", { configurable: true, value: savedGetViewStateId }) + } + }) + + it("applies mode and profile from a later 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( @@ -132,6 +240,55 @@ describe("ExtensionStateContext", () => { expect(JSON.parse(screen.getByTestId("rules").textContent!)).toEqual([]) }) + it("initializes with viewStateLoaded set to false", () => { + render( + + + , + ) + + expect(JSON.parse(screen.getByTestId("view-state-loaded").textContent!)).toBe(false) + }) + + it("marks viewStateLoaded true after receiving initial state", () => { + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + mode: "ask", + currentApiConfigName: "view-local-profile", + }, + }, + }), + ) + }) + + expect(JSON.parse(screen.getByTestId("view-state-loaded").textContent!)).toBe(true) + }) + + it("updates viewStateLoaded through setViewStateLoaded", () => { + render( + + + , + ) + + act(() => { + screen.getByTestId("set-view-state-loaded-button").click() + }) + + expect(JSON.parse(screen.getByTestId("view-state-loaded").textContent!)).toBe(true) + }) + it("updates rules from incoming rules message", () => { 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..6153e07fd4 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,365 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +// Minimal Storage surface for VSCodeAPIWrapper browser fallback tests. Typed +// precisely (instead of casting to Storage) so each double only promises the +// members the wrapper actually touches. +interface MockStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + clear(): void +} + +const createMockStorage = (initialState: Record = {}): MockStorage => { + 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] + } + }), + } +} + +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: MockStorage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + removeItem: vi.fn(() => { + throw new Error("storage denied") + }), + clear: vi.fn(() => { + throw new Error("storage denied") + }), + } + 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() + }) + + it("falls back to a timestamp-random id when crypto.randomUUID is unavailable", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: {}, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto global is undefined", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: undefined, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto object lacks randomUUID", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { "": 1 }, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // A truthy crypto global without a randomUUID member must still take the + // deterministic fallback: 1700000000000.toString(36) === "loyw3v28" and + // (0.987654321).toString(36) === "0.zk00000ytu", so the id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("creates a new viewStateId when the stored state parses to JSON null", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "after-null-view") }, + }) + const storage = createMockStorage({ vscodeState: "null" }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("after-null-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "after-null-view" }) + }) + + it("replaces an empty persisted viewStateId with a freshly created one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "refilled-view" }) + }) + + it("replaces a non-object persisted state with a freshly created viewStateId", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "replaced-string-view") }, + }) + // A persisted JSON string is truthy but not an object: the guard must keep it out of + // the fresh state, so the persisted record contains only the new viewStateId. + const storage = createMockStorage({ vscodeState: JSON.stringify("stale-string-state") }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("replaced-string-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "replaced-string-view" }) + }) + + it("returns the normalized id when the persisted id needs the extension-side cleanup", () => { + const randomUUID = vi.fn(() => "should-not-be-generated") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // ClineProvider.setViewStateId trims and rewrites unsafe characters, so the + // webview must report the same normalized id instead of the raw stored value. + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " side 1 " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("side_1") + expect(wrapper.getViewStateId()).toBe("side_1") + expect(randomUUID).not.toHaveBeenCalled() + }) + + it("generates a fresh id when the persisted id is whitespace-only", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "ws-refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("ws-refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "ws-refilled-view" }) + }) + + it("generates a fresh id when the persisted id is the __proto__ string", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "proto-refused-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "__proto__" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("proto-refused-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "proto-refused-view" }) + }) + + it("keeps one generated id when a write fails while stale persisted state stays readable", () => { + const randomUUID = vi.fn().mockReturnValueOnce("gen-one").mockReturnValueOnce("gen-two") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // Storage still serves a stale persisted state (e.g. pre-identity webview state + // without a viewStateId) while setItem throws: without the write-failure flag, + // every getViewStateId call would re-read the stale JSON and generate a fresh id. + const storage: MockStorage = { + getItem: vi.fn(() => JSON.stringify({ mode: "architect" })), + setItem: vi.fn(() => { + throw new Error("write denied") + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + const first = wrapper.getViewStateId() + const second = wrapper.getViewStateId() + + expect(first).toBe("gen-one") + expect(second).toBe("gen-one") // no flap: the in-memory state stays authoritative + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "gen-one" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("keeps the in-memory viewStateId when setItem is unavailable while reads still work", () => { + const randomUUID = vi.fn().mockReturnValueOnce("no-setitem-view").mockReturnValueOnce("flapped-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + const storage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + // The persisted JSON never gained the id: the in-memory state stays authoritative + // instead of re-reading the stale JSON on every call. + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "no-setitem-view" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("treats persisted storage as authoritative again once a write recovers", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "recovered-view") }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + let writesFail = true + const storage: MockStorage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + if (writesFail) { + throw new Error("write denied") + } + backing[key] = value + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // The first generation cannot be persisted: the in-memory state is authoritative. + expect(wrapper.getViewStateId()).toBe("recovered-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "recovered-view" }) + + // A later write succeeds: the flag clears and the persisted JSON is + // authoritative again for reads. + writesFail = false + wrapper.setState({ mode: "code", viewStateId: "recovered-view" }) + expect(backing.vscodeState).toBe(JSON.stringify({ mode: "code", viewStateId: "recovered-view" })) + // An external writer refreshes the persisted record: a recovered wrapper must see + // it (flag cleared); a still-flagged wrapper would keep the in-memory copy. + backing.vscodeState = JSON.stringify({ mode: "code", viewStateId: "recovered-view", external: true }) + expect(wrapper.getState()).toEqual({ mode: "code", viewStateId: "recovered-view", external: true }) + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..fc0f035de9 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -11,8 +11,13 @@ 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 + // Once a persistent-storage write fails (setItem throws or is unavailable), + // the persisted JSON is stale: the in-memory fallbackState is authoritative + // for getState() until a write succeeds again. + private storageWriteFailed: boolean = false constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -22,6 +27,46 @@ 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 + + // Mirror the ClineProvider.setViewStateId normalization so the webview never + // registers an id the extension would rewrite or reject: trim, replace unsafe + // characters, and drop whitespace-only and "__proto__" values. + const normalizedViewStateId = + typeof existingViewStateId === "string" ? existingViewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") : "" + + if (normalizedViewStateId && normalizedViewStateId !== "__proto__") { + return normalizedViewStateId + } + + 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 +94,24 @@ 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 } + + if (this.storageWriteFailed) { + // A previous write could not be persisted: reading localStorage would + // return stale JSON, so the in-memory fallback is authoritative. + return this.fallbackState + } + + 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 +128,24 @@ 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)) + this.storageWriteFailed = false + } else { + this.storageWriteFailed = true + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + this.storageWriteFailed = true + } + + return newState } }