diff --git a/.gitignore b/.gitignore index e6692fa08d..a69678b453 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,10 @@ qdrant_storage/ plans/ roo-cli-*.tar.gz* +.merge-* +.zdt-* +.eslint-*.txt +.fix-commit*.txt +.prettier-check.txt +.suites*.txt +.tsc.txt 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 f24f37d388..f7871432f6 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -27,6 +27,8 @@ import { 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) @@ -148,6 +150,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..056f7d03c6 --- /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 entries the marker was written + // to β€” the assertion requires every marked view-state id to retain the build + // mode, so an unrelated surviving entry cannot satisfy it β€” 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 markedIds: string[] = [] + { + const persisted = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"] + assert.ok(persisted, "Expected persisted viewStates before writing the rehydration marker") + + const marked: NonNullable = { ...persisted } + for (const [viewStateId, entry] of Object.entries(marked)) { + if (entry.mode === "ask") { + marked[viewStateId] = { ...entry, mode: "build" } + markedIds.push(viewStateId) + } + } + assert.ok(markedIds.length > 0, "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; the waitFor below then polls for the + // reloaded webview to report the provider's state (no extra fixed wait is + // needed once polling starts). + await vscode.commands.executeCommand("workbench.action.toggleSidebarVisibility") + await sleep(2_000) + await vscode.commands.executeCommand("workbench.action.toggleSidebarVisibility") + await sleep(2_000) + + // (a) Every durable entry the marker was written to still holds the build mode + // after the reload. A wipe, prune, or re-key of the durable map during the + // cycle would drop or rename the marked entries, and no unrelated entry can + // substitute for them. 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 markedIds.every((id) => persisted[id]?.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( + markedIds.every((id) => viewStates[id]?.mode === "build"), + "Expected every marked view-state id to still hold 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 f2b2e7e8e5..b7e6270d55 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, + SECRET_STATE_KEYS, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, GLOBAL_SETTINGS_KEYS, globalSettingsSchema, @@ -22,6 +23,13 @@ describe("destructive command guard global setting", () => { }) }) +describe("provider api-key secret registration", () => { + it("stores mimo and poe api keys in secret storage, not plaintext global state", () => { + expect(SECRET_STATE_KEYS).toContain("mimoApiKey") + expect(SECRET_STATE_KEYS).toContain("poeApiKey") + }) +}) + describe("alwaysDenyUnapprovedCommands global setting", () => { it("is opt-in by default", () => { expect(DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS).toBe(false) @@ -39,6 +47,35 @@ describe("alwaysDenyUnapprovedCommands global setting", () => { }) }) +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() + }) +}) + describe("collapseCommandOutputByDefault global setting", () => { it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => { const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value }) 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 e64406fa9c..bc0fb97a94 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. @@ -128,7 +131,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 @@ -195,6 +200,52 @@ 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. + */ +/** + * GlobalState keys addressable through the test-only API surface. + * + * `taskHistory` is excluded at the type level because it carries user PII and the + * runtime refuses it for both accessors. Credential-bearing keys are still refused + * at runtime (a key union cannot express the resolution), and `setGlobalState` + * additionally requires the key to have a global-settings value schema. + */ +export type TestableGlobalStateKey = Exclude + +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 + /** + * GlobalState keys the test-only accessors can address. `taskHistory` is excluded + * because it carries user PII and both runtime methods reject it; credential-bearing + * keys are rejected at runtime as well, which a key union cannot express. + */ + getGlobalState(key: K): GlobalState[K] + /** + * Writes a value to VS Code globalState. Intended for use in tests only. The + * implementation additionally requires the key to have a global-settings value + * schema and rejects credential-bearing keys and `taskHistory` at runtime. + */ + 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 f5757a51c7..1f195488cd 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -114,6 +114,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 */ @@ -122,6 +131,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(), @@ -383,6 +393,8 @@ export const SECRET_STATE_KEYS = [ "ioIntelligenceApiKey", "nanoGptApiKey", "basetenApiKey", + "mimoApiKey", + "poeApiKey", ] as const // Global secrets that are part of GlobalSettings (not ProviderSettings) @@ -402,6 +414,26 @@ export type SecretState = Pick => SECRET_STATE_KEYS.includes(key as ProviderSecretKey) || GLOBAL_SECRET_KEYS.includes(key as GlobalSecretKey) +/** + * Credential-bearing provider settings that do not use an *ApiKey name shape: + * header maps that can carry Authorization values, session tokens, service + * account JSON, and credential-file paths. They must be excluded from every + * flattened configuration surface alongside SECRET_STATE_KEYS. + */ +export const SENSITIVE_PROVIDER_CONFIG_KEYS = [ + "openAiHeaders", + "vertexKeyFile", + "vertexJsonCredentials", + "zooSessionToken", + "mimoApiKey", + "poeApiKey", + "qwenCodeOauthPath", + "geminiCliOAuthPath", +] as const + +export const isSensitiveProviderConfigKey = (key: string): boolean => + (SENSITIVE_PROVIDER_CONFIG_KEYS as readonly string[]).includes(key) + /** * GlobalState */ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 8a006c0639..9c9791ee72 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -712,6 +712,7 @@ export interface WebviewMessage { | "themeFixtureProbeResponse" | "diagnosticsResponse" 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 811ceda165..ab13f48dfe 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 c59933a7c1..2227c1547d 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 { getPanel, 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(), @@ -20,6 +32,10 @@ vi.mock("vscode", () => ({ joinPath: vi.fn((_base: unknown, ..._pathSegments: string[]) => ({ path: _pathSegments.join("/") })), }, ViewColumn: { + // Active is the value the no-visible-editors path targets: newGroupRight makes + // the group it creates the active one. -1 is the vscode.d.ts value; the mock previously + // carried only Two, which the old hardcoded target depended on. + Active: -1, Two: 2, }, window: { @@ -143,11 +159,11 @@ describe("registerCommands handlers", () => { let mockVisibleProvider: { postMessageToWebview: Mock retryWebviewRecovery: Mock<() => Promise> - evictCurrentTask?: Mock - refreshWorkspace?: Mock } let mockProvider: { postMessageToWebview: Mock + evictCurrentTask: Mock + refreshWorkspace: Mock webviewFocusTracker: WebviewFocusTracker retryWebviewRecovery: Mock<() => Promise> } @@ -156,6 +172,11 @@ describe("registerCommands handlers", () => { beforeEach(() => { vi.clearAllMocks() + + // vi.clearAllMocks drops call history but keeps implementations, so a per-view + // lookup installed by an earlier test would still be in place here. Clear it so + // each test decides its own routing instead of inheriting the previous one. + ;(ClineProvider.getInstanceForView as Mock).mockReset() handlers = {} mockOutputChannel = { @@ -181,6 +202,8 @@ describe("registerCommands handlers", () => { mockProvider = { retryWebviewRecovery: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), webviewFocusTracker: new WebviewFocusTracker(), } ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) @@ -211,6 +234,21 @@ describe("registerCommands handlers", () => { setPanel(undefined, "tab") }) + it("creates diagnostics from all provider instances without requiring a visible provider", async () => { + const providers = [{}, {}] + ;(ClineProvider.getAllInstances as Mock).mockReturnValue(providers) + ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + + await handlers["zoo-code.createDiagnosticsReport"]() + + const { createDiagnosticsReport } = await import("../../services/diagnostics") + expect(createDiagnosticsReport).toHaveBeenCalledWith({ + context: mockContext, + outputChannel: mockOutputChannel, + providers, + }) + }) + it("registers the ripgrep diagnostic command and stores its disposable in context.subscriptions", async () => { const { registerRipgrepDiagnosticCommand } = await import("../../services/ripgrep/diagnostic") const mock = vi.mocked(registerRipgrepDiagnosticCommand) @@ -267,59 +305,114 @@ describe("registerCommands handlers", () => { }, ) - it("creates diagnostics from all provider instances without requiring a visible provider", async () => { - const { createDiagnosticsReport } = await import("../../services/diagnostics") - const providers = [{}, {}] - ;(ClineProvider.getAllInstances as Mock).mockReturnValue(providers) - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - await handlers["zoo-code.createDiagnosticsReport"]() - - expect(createDiagnosticsReport).toHaveBeenCalledWith({ - context: mockContext, - outputChannel: mockOutputChannel, - providers, - }) - }) - - 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", () => { @@ -392,44 +485,158 @@ 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() + }) + + it("focusInput does not post when a tab panel is tracked without a live tab instance", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + await handlers["zoo-code.focusInput"]() + + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance there is no other target. + 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... + await handlers["zoo-code.focusInput"]() expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + + // ...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" }) }) - // 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 + // 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 @@ -447,22 +654,65 @@ 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.evictCurrentTask = evictCurrentTask - mockVisibleProvider.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) + 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" }) + }) - // Should not throw even with no visible provider - await handlers["zoo-code.plusButtonClicked"]() + it("focusInput logs the drop when a tab panel is tracked without a live tab instance", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + // No live instance for the tracked panel, so the tab path resolves no provider. + + 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. + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "focusInput: no live provider for the tracked tab panel; action dropped", + ) + }) + + // Determinism guard: vi.clearAllMocks keeps mockImplementation, so an implementation + // installed by an earlier handler test would decide which provider a later test + // resolves. The lookup must enter every test unset - this case fails on the residue of + // the tests above it, not on any behaviour of its own. + it("enters each test with no installed per-view provider lookup", () => { + expect((ClineProvider.getInstanceForView as Mock).getMockImplementation()).toBeUndefined() }) }) @@ -493,12 +743,14 @@ describe("openClineInNewTab", () => { webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), + dispose: vi.fn(), } ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel) // Reset module-level panel state. setPanel(undefined, "sidebar") setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() }) it.each([false, true])( @@ -532,6 +784,9 @@ describe("openClineInNewTab", () => { undefined, ) + // 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", @@ -542,4 +797,1217 @@ describe("openClineInNewTab", () => { }), ) }) + + 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 the group it just created 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.Active, + { + 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("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) + + // 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("keeps a hidden live tab as the tracked ref when the tracked panel closes", async () => { + // A Zoo Code tab that sits open behind another editor in its group is neither active nor + // visible. It is still a live tab, so the tracked ref must keep resolving the tab-scoped + // commands and focusInput at it - clearing the reference is for when nothing remains. + 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(), + }) + + 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 leaves only the hidden tab: neither flag is set on it. + const disposeB = (panelB.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeB() + expect(panelA.active).toBeUndefined() + expect(panelA.visible).toBeUndefined() + expect(getPanel()).toBe(panelA) + + // Closing that tab too leaves nothing live, and only then is the ref cleared. + const disposeA = (panelA.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeA() + expect(getPanel()).toBeUndefined() + }) + + it("prefers the active panel over a merely visible one when re-pointing the tracked tab ref", async () => { + // Panel A is created and tracked first and stays merely visible... + 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 and becomes the active (tracked) panel. + 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) + + // A third panel activates and becomes the tracked one... + const panelC = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-C", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelC) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + Object.assign(panelC, { active: true, visible: true }) + Object.assign(panelA, { visible: true }) + ;(panelC.onDidChangeViewState as Mock).mock.calls[0]![0]!({ webviewPanel: panelC }) + expect(getPanel()).toBe(panelC) + + // ...so when it closes, the re-point must pick the active panel (B) over the + // merely visible one (A): the active tab is the current tab. + const disposeC = (panelC.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeC() + expect(getPanel()).toBe(panelB) + + // Tidy up the still-live panels so module state does not leak into other tests. + ;(panelA.onDidDispose as Mock).mock.calls[0]![0]!() + ;(panelB.onDidDispose as Mock).mock.calls[0]![0]!() + }) + + it("disposes a tab panel whose webview resolution rejects so it cannot linger as a stale tracked tab", async () => { + // The disposal handler is registered before the webview is resolved, + // so a rejected resolve cannot skip it and leave the panel in the + // live set with no path to remove it. + const broken = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(broken) + const resolveSpy = vi.spyOn(ClineProvider.prototype, "resolveWebviewView") + resolveSpy.mockRejectedValue(new Error("resolve failed")) + // The provider's own dispose must run in the failure path too: with + // the real resolveWebviewView, a rejection before its onDidDispose + // registration would otherwise strand the provider in + // ClineProvider.activeInstances. + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose") + disposeSpy.mockResolvedValue(undefined) + + // The command surfaces the same failure as before the guard existed... + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("resolve failed") + + // ...the failed panel is disposed... + expect(broken.dispose).toHaveBeenCalledTimes(1) + // ...and the provider's own dispose runs in the failure path so it + // cannot linger as an orphaned active instance. The activeInstances + // removal itself is proven with a real provider in + // ClineProvider.spec.ts: this harness mocks the provider module, and a + // getInstanceForView assertion here would be vacuous because the + // mocked resolver never assigns the panel to provider.view. + expect(disposeSpy).toHaveBeenCalledTimes(1) + // ...and its disposal handler was registered despite the failure. The + // mock panel does not wire dispose to the handler, so fire it manually + // to prove the cleanup it performs: the tracked ref is cleared with no + // live tab panels remaining. + expect(broken.onDidDispose).toHaveBeenCalledTimes(1) + const disposeHandler = (broken.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeHandler() + expect(getPanel()).toBeUndefined() + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) + + it("keeps the original resolve error when provider dispose also fails", async () => { + // A cleanup failure inside the failure path must not mask the + // original resolve error: it is logged and swallowed while the + // command handler still observes the resolve failure. + const broken = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(broken) + const resolveSpy = vi.spyOn(ClineProvider.prototype, "resolveWebviewView") + resolveSpy.mockRejectedValue(new Error("resolve failed")) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose") + disposeSpy.mockRejectedValue(new Error("dispose failed")) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("resolve failed") + + expect(broken.dispose).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openClineInNewTab] provider dispose failed: Error: dispose failed", + ) + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) + + 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("starts a fresh creation when the pending one resolves to a disposed provider", async () => { + // The panel closes while the creation is still awaiting its post-creation work, and a + // closing panel disposes its provider: the shared promise then resolves to a provider that + // can open no tab, so a later call must start its own creation instead of joining it. + // Only the first provider is the closing tab. A spy that flags every provider as disposed + // would mark the replacement disposed too, and the identity assertions below would then + // pass while the caller is handed a provider that can serve no tab. + let sawClosingTab = false + const resolveSpy = vi.spyOn(ClineProvider.prototype, "resolveWebviewView").mockImplementation(async function ( + this: ClineProvider, + ) { + // The automocked ClineProvider carries no _disposed field at all, so the double writes + // one: the closing tab's provider gets the flag, every later provider is written live. + this["_disposed"] = !sawClosingTab + if (!sawClosingTab) { + sawClosingTab = true + } + }) + + const [first, second] = await Promise.all([ + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ]) + + const ctor = vi.mocked(ClineProvider) + const disposedProvider = ctor.mock.instances[0] + expect(first).toBe(disposedProvider) + expect(disposedProvider["_disposed"]).toBe(true) + + // The joined call saw a disposed provider and re-ran the creation: a second panel exists and + // the caller receives the live provider, not the closed tab's one. + expect(second).not.toBe(disposedProvider) + expect(second).toBe(ctor.mock.instances[1]) + // "Live" has to mean something here: the replacement is the provider this caller can post to, + // so it must not carry the disposal flag the closing tab's provider carries. + expect(second["_disposed"]).toBe(false) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + + resolveSpy.mockRestore() + }) + + it("joins the shared replacement when three opens wake on one disposed creation", async () => { + // Three overlapping opens park on the same in-flight creation and the panel closes before it + // settles. The call that started the creation keeps the provider it built; the two callers + // parked on it must not each start their own replacement - the first of them stores one in + // the shared slot and the other joins it - or a single menu gesture opens two editor tabs. + let sawClosingTab = false + const resolveSpy = vi.spyOn(ClineProvider.prototype, "resolveWebviewView").mockImplementation(async function ( + this: ClineProvider, + ) { + // The automocked ClineProvider carries no _disposed field at all, so the double writes + // one: the closing tab's provider gets the flag, every later provider is written live. + this["_disposed"] = !sawClosingTab + if (!sawClosingTab) { + sawClosingTab = true + } + }) + + const open = () => + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const [first, second, third] = await Promise.all([open(), open(), open()]) + + const ctor = vi.mocked(ClineProvider) + const disposedProvider = ctor.mock.instances[0] + expect(first).toBe(disposedProvider) + expect(disposedProvider["_disposed"]).toBe(true) + + // Exactly one replacement panel exists, and both late callers received it: the slot is joined, + // not re-created, once a replacement is in flight. + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(ctor.mock.instances).toHaveLength(2) + expect(second).toBe(ctor.mock.instances[1]) + expect(third).toBe(second) + expect(second["_disposed"]).toBe(false) + expect(third["_disposed"]).toBe(false) + + resolveSpy.mockRestore() + }) + + 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", + }) + }) + + 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("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() + }) + describe("openClineInNewTab initialization failure", () => { + 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(), + dispose: vi.fn(), + } + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel) + + // Reset module-level panel state. + setPanel(undefined, "sidebar") + setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() + }) + + 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")) + // The real provider dispose disposes the panel, which fires the disposal + // handler installed by createClineTabPanel; the double mirrors that + // contract so the tracked-ref cleanup runs the production path. + let disposeHandler: (() => void) | undefined + const failingPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn((handler: () => void) => { + disposeHandler = handler + }), + dispose: vi.fn(() => disposeHandler?.()), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(failingPanel) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockImplementation(async () => { + failingPanel.dispose() + }) + + 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 and the provider is disposed - disposing the provider + // disposes the panel, whose disposal handler then clears the tracked + // ref (no other live panel remains here). + expect(__getLiveTabPanelCountForTests()).toBe(0) + expect(getPanel()).toBeUndefined() + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) + + it("re-points the tracked tab at the surviving panel when a new panel's initialization fails", async () => { + // Panel A opens fully and becomes the tracked tab. + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + active: true, + visible: true, + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(getPanel()).toBe(panelA) + + // Panel B's initialization fails. Its provider dispose mirrors the real + // contract: disposing the provider disposes the panel, which fires the + // disposal handler installed by createClineTabPanel. + let disposeHandlerB: (() => void) | undefined + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + active: true, + visible: true, + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn((handler: () => void) => { + disposeHandlerB = handler + }), + dispose: vi.fn(() => disposeHandlerB?.()), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + const resolveSpy = vi + .spyOn(ClineProvider.prototype, "resolveWebviewView") + .mockRejectedValueOnce(new Error("init failed")) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockImplementation(async () => { + panelB.dispose() + }) + + await expect( + createClineTabPanel({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("init failed") + + // The failed panel leaves the live registry, and the tracked ref follows + // the disposal handler to the surviving panel A instead of being cleared + // up front - clearing it first would make the handler skip the re-point + // and leave the tab surface without a provider. + expect(__getLiveTabPanelCountForTests()).toBe(1) + expect(getPanel()).toBe(panelA) + expect(disposeSpy).toHaveBeenCalledTimes(1) + + // Tab-scoped commands resolve the provider from the tracked panel, so + // they target panel A's provider again. + const providerA = { + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + } + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + view === panelA ? providerA : undefined, + ) + 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.plusButtonClickedInTab"]() + expect(providerA.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(providerA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + + 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(), + dispose: 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(), + dispose: 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")) + // The real provider dispose disposes the panel, which fires the disposal + // handler installed by createClineTabPanel; the double mirrors that + // contract so the tracked-ref cleanup runs the production path. + let disposeHandler: (() => void) | undefined + const failingPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn((handler: () => void) => { + disposeHandler = handler + }), + dispose: vi.fn(() => disposeHandler?.()), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(failingPanel) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockImplementation(async () => { + failingPanel.dispose() + }) + + 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 and the provider is disposed - disposing the provider + // disposes the panel, whose disposal handler then clears the tracked + // ref (no other live panel remains here). + 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 f0f4747eee..1739c8115a 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" @@ -31,16 +31,54 @@ 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. + * @internal Exported for focused implementation tests. + */ +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. + * @internal Exported for focused implementation tests. + */ +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 + * @internal Exported for focused implementation tests. */ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined { return tabPanel || sidebarPanel } /** - * 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, @@ -48,13 +86,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 @@ -81,27 +128,58 @@ export const registerCommands = (options: RegisterCommandOptions) => { // type while rejecting direct calls with arguments. VS Code dispatches positional // arguments dynamically; `unknown` requires callers to inspect returned values. type CommandCallback = (...args: never[]) => 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") @@ -111,43 +189,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 () => { @@ -180,8 +265,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) { @@ -233,7 +332,98 @@ 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. + // The join is re-entered rather than trusted once. A creation can resolve + // to a disposed provider - the panel closed mid-flight - and several callers + // may be parked on that same settled promise. The first of them to wake + // starts the replacement and stores it in the slot; the others must re-read + // the slot and join that replacement instead of falling through, or each + // wake-up creates its own panel and the serialization is lost exactly where + // it matters. Re-entry terminates: each pass either returns a live provider + // or consumes a settled creation and moves to a strictly newer one, so it + // cannot outlive the number of callers. + for (;;) { + const pendingCreation = pendingTabPanelCreation + if (!pendingCreation) { + break + } + + // The panel can close while the creation is still awaiting its post-creation delay or the + // editor-group lock, and a closing panel disposes its provider: a caller that arrives then + // would receive that disposed provider from the shared promise and open no editor tab at + // all. Reuse the in-flight creation only when the provider it resolves to is still live - + // bracket access: _disposed is the only liveness signal ClineProvider exposes. + const resolvedProvider = await pendingCreation + + if (!resolvedProvider["_disposed"]) { + return resolvedProvider + } + + if (pendingTabPanelCreation === pendingCreation) { + pendingTabPanelCreation = undefined + } + } + + 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. A joining caller + // that woke on the disposed creation can reach the slot first - it + // re-enters the loop, sees the slot empty, and stores its own + // creation before this finally block runs - so the equality check is + // what keeps this settlement from dropping that replacement and + // letting a fourth call open a third panel. + 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). @@ -244,8 +434,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 } @@ -267,7 +458,12 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr await vscode.commands.executeCommand("workbench.action.newGroupRight") } - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + // With no editors visible the panel must land in the group newGroupRight just + // created, which that command makes the active group. Hardcoding ViewColumn.Two + // re-targets whatever group two already is - a second Zoo panel when one is open + // there - and leaves the newly created group empty, so repeated requests pile up + // empty groups instead of hosting panels in them. + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Active const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { enableScripts: true, @@ -277,6 +473,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). @@ -285,12 +482,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 } @@ -299,19 +505,76 @@ 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( () => { - // A recovery may already have installed a replacement panel. - if (tabPanel === newPanel) setPanel(undefined, "tab") + liveTabPanels.delete(newPanel) + if (tabPanel === newPanel) { + // Re-point the tracked ref at a remaining live tab (active first, + // then visible) so the tab-scoped title-bar commands and + // focusInput keep resolving an instance; clear the ref as before + // when nothing remains. The identity guard above keeps a late + // disposal of an already-replaced panel from clobbering the + // replacement's ref. + const live = [...liveTabPanels] + const remaining = + live.find((panel) => panel.active) ?? + live.find((panel) => panel.visible) ?? + // A tab open behind another editor in its group is neither active nor visible, + // and it is still a live tab: keep the ref on it so the tab-scoped commands and + // focusInput still resolve an instance. Only an empty set clears the ref. + live[0] + setPanel(remaining, "tab") + } }, null, context.subscriptions, // Also register dispose listener ) + try { + await tabProvider.resolveWebviewView(newPanel) + } catch (error) { + // Initialization failed: dispose the panel so the handler installed above + // removes it from the live registry and re-points or clears the tracked ref, + // and dispose the provider too - resolveWebviewView registers its own panel + // handler only after its awaited getHtmlContent/getState calls, so a rejection + // before that point would otherwise strand the provider in + // ClineProvider.activeInstances. The registry delete is repeated here so a + // double whose dispose does not fire the handler still leaves no stale entry, + // and a failing provider dispose is logged rather than allowed to mask the + // initialization error the caller must observe. + newPanel.dispose() + liveTabPanels.delete(newPanel) + await tabProvider + .dispose() + .catch((disposeError) => + outputChannel.appendLine(`[openClineInNewTab] provider dispose failed: ${disposeError}`), + ) + throw error + } + // Lock the editor group so clicking on files doesn't open them over the panel. + // This step runs after the panel and provider are live, so a failure here must + // release them the same way the resolve-failure branch above does: otherwise the + // caller sees a rejected creation while the panel stays open with a registered + // provider behind it. await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + try { + await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + } catch (error) { + newPanel.dispose() + liveTabPanels.delete(newPanel) + await tabProvider + .dispose() + .catch((disposeError) => + outputChannel.appendLine( + `[openClineInNewTab] provider dispose failed after a group-lock failure: ${disposeError}`, + ), + ) + throw error + } return tabProvider } diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index e53f60fb83..908e30e43c 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -67,13 +67,26 @@ 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, }) +/** + * Global settings the API round trip may not carry in either direction. Per-view selections + * are minted and held by the extension host - they name a view, not a user - so a caller that + * could write them could pin another view's identity. The export schema above omits the key; + * the API surface consults this list on both of its settings writes. + */ +const NON_PORTABLE_SETTINGS_KEYS: readonly string[] = ["viewStates"] + +export const isNonPortableSettingsKey = (key: string): boolean => NON_PORTABLE_SETTINGS_KEYS.includes(key) export class ContextProxy { private readonly originalContext: vscode.ExtensionContext private stateCache: GlobalState private secretCache: SecretState + private _isInitialized = false constructor(context: vscode.ExtensionContext) { @@ -120,6 +133,33 @@ export class ContextProxy { await Promise.all(promises) + // Migration: provider keys that moved from plaintext global state to secret + // storage keep working for users who configured them before the move: the + // value is relocated to secret storage and the plaintext copy is dropped. + for (const key of ["mimoApiKey", "poeApiKey"] as const) { + try { + const legacyValue = this.originalContext.globalState.get(key) + if (legacyValue === undefined) { + continue + } + // Preserve an existing secret: the plaintext copy is redundant once a + // secret exists, and a deletion that failed earlier must still be + // retried while the secret already wins reads. + if (this.secretCache[key] === undefined) { + await this.originalContext.secrets.store(key, legacyValue) + this.secretCache[key] = legacyValue + } + // Erase the plaintext only after the secret write succeeded (or was + // unnecessary): a failed store leaves the value for a retry instead of + // losing the user's key. + await this.originalContext.globalState.update(key, undefined) + } catch (error) { + logger.error( + `Error migrating legacy secret ${key}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // Migration: Check for old nested image generation settings and migrate them await this.migrateImageGenerationSettings() @@ -314,7 +354,12 @@ export class ContextProxy { private async migrateImageGenerationSettings() { try { // Check if there's an old nested structure - const oldNestedSettings = this.originalContext.globalState.get("openRouterImageGenerationSettings") + // The pre-typed nested layout only ever carried these two fields (the + // migration below reads nothing else). + const oldNestedSettings = this.originalContext.globalState.get<{ + openRouterApiKey?: string + selectedModel?: string + }>("openRouterImageGenerationSettings") if (oldNestedSettings && typeof oldNestedSettings === "object") { logger.info("Migrating old nested image generation settings to flattened structure") @@ -499,6 +544,7 @@ 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. const promises = [ ...SECRET_STATE_KEYS.map(async (key) => { try { @@ -716,7 +762,7 @@ export class ContextProxy { * @returns A promise that resolves when all reset operations are complete */ public async resetAllState() { - // Clear in-memory caches + // Clear in-memory caches. this.stateCache = {} this.secretCache = {} diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 01695d7994..391506871b 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -55,6 +55,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() @@ -99,6 +112,36 @@ export class ProviderSettingsManager { return next } + // Lock shared by every manager bound to the same secrets key. The sidebar and + // each editor tab construct their own `ProviderSettingsManager` over the same + // `context.secrets`, so the per-instance `_lock` above cannot serialize a + // check-then-write pair issued through two instances: a same-name create from + // the other view fits between `hasConfig` and `saveConfig`. + private static sharedLocks = new Map>() + + /** + * Run `cb` inside the critical section shared by every manager using this + * instance's secrets key, so a check-then-write pair - and the compensation + * that undoes it - cannot interleave with another view's. The per-instance + * lock still guards each individual read/write; this outer lock is taken + * first and released last, so the two cannot deadlock. + */ + public runExclusiveAcrossInstances(cb: () => Promise): Promise { + const key = this.secretsKey + const previous = ProviderSettingsManager.sharedLocks.get(key) ?? Promise.resolve() + const run = previous.then(cb) + // Store a settled-clean owner so one failed critical section cannot poison + // the queue for the next caller. + ProviderSettingsManager.sharedLocks.set( + key, + run.then( + () => {}, + () => {}, + ), + ) + return run + } + /** * Initialize config if it doesn't exist and run migrations. */ @@ -511,7 +554,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) { @@ -522,6 +565,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}`) } } @@ -543,7 +591,7 @@ export class ProviderSettingsManager { /** * Set the API config for a specific mode. */ - public async setModeConfig(mode: Mode, configId: string) { + public async setModeConfig(mode: Mode, configId: string | undefined) { try { return await this.lock(async () => { const providerProfiles = await this.load() @@ -551,8 +599,13 @@ export class ProviderSettingsManager { if (!providerProfiles.modeApiConfigs) { providerProfiles.modeApiConfigs = {} } - // Assign the chosen config ID to this mode - providerProfiles.modeApiConfigs[mode] = configId + // Assign the chosen config ID to this mode. Restoring a mode that had no config + // removes the entry instead of persisting an undefined value. + if (configId === undefined) { + delete providerProfiles.modeApiConfigs[mode] + } else { + providerProfiles.modeApiConfigs[mode] = configId + } await this.store(providerProfiles) }) } catch (error) { @@ -573,6 +626,28 @@ export class ProviderSettingsManager { throw new Error(`Failed to get mode config: ${error}`) } } + /** + * Read this store’s own gate - the selected profile and the complete mode mapping - in one + * locked snapshot, for callers that must compare what they wrote against what the store + * reports after a partial mutation. The mode map is returned as a copy rather than the + * live object so a caller can hold the snapshot across awaits while a later write replaces + * it. Read-only: nothing here stores, so this adds no new write path. + */ + public async getSelectionAndModeConfigs(): Promise< + Pick + > { + try { + return await this.lock(async () => { + const { currentApiConfigName, modeApiConfigs } = await this.load() + return { + currentApiConfigName, + modeApiConfigs: modeApiConfigs ? { ...modeApiConfigs } : undefined, + } + }) + } catch (error) { + throw new Error(`Failed to read the profile selection and mode configs: ${error}`) + } + } public async export() { try { diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 5d78e16351..9a52168602 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -77,11 +77,13 @@ describe("ContextProxy", () => { describe("constructor", () => { it("should initialize state cache with all global state keys", () => { - // +3 for the migration checks: + // +5 for the migration checks: // 1. openRouterImageGenerationSettings // 2. customCondensingPrompt // 3. customSupportPrompts (for migrateOldDefaultCondensingPrompt) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) + // 4. mimoApiKey legacy-plaintext probe + // 5. poeApiKey legacy-plaintext probe + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 5) for (const key of GLOBAL_STATE_KEYS) { expect(mockGlobalState.get).toHaveBeenCalledWith(key) } @@ -91,6 +93,62 @@ describe("ContextProxy", () => { expect(mockGlobalState.get).toHaveBeenCalledWith("customSupportPrompts") }) + it("relocates plaintext provider keys that moved to secret storage", async () => { + // A user configured mimoApiKey while it was still stored in plaintext + // global state; the key now lives in secret storage. + mockSecrets.get.mockImplementation(async (key: string) => + key === "mimoApiKey" || key === "poeApiKey" ? undefined : "test-secret", + ) + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? "legacy-mimo" : undefined)) + + const migrated = new ContextProxy(mockContext) + await migrated.initialize() + + // The legacy value is readable as a secret and no longer kept in plaintext. + expect(migrated.getSecret("mimoApiKey")).toBe("legacy-mimo") + expect(mockSecrets.store).toHaveBeenCalledWith("mimoApiKey", "legacy-mimo") + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + // poeApiKey has no legacy plaintext value: nothing is stored or cleared. + expect(mockSecrets.store).not.toHaveBeenCalledWith("poeApiKey", expect.anything()) + expect(mockGlobalState.update).not.toHaveBeenCalledWith("poeApiKey", expect.anything()) + }) + + it("keeps an existing secret when a stale plaintext copy exists", async () => { + mockSecrets.get.mockImplementation(async (key: string) => + key === "mimoApiKey" ? "current-secret" : "test-secret", + ) + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? "legacy-mimo" : undefined)) + + const migrated = new ContextProxy(mockContext) + await migrated.initialize() + + // The secret wins; the migration must not overwrite it with the stale + // plaintext value. + expect(migrated.getSecret("mimoApiKey")).toBe("current-secret") + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + // The redundant plaintext copy must still be erased: leaving it keeps the + // exposure alive forever, and a deletion that failed once must be retried. + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + }) + + it("keeps the plaintext copy when the migrated secret write fails", async () => { + mockSecrets.get.mockImplementation(async (key: string) => + key === "mimoApiKey" || key === "poeApiKey" ? undefined : "test-secret", + ) + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? "legacy-mimo" : undefined)) + mockSecrets.store.mockImplementationOnce(async () => { + throw new Error("secret store failed") + }) + + const migrated = new ContextProxy(mockContext) + await migrated.initialize() + + // Erasing the plaintext before the secret write lands could lose the key: + // the value must survive for a retry on the next initialization. + expect(mockGlobalState.update).not.toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migrated.getSecret("mimoApiKey")).toBeUndefined() + }) + it("should initialize secret cache with all secret keys", () => { expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_STATE_KEYS.length + GLOBAL_SECRET_KEYS.length) for (const key of SECRET_STATE_KEYS) { @@ -111,8 +169,8 @@ describe("ContextProxy", () => { const result = proxy.getGlobalState("apiProvider") expect(result).toBe("deepseek") - // Original context should be called once during updateGlobalState (+3 for migration checks) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) // From initialization + migration checks + // Original context should be called once during updateGlobalState (+5 for migration checks) + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 5) // From initialization + migration checks }) it("should handle default values correctly", async () => { @@ -158,6 +216,251 @@ describe("ContextProxy", () => { }) describe("updateGlobalState", () => { + it("restores the initialize-loaded value when the first write for a key fails", async () => { + // A key whose load seeded the durable record and whose first write fails: + // the rollback must restore the loaded value, not undefined. + mockGlobalState.get.mockImplementation((key: string) => + key === "apiProvider" ? "seeded-value" : undefined, + ) + await proxy.initialize() + mockGlobalState.update.mockRejectedValueOnce(new Error("storage failed")) + + await expect(proxy.updateGlobalState("apiProvider", "anthropic")).rejects.toThrow("storage failed") + + expect(proxy.getGlobalState("apiProvider")).toBe("seeded-value") + }) + + 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 initialize-loaded secret when the first write for a key fails", async () => { + // Same as the state case, for the SECRET_STATE_KEYS load loop's seed. + mockSecrets.get.mockImplementation((key: string) => (key === "apiKey" ? "seeded-secret" : undefined)) + await proxy.initialize() + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + + await expect(proxy.storeSecret("apiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBe("seeded-secret") + }) + + it("restores the initialize-loaded global secret when the first write for a key fails", async () => { + // Same as the state case, for the GLOBAL_SECRET_KEYS load loop's seed. + mockSecrets.get.mockImplementation((key: string) => + key === "openRouterImageApiKey" ? "seeded-global-secret" : undefined, + ) + await proxy.initialize() + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + + await expect(proxy.storeSecret("openRouterImageApiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("openRouterImageApiKey")).toBe("seeded-global-secret") + }) + + it("restores the refreshed secret when a post-refresh write fails", async () => { + // The refresh re-seeds the durable record with the loaded value; a write + // failing after the refresh must roll back to that value, not the + // pre-refresh one the record held before the re-sync. + await proxy.storeSecret("apiKey", "old-key") + mockSecrets.get.mockImplementation((key: string) => (key === "apiKey" ? "refreshed-value" : undefined)) + await proxy.refreshSecrets() + + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + await expect(proxy.storeSecret("apiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBe("refreshed-value") + }) + + it("restores the refreshed global secret when a post-refresh write fails", async () => { + // Same as the refresh case, for the GLOBAL_SECRET_KEYS re-sync loop. + await proxy.storeSecret("openRouterImageApiKey", "old-key") + mockSecrets.get.mockImplementation((key: string) => + key === "openRouterImageApiKey" ? "refreshed-global" : undefined, + ) + await proxy.refreshSecrets() + + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + await expect(proxy.storeSecret("openRouterImageApiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("openRouterImageApiKey")).toBe("refreshed-global") + }) + + 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 re-seeds the durable + // records with the loaded values: the pending write's later failure rolls + // back to the refreshed durable value, not 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 caches and the + // durable records: its rollback must restore the (re-seeded) durable value, + // which is nothing β€” not its previous value, which storage no longer holds. + // 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("does not roll a post-reset write back over the pre-reset durable value", async () => { + // resetAllState clears the durable records before the storage deletes: a + // write issued into the cleared window and failing before the + // re-initialization re-seeds must roll back to nothing (storage is + // already cleared), not to the pre-reset durable value. + await proxy.updateGlobalState("apiProvider", "deepseek") + + let failDelete!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failDelete = reject + }), + ) + const resetPromise = proxy.resetAllState() + // The synchronous clears ran; the reset is now awaiting the storage deletes. + + let failWrite!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failWrite = reject + }), + ) + const pending = proxy.updateGlobalState("apiProvider", "openrouter") + + failWrite(new Error("storage failed")) + await expect(pending).rejects.toThrow("storage failed") + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + + failDelete(new Error("reset failed")) + await expect(resetPromise).rejects.toThrow("reset failed") + }) + + it("does not reseed the durable record from a pre-reset write that completes after the reset", async () => { + // A write issued before resetAllState may still be in flight when the reset + // clears the durable maps and the re-initialization re-seeds them from + // (now empty) storage. Its late completion must not re-record its obsolete + // value over the re-seeded record: a later failed write would then roll the + // cache back to a value storage no longer holds. + await proxy.updateGlobalState("apiProvider", "deepseek") + + let resolveWrite!: (value?: unknown) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWrite = resolve + }), + ) + const stale = proxy.updateGlobalState("apiProvider", "openrouter") + + // Reset reads through globalState.get (undefined by default): the + // re-seeded durable record holds nothing for this key. + await proxy.resetAllState() + + // The pre-reset write lands after the re-seed. + resolveWrite() + await stale + + let failWrite!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failWrite = reject + }), + ) + const pending = proxy.updateGlobalState("apiProvider", "openrouter") + failWrite(new Error("storage failed")) + await expect(pending).rejects.toThrow("storage failed") + + // The rollback target is the re-seeded (empty) durable record, not the + // stale pre-reset write's value. + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + }) + it("should update state directly in original context", async () => { await proxy.updateGlobalState("apiProvider", "deepseek") @@ -230,6 +533,44 @@ describe("ContextProxy", () => { const storedValue = await proxy.getSecret("apiKey") expect(storedValue).toBeUndefined() }) + it("does not reseed the durable record from a pre-refresh secret write that completes after the refresh", async () => { + // Same race as the pre-reset case, for the refresh re-seed: a write + // issued before refreshSecrets may complete after the refresh has + // re-seeded the durable secret records from storage. + await proxy.storeSecret("apiKey", "old-key") + + let resolveStore!: (value?: unknown) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStore = resolve + }), + ) + const stale = proxy.storeSecret("apiKey", "stale-key") + + // The refresh re-seeds from storage. + mockSecrets.get.mockResolvedValue("storage-key") + await proxy.refreshSecrets() + + // The pre-refresh write lands after the re-seed. + resolveStore() + await stale + + let failStore!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failStore = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "new-key") + failStore(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + + // The rollback target is the freshly loaded value, not the stale + // pre-refresh write's value. + expect(proxy.getSecret("apiKey")).toBe("storage-key") + }) }) describe("setValue", () => { @@ -736,6 +1077,22 @@ describe("ContextProxy", () => { expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key") expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4") }) + + it("settles every started write before rejecting", async () => { + const landed: string[] = [] + vi.spyOn(proxy, "setValue").mockImplementation(async (key) => { + if (key === "mode") { + throw new Error("write failed") + } + await new Promise((resolve) => setTimeout(resolve, 20)) + landed.push(String(key)) + }) + + await expect(proxy.setValues({ mode: "ask", apiModelId: "gpt-4" })).rejects.toThrow("write failed") + // The sibling write must have landed before the rejection surfaced, so a + // caller restoring prior values cannot race an in-flight write. + expect(landed).toContain("apiModelId") + }) }) describe("setProviderSettings", () => { @@ -1170,4 +1527,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 a1fc9ac7de..640394cab6 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 @@ -90,6 +90,50 @@ describe("ProviderSettingsManager", () => { }) describe("initialize", () => { + it("serializes critical sections across managers bound to the same secrets key", async () => { + const first = new ProviderSettingsManager(mockContext) + const second = new ProviderSettingsManager(mockContext) + const events: string[] = [] + let releaseFirst: () => void = () => {} + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + + const firstRun = first.runExclusiveAcrossInstances(async () => { + events.push("first:enter") + await firstGate + events.push("first:exit") + }) + // Let the first critical section start before the second one is queued. + await new Promise((resolve) => setTimeout(resolve, 0)) + const secondRun = second.runExclusiveAcrossInstances(async () => { + events.push("second:enter") + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // The sidebar and each editor tab own a manager over the same + // context.secrets: without a lock shared by those instances the second + // critical section starts while the first is still inside it. + expect(events).toEqual(["first:enter"]) + + releaseFirst() + await Promise.all([firstRun, secondRun]) + expect(events).toEqual(["first:enter", "first:exit", "second:enter"]) + }) + + it("keeps the shared critical section usable after a rejection", async () => { + const first = new ProviderSettingsManager(mockContext) + const second = new ProviderSettingsManager(mockContext) + + await expect( + first.runExclusiveAcrossInstances(async () => { + throw new Error("boom") + }), + ).rejects.toThrow("boom") + + await expect(second.runExclusiveAcrossInstances(async () => "ok")).resolves.toBe("ok") + }) + it("should not write to storage when secrets.get returns null", async () => { // Mock readConfig to return null mockSecrets.get.mockResolvedValueOnce(null) @@ -973,6 +1017,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", ) @@ -1387,6 +1436,77 @@ describe("ProviderSettingsManager", () => { }) }) + // The delete compensation in ClineProvider compares what it wrote against what the store + // reports after a partial mutation, so the gate read must be complete (selection plus every + // mapping), stable across awaits (a copy, not the live map), and side-effect free. + describe("GetSelectionAndModeConfigs", () => { + it("returns the stored selection together with every mode mapping", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "selected-profile", + apiConfigs: { selected: { id: "selected-id" }, other: { id: "other-id" } }, + modeApiConfigs: { architect: "other-id", code: "selected-id" }, + migrations: { rateLimitSecondsMigrated: false }, + }), + ) + + const gate = await providerSettingsManager.getSelectionAndModeConfigs() + + expect(gate).toEqual({ + currentApiConfigName: "selected-profile", + modeApiConfigs: { architect: "other-id", code: "selected-id" }, + }) + }) + + it("returns a copy of the mode map so a later write cannot rewrite the snapshot", async () => { + // Round-trip the store so the read after the write can actually differ: a constant + // secrets double would hide an aliased snapshot behind stale data. + let stored = JSON.stringify({ + currentApiConfigName: "selected-profile", + apiConfigs: { selected: { id: "selected-id" }, other: { id: "other-id" } }, + modeApiConfigs: { code: "selected-id" }, + migrations: { rateLimitSecondsMigrated: false }, + }) + // store() receives the already-serialised profiles, so the double round-trips the string + // as-is: re-encoding it here would hand load() a double-encoded payload. + mockSecrets.get.mockImplementation(async () => stored) + mockSecrets.store.mockImplementation(async (_key: string, value: string) => { + stored = value + }) + + const snapshot = await providerSettingsManager.getSelectionAndModeConfigs() + await providerSettingsManager.setModeConfig("code", "other-id") + + // A snapshot handed back as the live map object would follow the later write, and the + // compensation could no longer tell what the gate held before the mutation. + expect(snapshot.modeApiConfigs).toEqual({ code: "selected-id" }) + expect(await providerSettingsManager.getModeConfigId("code")).toBe("other-id") + }) + + it("reads the gate without storing, so the read adds no write path", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "selected-profile", + apiConfigs: { selected: { id: "selected-id" } }, + modeApiConfigs: { code: "selected-id" }, + migrations: { rateLimitSecondsMigrated: false }, + }), + ) + const storeCallsBefore = mockSecrets.store.mock.calls.length + + await providerSettingsManager.getSelectionAndModeConfigs() + + expect(mockSecrets.store.mock.calls.length).toBe(storeCallsBefore) + }) + + it("should throw error if reading the gate fails", async () => { + mockSecrets.get.mockRejectedValue(new Error("Storage failed")) + + await expect(providerSettingsManager.getSelectionAndModeConfigs()).rejects.toThrow( + "Failed to read the profile selection and mode configs", + ) + }) + }) describe("syncCloudProfiles", () => { it("should add new cloud profiles without secret keys", async () => { const existingConfig: ProviderProfiles = { diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 6c66655325..d12f13017a 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" }]) @@ -1017,6 +1108,237 @@ describe("importExport", () => { expect(mockProvider.settingsImportedAt).toBeUndefined() }) + it("should call broadcastResetToAllInstances after successful import when available", 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), + 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" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + // The broadcast is held open until the test releases it: recording the call + // synchronously would let an import that never awaits the broadcast reach + // the state post before the assertion runs, which is the failure this test + // claims to catch. + const broadcastGate = createDeferred() + const callOrder: string[] = [] + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockImplementation(async () => { + callOrder.push("post") + }), + broadcastResetToAllInstances: vi.fn().mockImplementation(async () => { + callOrder.push("broadcast") + await broadcastGate.promise + }), + } + + const importRun = importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + // While the broadcast is still in flight, the initiating provider's webview + // post must not have run yet. Asserting in the middle of the await proves + // the ordering happens during the call, not just in the final call order. + await vi.waitFor(() => { + expect(callOrder).toEqual(["broadcast"]) + }) + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + + broadcastGate.resolve() + await importRun + + // The broadcast clears the durable view state, so the initiating provider's + // webview post must run after it β€” otherwise the webview keeps the stale + // pre-import per-view mode/profile. + 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(() => {}) + + 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"), + ) + 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(() => {}) + + 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() + + consoleWarnSpy.mockRestore() + }) + it("should handle multiple profiles with mixed valid and invalid providers", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 9882039ad3..e747a472bb 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) { @@ -387,7 +396,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 b911479bc6..1cd03ef422 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -908,10 +908,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) @@ -2629,8 +2636,40 @@ 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, + ) + // A timed-out switch keeps its run-to-completion mutation queued and can still + // change this task's mode and profile. Wait for it to settle before delivering + // the message, or message processing runs against state the switch moves later. + await provider.awaitProfileMutationSettled?.() + } } if (providerProfile) { @@ -4340,7 +4379,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: equivalent mutant, only tool_use and mcp_tool_use entries carry an id, so the type check cannot change entry.id === event.id (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4393,7 +4431,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: equivalent mutant, event.name is always a string here, so the legacy nameless branch is unreachable if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4410,7 +4447,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: equivalent mutant, streamingKey is always a string here (makeStreamingKey), so the null side is unreachable streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4419,7 +4455,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check 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. @@ -4838,7 +4873,7 @@ 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: equivalent mutant, finalizeRawChunks emits end events with a string name, so the ?? event.id fallback is unreachable + let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4855,13 +4890,12 @@ 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: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline + 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 c1ee5a0b13..89052dd828 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -39,6 +39,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" @@ -60,6 +61,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, @@ -3661,6 +3665,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), @@ -4209,8 +4214,19 @@ 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) + // The switch applies (the mock below writes _taskMode), so the success + // path must not log a not-applied warning. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + 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, @@ -4219,7 +4235,12 @@ 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") + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("was not applied")) vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") const stream = (async function* () { yield { type: "text", text: "response" } as ApiStreamChunk @@ -4231,8 +4252,243 @@ 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") + warnSpy.mockRestore() + }) + + 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(() => {}) + + // Deferred settlement: the test asserts the in-flight state (nothing delivered + // while the queued mutation is still settling) instead of racing a resolved mock. + let releaseSettle!: () => void + const settlePending = new Promise((resolve) => { + releaseSettle = resolve + }) + const settleSpy = vi.fn(() => settlePending) + Object.assign(mockProvider, { awaitProfileMutationSettled: settleSpy }) + const responseOrder: string[] = [] + handleResponseSpy.mockImplementation(() => { + responseOrder.push("delivered") + }) + + const submitting = task.submitUserMessage("still delivered", undefined, "code") + // Flush pending microtasks until the delivery path reaches the deferred settle: the + // message must not be delivered while settlement is pending. + for (let turn = 0; turn < 50 && settleSpy.mock.calls.length === 0; turn++) { + await Promise.resolve() + } + expect(settleSpy).toHaveBeenCalledOnce() + expect(responseOrder).toEqual([]) + + releaseSettle() + await submitting + + // The mode-switch rejection is caught and logged locally... + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[Task#submitUserMessage] Mode switch to code failed (taskId=${task.taskId}):`, + switchError, + ) + // ...the queued run-to-completion mutation is awaited before the message is + // delivered, so a timed-out switch cannot move the task's mode mid-delivery. + expect(settleSpy).toHaveBeenCalledOnce() + expect(responseOrder).toEqual(["delivered"]) + // ...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 () => { 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 fd8e939759..b87c3a2892 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, @@ -168,8 +170,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( @@ -187,7 +189,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.") }) @@ -247,7 +249,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) }) @@ -306,7 +308,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.", ) @@ -328,17 +330,76 @@ 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(), + toolCallId: "test-call-id", + } + const block = createBlock({ mode_slug: "architect", reason: "test" }) + + 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 6cec3bdbe7..ef7ad52c20 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -8,6 +8,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import delay from "delay" import axios from "axios" import debounce from "lodash.debounce" +import deepEqual from "fast-deep-equal" import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { @@ -31,6 +32,7 @@ import { type GlobalState, type ProviderName, type ProviderSettings, + type ProviderSettingsWithId, type RooCodeSettings, type ProviderSettingsEntry, type StaticAppProperties, @@ -74,6 +76,7 @@ import { isRetiredProvider, modelIdKeys, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -132,7 +135,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi import { ContextProxy } from "../config/ContextProxy" import { withoutInvalidDisplaySettings } from "../config/displaySettings" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -160,6 +163,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 @@ -248,6 +259,11 @@ 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< string, @@ -387,6 +403,48 @@ export class ClineProvider return callerResult } + /** + * Deletes a custom mode through the provider-profile mutation queue. Without it a queued + * mode switch can read the mode list, wait while this deletion removes the slug, and then + * persist it - deletion would bypass the queue that orders exactly those two mutations. + * The switch's own re-checks cover the reverse order (a deletion that lands while the + * switch is executing). + */ + public async deleteCustomMode(slug: string): Promise { + return this.enqueueProviderProfileMutation( + async () => this.customModesManager.deleteCustomMode(slug), + // The deletion must run to completion: a timeout that advanced the queue while the + // mode files were being rewritten would let the next switch read a partial list. + { runToCompletion: true }, + ) + } + + /** + * Resolves once the currently queued provider-profile mutation (and everything queued + * before it) has settled. A caller that timed out on a run-to-completion mutation waits + * on this before acting on state that mutation can still change. + */ + public awaitProfileMutationSettled(): Promise { + return this.providerProfileMutationQueue.then( + () => undefined, + () => undefined, + ) + } + + /** + * Whether a mode slug still resolves, as read inside a queued mutation. Built-in slugs are + * answered without a store read: they cannot be deleted, so a queued callback does not need + * to re-read the custom-mode list for them (a custom mode shadowing a built-in keeps + * resolving to the built-in once the custom definition is gone). + */ + private async modeSlugResolves(slug: Mode): Promise { + if (getModeBySlug(slug)) { + return true + } + + return !!getModeBySlug(slug, await this.customModesManager.getCustomModes()) + } + private withProviderProfileMutationTimeout(operation: Promise, onTimeout: () => void): Promise { let timeoutId: ReturnType | undefined const timeout = new Promise((_, reject) => { @@ -415,6 +473,71 @@ 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. + */ + // #1562 seam: `loadViewState(): Promise` and its `return true` sites are this + // unit's delivery signal - true means the load ran or had nothing to load, false means + // the read failed and the caller must roll the registered id back so a retry can run. + // `wroteUnderTemporaryViewStateId` is main's authorship flag, rehydrated at the top of + // disposeInternal() below: a view that persisted state under its temporary id and then + // disposed before registering a stable id still has to load that entry, and #1562's + // early-return-true guard is what lets that load run instead of skipping it. + 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">() + + /** + * Bumped whenever durable and in-memory view state are cleared as a unit (reset and + * import broadcasts clear every live buffer through _clearViewLocalState). An + * in-flight loadViewState captures it and abandons its assignment when the value has + * moved, so a load that read pre-reset persisted entries cannot resurrect selections + * a reset already cleared - the field-mutation tracking only covers field writes. + */ + private viewStateLoadGeneration = 0 + + /** + * 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 + } + + /** + * This view's pinned mode slug from the view-local buffer, or undefined when the view + * follows the shared selection. Exposed so a custom-mode deletion can key the reset + * onto the view it actually applies to instead of moving every view's pin. + */ + get pinnedModeName(): string | undefined { + return this.viewLocalState.mode + } public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "nov-2026-v3.96.0-settings-costs-reliability" // v3.96.0 chat settings, provider costs, and reliability @@ -430,6 +553,11 @@ 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, @@ -463,6 +591,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) => { @@ -599,6 +730,594 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy. + * When fresh is set, the map is read directly from globalState (bypassing the + * ContextProxy cache) so serialized writes never observe a stale in-memory value. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return 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 + } + + try { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected write leaves the cache claiming an + // entry that never reached disk. Restore the cache to the value durable + // storage actually holds (undefined when the key was never written); the + // cache update lands even if this compensating write also fails, so + // readers cannot observe the lost entry either way. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + // 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] + try { + await this.contextProxy.setValue("viewStates", states) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected write leaves the cache missing an + // entry that durable storage still holds. Restore the cache to the + // value storage actually holds (undefined when the key was never + // written); the cache update lands even if this compensating write + // also fails, so readers cannot observe the loss either way. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + }) + + 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) { + try { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache + // before the durable write: restore the cache to the value durable + // storage actually holds so closed-view pins cannot serve re-pointed + // names that never reached disk, then let the caller compensate. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + } + }) + + 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 + } + + try { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected re-key would leave the cache serving + // the stable entry while durable storage still holds the temporary one. + // Restore the cache to the value durable storage actually holds (the same + // rollback savePersistedViewState applies), so a later load or a retry of + // the registration sees the pre-rekey entry. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + + // 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) + + // loadViewState logs and swallows its own read failures, so a false return is the + // only signal that the load did not happen: roll the id back here too, or a retry + // with the same id would early-return on the guard above and the view would never + // reload its pinned mode/profile. + if (!(await this.loadViewState())) { + this.viewStateId = previousViewStateId + } + } 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. + * @returns true when the load ran or had nothing to load, false when a read failed and the + * persistence error was swallowed here (the caller must then roll back the id it registered). + */ + 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. + // #1562 seam: loadViewState returns whether the load delivered - true when it ran + // or had nothing to load, false only when the read failed and swallowed the error, so + // a false return is the caller-only signal that the registered id must roll back. + const loadedForViewId = this.viewStateId + + // Capture the clear generation too: a reset/import that clears view state as a + // unit does not change the id and is not a field mutation, so only the + // generation reveals that this load's reads are already stale. + const loadedForGeneration = this.viewStateLoadGeneration + + // 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 true + } + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + // Snapshot the in-memory buffer before the async profile lookup. The + // mutation paths update viewLocalState in place, so a shallow copy is + // what makes fields mutated during the load window observable below. + const preLoadBuffer = { ...this.viewLocalState } + + // 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.viewStateLoadGeneration !== loadedForGeneration) { + // A newer id owns this provider now: the load was superseded, not failed, so the + // caller must keep the id the newer registration set. + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return true + } + // #1562 seam: superseded is not failed. A newer registration owns the id now, so + // this load counts as delivered and the caller keeps the id that newer call set. + + // Reapply the buffer fields that changed while the load was in flight, + // tracking the change instead of testing against undefined: a field cleared + // during the load window must stay cleared (the loaded value must not + // resurrect it), and a field written to a new value must win over it. + // Untouched fields keep the persisted values authoritative, and the + // pre-load buffer is never merged wholesale so stale temporary-id state + // cannot override the stable persisted state. + const postLoadBuffer = this.viewLocalState + const mergedState: Partial = { ...loadedState } + + if (!Object.is(preLoadBuffer.mode, postLoadBuffer.mode)) { + if (postLoadBuffer.mode === undefined) { + delete mergedState.mode + } else { + mergedState.mode = postLoadBuffer.mode + } + } + + if (!Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName)) { + if (postLoadBuffer.currentApiConfigName === undefined) { + delete mergedState.currentApiConfigName + } else { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } + } + + if (!Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration)) { + if (postLoadBuffer.apiConfiguration === undefined) { + delete mergedState.apiConfiguration + } else { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } + } + // A mutation recorded inside the load window that left the buffer holding the value + // it already held - a clear of an unset field, or a write of the same value - is + // invisible to the buffer comparison above, yet it is still a completed mutation + // and outranks the persisted values this read. Drop the loaded field in that case. + if (this.viewLocalStateMutatedFields.has("mode") && postLoadBuffer.mode === undefined) { + delete mergedState.mode + } + if ( + this.viewLocalStateMutatedFields.has("currentApiConfigName") && + postLoadBuffer.currentApiConfigName === undefined + ) { + delete mergedState.currentApiConfigName + } + if ( + this.viewLocalStateMutatedFields.has("apiConfiguration") && + postLoadBuffer.apiConfiguration === undefined + ) { + delete mergedState.apiConfiguration + } + + // The profile name can move during the load window: the settings above were resolved for the + // name the persisted record pinned, so when a different name was reapplied mid-load (a + // profile switch while this view was starting) they must be re-resolved for the name the + // view actually holds - getState() pairs the two and createTask hands that pair to the new + // task, which also records the name. Dropping the settings instead is not safe: the view + // would fall back to shared settings that can belong to another profile. + // Only a name that moved inside the load window needs a re-resolve: a pin that already + // held this value before the load started is stale and the persisted record stays + // authoritative for both the name and its settings. + const reappliedProfileName = + !Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName) && + typeof postLoadBuffer.currentApiConfigName === "string" && + postLoadBuffer.currentApiConfigName !== persisted?.currentApiConfigName && + Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration) + ? postLoadBuffer.currentApiConfigName + : undefined + + if (reappliedProfileName) { + try { + const { name: _name, ...reappliedSettings } = await this.providerSettingsManager.getProfile({ + name: reappliedProfileName, + }) + mergedState.apiConfiguration = reappliedSettings as ProviderSettings + } catch (error) { + // The reapplied name no longer resolves: keep the name and let the view fall back to + // the shared store rather than pairing it with a different profile's settings. + this.log( + `[loadViewState] Unable to resolve API profile '${reappliedProfileName}' reapplied during the load for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + delete mergedState.apiConfiguration + } + + if (this.viewStateId !== loadedForViewId) { + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return true + } + } + + this.viewLocalState = mergedState + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + return true + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + return false + } + } + + /** + * 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) + + // The saved value is rendered only up to a bound: view-local values span user-authored + // settings, and this diagnosability line must never dump arbitrary-length state. + this.log( + `[saveViewState] Saved ${String(key)}=${typeof value === "string" ? `${value.slice(0, 40)}${value.length > 40 ? "..." : ""}` : value === undefined ? "unset" : typeof value} for viewId ${this.viewId}`, + ) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -950,65 +1669,162 @@ export class ClineProvider return } + // Set before the first await: every re-entrant call, guard and delivery check must + // see the provider as closing for the whole teardown window. this._disposed = true + + try { + await this.disposeInternal() + } finally { + // Released here rather than in the teardown body: a pending webview diagnostics + // request holds a resolver and a timeout that close over this provider, so a view that + // goes away mid-request must release them even when an earlier cleanup step threw. + this.diagnosticsRequestBroker.dispose() + + // Deregistration must not depend on the teardown completing: a failing task + // eviction, a throwing disposable or a rejecting MCP client would otherwise + // leave this provider registered while _disposed is already set, and the early + // return above means no later dispose() could repair the registry. + ClineProvider.activeInstances.delete(this) + + // Clean up any event listeners attached to this provider + this.removeAllListeners() + + McpServerManager.unregisterProvider(this) + } + } + + /** + * Teardown body for {@link dispose}. Kept separate so every step here - task eviction + * included - runs under the caller's finally, which owns the deregistration. + */ + private async disposeInternal(): Promise { this._postStateToWebviewThrottled.cancel() this.stopWebviewWatchdog() this.diagnosticsRecorder.record({ boundary: "provider", phase: "start", type: "dispose" }) this.log("Disposing ClineProvider...") + // A disposed provider will never register a stable view state id: release + // any API-driven task creation that is awaiting viewStateReadiness. + this.viewStateReadinessResolve() + // Reject any tasks still waiting for a scheduler permit so they don't // hold the event loop after the provider is torn down. this.taskScheduler.cancelQueued() + // Cleanup steps (task eviction, MCP/skills/marketplace teardown) can + // reject. _disposed is already set, so no caller can retry disposal: + // each step runs independently so one failure cannot skip the later + // release steps, the first error is preserved, and it is reported once + // every step has run. + let cleanupError: unknown + + const cleanupStep = async (label: string, fn: () => unknown | Promise) => { + // #1562 seam: the constructor's `void this.loadViewState()` (construction-time hydrate) + // is the other #1562 consumer of the delivery signal; it has no caller to report to, so + // a false return there is intentionally dropped. Disposal does not re-hydrate: there is + // no live view left to deliver to, and a load started here would mutate the view-local + // state while the teardown is already running. + try { + await fn() + } catch (error) { + if (cleanupError === undefined) { + cleanupError = error + } + this.log( + `Provider cleanup step '${label}' failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // Clear all tasks from the stack. The first pop goes through evictCurrentTask() // so an active delegated child is marked interrupted before the extension shuts down, // rather than being left persisted as "active" across the reload. - if (this.taskRegistry.length > 0) { - const task = this.taskRegistry.current! - await this.evictCurrentTask() - await this.drainTaskDisposal(task) - } - while (this.taskRegistry.length > 0) { - const task = this.taskRegistry.current! - await this.removeClineFromStack() - await this.drainTaskDisposal(task) - } - - this.log("Cleared all tasks") + await cleanupStep("tasks", async () => { + if (this.taskRegistry.length > 0) { + const task = this.taskRegistry.current! + await this.evictCurrentTask() + await this.drainTaskDisposal(task) + } + while (this.taskRegistry.length > 0) { + const task = this.taskRegistry.current! + await this.removeClineFromStack() + await this.drainTaskDisposal(task) + } + this.log("Cleared all tasks") + }) // Clear all pending edit operations to prevent memory leaks - this.clearAllPendingEditOperations() - this.log("Cleared pending operations") + await cleanupStep("pendingEdits", () => { + this.clearAllPendingEditOperations() + this.log("Cleared pending operations") + }) - if (this.view && "dispose" in this.view) { - this.view.dispose() - this.log("Disposed webview") - } + await cleanupStep("view", () => { + if (this.view && "dispose" in this.view) { + this.view.dispose() + this.log("Disposed webview") + } + }) - this.clearWebviewResources() + await cleanupStep("webviewResources", () => this.clearWebviewResources()) - // Clean up cloud service event listener - if (CloudService.hasInstance()) { - CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) - } - - this._workspaceTracker?.dispose() - this._workspaceTracker = undefined - await this.mcpHub?.unregisterClient() - this.mcpHub = undefined - await this.skillsManager?.dispose() - this.skillsManager = undefined - await this.marketplaceManager?.cleanup() - this.customModesManager?.dispose() - this.taskHistoryStore.dispose() - this.log("Disposed all disposables") - ClineProvider.activeInstances.delete(this) - this.diagnosticsRequestBroker.dispose() + await cleanupStep("cloudService", () => { + // Clean up cloud service event listener + if (CloudService.hasInstance()) { + CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) + } + }) - // Clean up any event listeners attached to this provider - this.removeAllListeners() + await cleanupStep("disposables", () => { + while (this.disposables.length) { + const x = this.disposables.pop() + if (x) { + // Isolate individual disposables: one throwing disposable must not + // strand the rest of the list. + try { + x.dispose() + } catch (error) { + if (cleanupError === undefined) { + cleanupError = error + } + this.log( + `Provider cleanup step 'disposable' failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + }) - McpServerManager.unregisterProvider(this) + await cleanupStep("workspaceTracker", () => { + this._workspaceTracker?.dispose() + this._workspaceTracker = undefined + }) + + await cleanupStep("mcpHub", async () => { + await this.mcpHub?.unregisterClient() + this.mcpHub = undefined + }) + + await cleanupStep("skillsManager", async () => { + await this.skillsManager?.dispose() + this.skillsManager = undefined + }) + + await cleanupStep("marketplace", () => this.marketplaceManager?.cleanup()) + + await cleanupStep("customModesManager", () => this.customModesManager?.dispose()) + + await cleanupStep("taskHistoryStore", () => this.taskHistoryStore.dispose()) + + this.log("Disposed all disposables") + // The releases themselves live in dispose's finally, not here: the body used to duplicate + // all four after this log, so every dispose ran the broker release, the listener removal + // and the MCP unregistration twice. + + if (cleanupError !== undefined) { + throw cleanupError + } } public get isViewVisible(): boolean { @@ -1023,6 +1839,21 @@ 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 { + // A provider that is already disposing must not be handed out: dispose() sets + // _disposed before it awaits task cleanup, and during that window the view is still + // registered while every postMessageToWebview on it is dropped. A caller that + // reveals the view and posts didBecomeVisible would therefore have no visible + // effect, and the user's next click would open a second tab. + return Array.from(this.activeInstances).find((instance) => instance.view === view && !instance._disposed) + } + public static async getInstance(): Promise { let visibleProvider = ClineProvider.getVisibleInstance() @@ -1485,7 +2316,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, @@ -1722,28 +2556,34 @@ export class ClineProvider return } - try { - const delivered = await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) - this.diagnosticsRecorder.record({ - boundary: "webview-out", - phase: delivered ? "success" : "failure", - type: message.type, - action: message.action, - elapsedMs: Date.now() - startedAt, - stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, - taskId, + 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))) + .then((delivered) => { + // VS Code resolves `false` when the message was not delivered (the page was + // torn down or never finished loading). That is a drop just like a rejection, + // so it is logged too instead of losing state updates silently. + if (delivered === false) { + this.log(`[postMessageToWebview] undelivered message type=${message.type}`) + } }) - } catch { - this.diagnosticsRecorder.record({ - boundary: "webview-out", - phase: "failure", - type: message.type, - action: message.action, - elapsedMs: Date.now() - startedAt, - taskId, + .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)}`, + ) }) - // View disposed, drop message silently - } } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -2042,10 +2882,30 @@ 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()) { - return this.enqueueProviderProfileMutation((signal) => - this.handleModeSwitchUnlocked(newMode, targetTask, signal), + return this.enqueueProviderProfileMutation( + async (signal) => { + // The existence check runs inside the queued callback, not before the switch joins + // the queue: a custom mode can be deleted while this switch waits its turn, and a + // pre-queue check would then pin a slug that no longer resolves to the view, the + // shared mode and the task history. + if (!(await this.modeSlugResolves(newMode))) { + this.log(`[ClineProvider#handleModeSwitch] ignoring unknown mode "${newMode}"`) + return + } + + return this.handleModeSwitchUnlocked(newMode, targetTask, signal) + }, + // The switch and its nested activation must own the queue until every + // write - including compensation - settles: a timeout must not let the + // next queued mutation overlap writes that are still in flight. + { runToCompletion: true }, ) } @@ -2056,9 +2916,28 @@ 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) + + // Captured above the try so the catch below can undo a history write that already landed. + // The emit on the persisted new mode runs after that write, and a listener that throws there + // must not leave task history on the new mode while _taskMode and the view's durable pin + // still hold the old one. + let undoHistoryWrite: (() => Promise) | undefined try { // Update the task history with the new mode first. @@ -2066,87 +2945,337 @@ export class ClineProvider if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) + + undoHistoryWrite = async () => { + // Restore only the field this switch changed, re-reading the item so fields the running + // task persisted during the pending window survive the restore. + const latest = this.getTaskHistoryItem(task.taskId) ?? taskHistoryItem + await this.updateTaskHistory({ ...latest, mode: taskHistoryItem.mode }) + } + + // 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 + // Listeners were told about the new mode before the failure landed: re-emit + // with the restored mode so API/IPC consumers track the rollback. + if (previousTaskMode) { + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, 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( `Failed to persist mode switch for task ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`, ) + // The history write landed before this failure - the emit above is the reachable + // thrower now that the history write runs first - so undo it: without the undo, the + // persisted item keeps a mode the task, its view pin and its profile never took on. + // A failed undo is logged; the original failure is what the caller sees. + if (undoHistoryWrite) { + try { + await undoHistoryWrite() + } catch (rollbackError) { + this.log( + `Failed to roll back the task history entry after a failed mode switch: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + // Optionally, we could emit an event to notify about the failure. // This ensures the in-memory state remains consistent with persisted state. throw error } } - 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 + } - this.emit(RooCodeEventName.ModeChanged, newMode) + // The task writes above await (the history write first), so a custom mode can be deleted + // inside that window even while this switch owns the queue. Re-check before the first + // durable write so a slug that no longer resolves is never pinned to the view or written + // to the shared mode, and undo the task-side writes that already landed. + if (!(await this.modeSlugResolves(newMode))) { + this.log(`[ClineProvider#handleModeSwitch] ignoring mode "${newMode}" deleted while the switch was pending`) - // 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) { - if (targetTask !== null) { - await this.postStateToWebview() + if (rollbackTaskWrites) { + try { + await rollbackTaskWrites() + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back task mode writes after the mode was deleted: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } } + return } - 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 + // Captured for the reconcile below: the view's pre-switch durable mode pin + // (undefined means the view never pinned a mode) and the pre-switch shared + // mode. The pin write only touches the view buffer and the per-view store, so + // reading the shared value here is unaffected by it. + const previousViewMode = this.viewLocalState.mode + const previousMode = this.getValue("mode") + let pinLanded = false + let sharedAttempted = false + + // Explicit partial-failure behavior: every failure after the task-mode write + // rolls back all writes this switch owns - shared mode, view pin, task mode, + // and the task-history entry - so the caller (Task#submitUserMessage) delivers + // the submitted message on the pre-switch mode and profile instead of a + // half-applied switch that runs the new mode against the unchanged profile. + // Each restore is awaited on its own and logged on failure, never swallowed. + const reconcileSwitchWrites = async () => { + if (sharedAttempted) { + // The shared write may have landed before its promise rejected: restore + // it unconditionally so getValues() cannot mix a fresh shared mode with + // the rolled-back view state. + 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) + }`, + ) + } + } + if (pinLanded) { + try { + await this.saveViewState("mode", previousViewMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back the view mode pin after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + 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) + }`, + ) + } + } + } - // Load the saved API config for the new mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) - const listApiConfig = await this.providerSettingsManager.listConfig() + // Once the mode-linked activation below has started writing shared state, a + // mode-only rollback would pair the old mode with the new profile: the catch must + // then keep the switch instead of reconciling. + let activationStarted = false + try { + if (viewScopedSwitch) { + // Pin the view's durable per-view mode first: the pin is this view's own + // selection. If any write below fails, the reconcile above restores every + // write that landed, including this pin, so a delivered message never runs + // the new task mode against an unchanged view mode or mode-linked profile. + await this.saveViewState("mode", newMode) + pinLanded = true + + // 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. + // Mark the shared write as attempted only once it resolved: a rejection + // is already compensated inside setValue (which restores the prior + // shared value before rethrowing), and reconciling again here would + // replay the same restore. A resolved write, by contrast, must be + // restored by the reconcile when a later write fails. + await this.setValue("mode", newMode) + sharedAttempted = true + } - if (signal?.aborted) return + // If workspace lock is on, keep the current API config: the mode-linked + // activation below is skipped, and the committed switch falls through to the + // emit/post tail outside the reconciled region. + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (!lockApiConfigAcrossModes) { + // A background-task switch applies to the task only: no view pin, no + // ModeChanged, and no mode-linked activation on its behalf. + if (!viewScopedSwitch) { + return + } - // Update listApiConfigMeta first to ensure UI has latest data. - await this.updateGlobalState("listApiConfigMeta", listApiConfig) + // Load the saved API config for the new mode if it exists. + const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const listApiConfig = await this.providerSettingsManager.listConfig() - // If this mode has a saved config, use it. - if (savedConfigId) { - const profile = listApiConfig.find(({ id }) => id === savedConfigId) + // The pin and shared mode writes above have committed the mode: an abort + // here skips the remaining activation work but falls through to the + // emit/post tail so listeners and the webview learn the committed mode. + if (!signal?.aborted) { + await this.updateGlobalState("listApiConfigMeta", listApiConfig) - if (profile?.name) { - // Check if the profile has actual API configuration (not just an id). - // In CLI mode, the ProviderSettingsManager may return empty default profiles - // that only contain 'id' and 'name' fields. Activating such a profile would - // overwrite the CLI's working API configuration with empty settings. - // Skip activation if the profile has no apiProvider set - this indicates - // an unconfigured/empty profile. - const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) - const hasActualSettings = !!fullProfile.apiProvider - - if (hasActualSettings) { - await this.activateProviderProfileUnlocked( - { name: profile.name }, - targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, - signal, - ) - } else { - // The task will continue with the current/default configuration. - } - } else { - // The task will continue with the current/default configuration. - } - } else { - // If no saved config for this mode, save current config as default. - const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") + // If this mode has a saved config, use it. + if (savedConfigId) { + const profile = listApiConfig.find(({ id }) => id === savedConfigId) + + if (profile?.name) { + // Check if the profile has actual API configuration (not just an id). + // In CLI mode, the ProviderSettingsManager may return empty default profiles + // that only contain 'id' and 'name' fields. Activating such a profile would + // overwrite the CLI's working API configuration with empty settings. + // Skip activation if the profile has no apiProvider set - this indicates + // an unconfigured/empty profile. + const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) + const hasActualSettings = !!fullProfile.apiProvider + + if (hasActualSettings) { + // The committed-switch guard flips only when the activation has + // actually written the activated profile: a lookup failure (e.g. a + // deleted profile) throws before any write and must still reconcile. + await this.activateProviderProfileUnlocked( + { name: profile.name }, + { + ...(targetTask === null ? { skipCurrentTaskRebuild: true } : {}), + onSharedWriteStart: () => { + activationStarted = true + }, + }, + signal, + ) + } else { + // The task will continue with the current/default configuration. + } + } else { + // The task will continue with the current/default configuration. + } + } else { + // If no saved config for this mode, save current config as default. + // Use this view's effective profile: with per-view pins the shared slot can + // belong to a sibling view, and recording that profile as this mode's default + // would activate the wrong profile for every later switch to it. + const currentApiConfigNameAfter = this.getValues().currentApiConfigName - if (currentApiConfigNameAfter) { - const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) + if (currentApiConfigNameAfter) { + const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) - if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) + if (config?.id) { + await this.providerSettingsManager.setModeConfig(newMode, config.id) + } + } + } + } + } + } catch (error) { + // Two reasons to keep the switch instead of reconciling: once the mode-linked + // activation has started writing shared state, a mode-only rollback would pair + // the old mode with the new profile; and after a timeout abort the queue has + // advanced, so a newer mutation may own these fields and replaying the + // pre-switch values would clobber it. + if (!activationStarted && !signal?.aborted) { + await reconcileSwitchWrites() + } else if (signal?.aborted) { + // After an abort the mutation queue has advanced: a newer mutation may + // already own the mode fields, so emitting here could announce a mode the + // newer mutation replaced - listeners are intentionally not notified on + // this path. + this.log(`[handleModeSwitch] Skipping rollback for aborted switch to "${newMode}"`) + } else { + // The activation had already written the activated profile: the switch is + // kept. Listeners must still learn the committed mode before the rethrow, + // and the webview must refresh in case the activation failed before its + // own state post. + if (viewScopedSwitch) { + this.emit(RooCodeEventName.ModeChanged, newMode) + } + if (targetTask !== null) { + try { + await this.postStateToWebview() + } catch (postError: unknown) { + this.log( + `[handleModeSwitch] Mode switch to "${newMode}" kept but the state post failed: ${ + postError instanceof Error ? postError.message : String(postError) + }`, + ) + } } } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + + // The switch has committed: notify listeners only now, so a reconciled failure + // never leaves listeners tracking a mode the stores rolled back from. After an + // abort the mutation queue has advanced: announce only while this switch's + // committed mode is still current - a superseded commit stays silent because + // the newer mutation already announced its own mode. + if (signal?.aborted && this.getValues().mode !== newMode) { + return + } + if (viewScopedSwitch) { + this.emit(RooCodeEventName.ModeChanged, newMode) } + // The switch has committed: a UI-refresh failure must not roll it back, so the + // trailing post runs outside the reconciled region and its failure is only logged. if (targetTask !== null) { - await this.postStateToWebview() + try { + await this.postStateToWebview() + } catch (postError: unknown) { + this.log( + `[handleModeSwitch] Mode switch to "${newMode}" committed but the state post failed: ${ + postError instanceof Error ? postError.message : String(postError) + }`, + ) + } } } @@ -2334,15 +3463,80 @@ export class ClineProvider // we rely on the `ContextProxy`'s data store and in other cases // we rely on the `ProviderSettingsManager`'s data store. It might // be simpler to unify these two. - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) + // Snapshot the prior manager record before the save so a failed post-save + // read-back can compensate the landed write instead of leaving the record + // changed while the shared store was not. + let previousRecord: Awaited> | undefined + try { + previousRecord = await this.providerSettingsManager.getProfile({ name }) + } catch (error) { + // getProfile wraps a missing record and a storage read failure in the same plain + // Error, so absence is classified with hasConfig instead of by message text. A read + // failure that is not absence must not be mistaken for a create: the compensation + // below would then deleteConfig a profile that exists and take its credentials with + // it. Fail closed before saveConfig runs; a failing hasConfig fails closed too. + if (await this.providerSettingsManager.hasConfig(name)) { + throw error + } + } + + // TODO: Do we need to be calling `activateProfile`? It's not + // clear to me what the source of truth should be; in some cases + // we rely on the `ContextProxy`'s data store and in other cases + // we rely on the `ProviderSettingsManager`'s data store. It might + // be simpler to unify these two. + // The profile rename holds the manager's cross-instance critical section across its + // name check, its save, and its compensation delete. Only the per-instance mutation + // queue orders this view's writes, so a same-name save from another view can land + // between that rename's check and its save and then be deleted by its compensation. + // Joining the section here puts both writers behind one lock; only the store write + // is wrapped, so the shared lock is never held across the activation work below. + const id = await this.providerSettingsManager.runExclusiveAcrossInstances(() => + this.providerSettingsManager.saveConfig(name, providerSettings), + ) if (signal.aborted) return id + // saveConfig normalizes the record (schema parse, legacy downgrades): + // read the stored copy back and use it for every activation write, + // sibling refresh, overlay, and task-handler update below, so raw + // webview fields the profile schema strips cannot enter the shared + // store, sibling view state, or task configuration. Fail closed when + // the read-back fails: the raw caller payload must never stand in for + // the validated record, so the landed save is compensated and the + // mutation group fails instead of propagating unvalidated settings. + let normalizedSettings: ProviderSettings + try { + const { + name: _savedName, + id: _savedId, + ...storedRecord + } = await this.providerSettingsManager.getProfile({ name }) + normalizedSettings = storedRecord as ProviderSettings + } catch (error) { + // Fail closed: compensate the landed save (restore the prior record, + // or delete a landed create) and fail the group so the raw caller + // payload never stands in for the validated stored record. + try { + if (previousRecord) { + const { name: _previousName, id: _previousId, ...previousSettings } = previousRecord + await this.providerSettingsManager.saveConfig(name, previousSettings) + } else { + await this.providerSettingsManager.deleteConfig(name) + } + } catch (restoreError) { + this.log( + `[persistProviderProfile] failed to compensate the landed save of '${name}': ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + throw new Error( + `Provider profile '${name}' could not be read back after save: ${error instanceof Error ? error.message : String(error)}`, + ) + } + if (activate) { const { mode } = await this.getState() - if (signal.aborted) return id - // These promises do the following: // 1. Adds or updates the list of provider profiles. // 2. Sets the current provider profile. @@ -2353,12 +3547,27 @@ 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. - // `allSettled` so a caller that rolls back after a failure never races a write still in flight. + 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) + + // `allSettled` so the caller's error path never races a write still in flight. const activationResults = await Promise.allSettled([ - 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), + this.contextProxy.setProviderSettings(normalizedSettings), + // 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 }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", @@ -2366,20 +3575,39 @@ export class ClineProvider if (failedActivation) throw failedActivation.reason - if (signal.aborted) return id + // 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, normalizedSettings) // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) // 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: normalizedSettings }) + // 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(normalizedSettings, { forceRebuild: true }) + } + } + await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) } - if (signal.aborted) return id - await this.postStateToWebview() return id } @@ -2395,16 +3623,291 @@ export class ClineProvider if (!profileToActivate) { throw new Error("You cannot delete the last profile") } - - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) - - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) - - await this.postStateToWebview() + + // Snapshot the manager record and every persistence surface this + // deletion rewrites BEFORE the manager delete commits, so a failure + // inside the write group below can be compensated instead of leaving + // the secrets-backed record gone while metadata and view pins still + // reference it. + let previousRecord: Awaited> | undefined + try { + previousRecord = await this.providerSettingsManager.getProfile({ name: profileToDelete.name }) + } catch (error) { + // Absence is classified with hasConfig, not by message text: getProfile wraps both + // a missing record and a storage read failure in one plain Error. Only an absent + // record may proceed name-only - the idempotent not-found path below still prunes + // the stale list entry, and compensation skips the record restore. Continuing past + // a read failure would delete the secrets-backed record and then skip its restore. + if (await this.providerSettingsManager.hasConfig(profileToDelete.name)) { + throw error + } + } + const previousEntries = this.getProviderProfileEntries() + const previousSelection = globalSettings.currentApiConfigName + const previousSharedSettings = this.contextProxy.getProviderSettings() + const previousViewStates = this.getPersistedViewStates({ fresh: true }) + // The manager owns a gate of its own - the profile its store selects and the mode -> + // profile mapping - and activating the replacement below rewrites both. It lives in the + // secrets-backed profiles file behind the manager lock, so none of the context-gate + // restores in the catch below can reach it: snapshot it here, before the delete commits, + // through the manager’s own read (a copy of the map, not the live object). + const previousManagerGate = await this.providerSettingsManager.getSelectionAndModeConfigs() + // What the activation below writes into that gate: the replacement’s name, and its id as + // the profile list records it (the mode mapping is keyed by id). The restore legs compare + // the manager’s current report against these, and only roll back while they still match. + const activatedManagerGateName = profileToActivate + const activatedManagerGateId = previousEntries.find((entry) => entry.name === profileToActivate)?.id + + // Remove the profile from the settings store (context.secrets) so it cannot be + // resurrected by a later listApiConfigMeta sync. 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 below is still + // pruned as an idempotent success, while any other failure (e.g. refusing to + // delete the last remaining configuration) propagates. 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`, + ) + } + + try { + // Re-point any persisted view pin that referenced the deleted profile so views + // do not rehydrate a missing profile name after a reload. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + + // Whether this deletion has to re-anchor this view onto the replacement. The second leg + // is a view whose own pin names the deleted profile. The first leg is narrower than it + // reads: a view with no pin of its own does not resolve to the deleted profile, it + // follows the shared selection, and the deletion hands that selection to it through + // activation regardless of which profile was removed - the same hand-off the manager + // gate and the current task handler depend on. Measured both deletions: with the shared + // selection pointing at a survivor, deleting an unrelated profile still activates the + // selection for this view and leaves it pinned to it; deleting the profile the selection + // names activates the replacement. Either way what the view serves afterwards is the + // profile the shared store holds. + const viewFollowsDeletedProfile = + this.viewLocalState.currentApiConfigName === undefined || + this.viewLocalState.currentApiConfigName === profileToDelete.name + + if (viewFollowsDeletedProfile) { + // 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: sync the + // shared profile list and post the updated state only. The buffer already holds + // the surviving pin, so the current-profile slot is left untouched here: a + // setValue would only trigger a viewStates prune write and could clobber the pin + // with the shared slot's value. + const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + + // Write only the profile list back: replaying the full settings snapshot + // captured above would also rewrite unrelated keys (including viewStates, + // which ClineProvider mutates directly in storage for concurrent views) + // with this view's stale cached copy. + await this.contextProxy.setValue("listApiConfigMeta", entries) + + // 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's own pin names a surviving profile at this point: a view pinned + // to the deleted profile (or with no pin of its own) took the activation + // path above and returned, so only the shared selection can need re-pointing + // here, and this view's own overlay can be left alone. + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName + + // The selection and provider-key writes below move shared state onto the surviving + // profile. A live sibling pinned to a profile that is neither the deleted one nor the + // replacement, and which has no API overlay of its own, would then report its own pin + // name together with the replacement's settings, because getState() merges the shared + // provider keys under the pin: a task created from that view would run against the + // wrong provider, model and credentials. Capture each such sibling's own profile + // settings into its overlay first, exactly as activation and upsert do before their + // shared writes. + if (deletedWasGlobal) { + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(profileToActivate) + + // The shared selection changed, but this view's own pin 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() + } catch (error) { + // The manager delete committed but a later write failed: restore the + // deleted record first, then the shared metadata, selection, and shared + // settings the deletion had rewritten, and finally merge the affected + // persisted pins back. The merge re-reads the durable map and rewrites + // only entries that named the deleted profile in the pre-delete + // snapshot, so concurrent viewStates updates from sibling views + // survive. Each restore is independent and logged; the original + // failure is what the caller sees. + if (previousRecord) { + try { + // Restore with the record's original id: the record is absent at this point (the + // delete committed), so saveConfig would otherwise mint a new one while the + // mode mappings and task pins still name the original. Only the name is dropped. + const { name: _previousName, ...previousSettings } = previousRecord + await this.providerSettingsManager.saveConfig(profileToDelete.name, previousSettings) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the manager record '${profileToDelete.name}': ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + } + try { + // Merge rather than clobber: another instance may have added + // profiles between the snapshot and the compensation. + const currentEntries = this.getProviderProfileEntries() + const deletedEntry = previousEntries.find((entry) => entry.name === profileToDelete.name) + if (deletedEntry && !currentEntries.some((entry) => entry.name === profileToDelete.name)) { + await this.contextProxy.setValue("listApiConfigMeta", [...currentEntries, deletedEntry]) + } + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the profile list: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + // The manager gate the activation moved onto the replacement. Restored through the same + // write paths the activation used - activateProfile for the selection, setModeConfig for + // each mapping - and only while the manager still reports what this deletion wrote: a + // concurrent activation or a later mutation may own the gate by now, and replaying the + // pre-delete values over it would undo that work. The record and list restores above run + // first so the name and ids being restored still exist. + try { + const currentManagerGate = await this.providerSettingsManager.getSelectionAndModeConfigs() + if ( + previousManagerGate.currentApiConfigName !== activatedManagerGateName && + currentManagerGate.currentApiConfigName === activatedManagerGateName + ) { + await this.providerSettingsManager.activateProfile({ + name: previousManagerGate.currentApiConfigName, + }) + } + const previousModeConfigs = previousManagerGate.modeApiConfigs ?? {} + const currentModeConfigs = currentManagerGate.modeApiConfigs ?? {} + for (const mode of new Set([...Object.keys(previousModeConfigs), ...Object.keys(currentModeConfigs)])) { + const previousModeConfigId = previousModeConfigs[mode] + const currentModeConfigId = currentModeConfigs[mode] + if ( + activatedManagerGateId !== undefined && + currentModeConfigId === activatedManagerGateId && + previousModeConfigId !== currentModeConfigId + ) { + await this.providerSettingsManager.setModeConfig(mode as Mode, previousModeConfigId) + } + } + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the manager selection and mode configs: ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + try { + await this.contextProxy.setValue("currentApiConfigName", previousSelection) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the shared selection: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + await this.contextProxy.setProviderSettings(previousSharedSettings) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the shared provider settings: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const current = this.getPersistedViewStates({ fresh: true }) + const merged = { ...current } + let changed = false + for (const [viewId, entry] of Object.entries(previousViewStates)) { + if (entry?.currentApiConfigName !== profileToDelete.name) { + continue + } + merged[viewId] = entry + changed = true + } + if (changed) { + await this.contextProxy.setValue("viewStates", merged) + } + }) + ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) + await restore + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the persisted view pins: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + throw error + } } private async persistStickyProviderProfileToCurrentTask( @@ -2437,12 +3940,112 @@ 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. + * + * A pin that cannot be READ fails the whole snapshot so the caller does not write the + * shared store: that sibling would otherwise report its pin's name with the activated + * profile's settings and start tasks on them. Siblings already snapshotted keep an overlay + * equal to their own pin's settings, which is what they reported before the aborted write. + * + * The one tolerated failure is a pin naming a profile the store no longer has + * (`ProviderSettingsNotFoundError`): the deletion path re-points those views itself + * (`rePinViewLocalStateForDeletedProfile`), and aborting here would let one stale view + * block every later activation in the host. Same discrimination `deleteProviderProfile` + * applies to its own missing-profile read. + */ + private async snapshotSiblingOverlaysBeforeSharedSettingsWrite( + activatedProfileName: string | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => + instance !== this && + instance.pinnedProfileName !== undefined && + instance.pinnedProfileName !== activatedProfileName && + instance.viewLocalState.apiConfiguration === undefined, + ) + + const unresolvedPins: Array<{ pinName: string; error: unknown }> = [] + + await Promise.all( + affected.map(async (instance) => { + const pinName = instance.pinnedProfileName + // The guard below is a safety net only: the filter predicate above guarantees a + // defined pin for every instance reaching this callback, and the filter-to-guard + // chain is synchronous (no await), so no reachable state trips it. + if (pinName === undefined) { + return + } + + try { + const { name: _siblingPinName, ...settings } = await this.providerSettingsManager.getProfile({ + name: pinName, + }) + // Commit through the sibling's own mutation queue with a pin re-check at + // commit time: the sibling may have activated a different profile (or + // gained a newer overlay) while this snapshot was in flight, and the + // captured settings must never land on top of a newer pin. Direct + // private access: compile-time safe across sibling instances. + await instance["enqueueProviderProfileMutation"](async () => { + if ( + instance.pinnedProfileName !== pinName || + instance.viewLocalState.apiConfiguration !== undefined + ) { + return + } + await instance._saveViewLocalStateFromMutation({ + apiConfiguration: settings as ProviderSettings, + }) + await instance.postStateToWebview() + }) + } catch (error) { + // A pin naming a deleted profile is tolerated: the deletion path re-points that + // view itself, and one stale view must not block every later activation. Any + // other read failure leaves the sibling's settings unknown, and unknown settings + // must not be silently replaced by the profile this call is about to activate - + // so that case fails the mutation before the shared write. + const deletedPin = error instanceof ProviderSettingsNotFoundError + + if (!deletedPin) { + unresolvedPins.push({ pinName, error }) + } + + this.log( + `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ + error instanceof Error ? error.message : String(error) + }${deletedPin ? " (deleted; the deletion path re-points this view)" : ""}`, + ) + } + }), + ) + + if (unresolvedPins.length > 0) { + const firstError = unresolvedPins[0].error + + throw new Error( + `Refusing to overwrite the shared provider settings while pinned profile(s) could not be resolved: ${[ + ...new Set(unresolvedPins.map(({ pinName }) => pinName)), + ].join(", ")} (${firstError instanceof Error ? firstError.message : String(firstError)})`, + ) + } + } + async activateProviderProfile( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + preserveForeignTask?: boolean /** Rebuild and sticky-write only when this task is still current. */ expectedCurrentTaskId?: string }, @@ -2458,25 +4061,86 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + preserveForeignTask?: boolean + // Fires once the activation has written the activated profile to shared + // state, so callers can mark the switch committed at the true point of no + // return (a profile lookup failure must stay reconcilable). + onSharedWriteStart?: () => void expectedCurrentTaskId?: string }, signal?: AbortSignal, ): Promise { - const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) - - if (signal?.aborted) return - const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false + if (!skipCurrentTaskRebuild) { + // 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. This runs before the manager writes the activated + // profile on purpose: a pin whose record cannot be read aborts the write, and + // aborting after activateProfile would leave the store on the new profile while + // a mode switch that already passed onSharedWriteStart keeps the new mode + // instead of restoring the pair, so mode and provider would disagree. + // For an activation by id the resolved name comes from the manager's own + // listing, which the shared write below reads as well. + let activatedProfileName: string + if ("name" in args) { + activatedProfileName = args.name + } else { + const listedProfiles = await this.providerSettingsManager.listConfig() + activatedProfileName = listedProfiles.find((entry) => entry.id === args.id)?.name ?? args.id + } + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(activatedProfileName) + } + + const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) + + // The manager has now written the activated profile: from here on a failure + // must keep the switch rather than roll the mode back onto the old profile. + options?.onSharedWriteStart?.() + + if (signal?.aborted) return + if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. - await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), - this.contextProxy.setProviderSettings(providerSettings), - ]) + 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) + + try { + await Promise.all([ + 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 }), + ]) + } catch (error) { + // The manager has already written the activated profile, so the caller keeps the + // switch and delivers its pending message now. The task must run the activated + // profile's settings: without this rebuild the handler still belongs to the profile + // the task started on, and the message goes out on that handler in the new mode. + this.updateTaskApiHandlerIfNeeded(providerSettings, { + forceRebuild: true, + expectedCurrentTaskId: options?.expectedCurrentTaskId, + }) + throw error + } + + // 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() @@ -2485,11 +4149,18 @@ export class ClineProvider await this.providerSettingsManager.setModeConfig(mode, id) } - // Change the provider for the current task. + // 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 const expectedCurrentTaskId = options?.expectedCurrentTaskId this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, - skipCurrentTaskRebuild, + skipCurrentTaskRebuild: skipCurrentTaskRebuild || foreignTaskFollowed, expectedCurrentTaskId, }) @@ -2511,6 +4182,181 @@ 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. + */ + 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 }) + } + // A sibling's UI refresh must not reject an already-applied profile mutation or + // trigger its rollback: log the failure and let the remaining siblings refresh. + try { + await instance.postStateToWebview() + } catch (error) { + this.log( + `[refreshViewLocalStateForUpdatedProfile] failed to post state to sibling view ${instance.viewId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }), + ) + } + + /** + * 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 + } + + if (!replacementSettings) { + // Renaming the pin without the replacement's settings would leave every affected view + // and running task holding the deleted profile's apiConfiguration under the + // replacement's name: the UI would show one profile while requests went out with + // another profile's provider, model and credentials - the deleted profile's secret + // keeps being used after the profile is gone. There is no safe partial repair here + // (the manager delete already committed), so fail closed and let deleteProviderProfile + // compensate the record and the shared surfaces it rewrote. + throw new Error( + `Unable to resolve API profile '${replacementName}' to re-point ${affected.length} live view(s) after deleting '${deletedProfileName}'`, + ) + } + + 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) + } + try { + await instance.postStateToWebview() + } catch (error) { + this.log( + `[rePinViewLocalStateForDeletedProfile] failed to post state to sibling view ${instance.viewId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }), + ) + } + + /** + * Re-point live views still pinned to a profile that is being renamed onto + * the new name. A rename keeps the profile id, so a view pinned to the old + * name should follow the record instead of falling back to the shared + * selection: without this, the pin dangles at a name that no longer + * resolves and the buffered overlay keeps serving the pre-rename copy. + * Must run before the old name is deleted. + */ + public async repointViewLocalStateAfterProfileRename( + oldName: string, + newName: string, + renamedSettings: ProviderSettingsWithId | undefined, + ): Promise { + // Closed views keep their pin only in the persisted map: re-point those entries + // first so a view that launches after an intervening activation still follows the + // renamed profile instead of dangling at the deleted name. + await this.repointPersistedViewStates(oldName, newName) + const affected = ClineProvider.getAllInstances().filter((instance) => instance.pinnedProfileName === oldName) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + // Assigning the settings (even undefined) is what replaces or clears the + // overlay: the renamed record's current settings are the only correct + // overlay for a pin that now names the new profile. + await instance._saveViewLocalStateFromMutation({ + currentApiConfigName: newName, + apiConfiguration: renamedSettings, + }) + // A task running under the old name holds its own API handler and sticky + // profile name: re-point both, or the conversation keeps sending the + // pre-rename configuration under a name that no longer resolves. + if (instance.getCurrentTask()?.taskApiConfigName === oldName) { + if (renamedSettings) { + instance.updateTaskApiHandlerIfNeeded(renamedSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(newName) + } + try { + await instance.postStateToWebview() + } catch (error) { + this.log( + `[repointViewLocalStateAfterProfileRename] failed to post state to sibling view ${instance.viewId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }), + ) + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) @@ -2646,8 +4492,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) } } } @@ -3443,12 +5291,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. @@ -3509,127 +5363,143 @@ 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, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, collapseCommandOutputByDefault: - stateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, - 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.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, + 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, - timeFormat: stateValues.timeFormat ?? "auto", - dateFormat: stateValues.dateFormat ?? "auto", - showRelativeTimes: stateValues.showRelativeTimes ?? true, - 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, + timeFormat: mergedStateValues.timeFormat ?? "auto", + dateFormat: mergedStateValues.dateFormat ?? "auto", + showRelativeTimes: mergedStateValues.showRelativeTimes ?? true, + 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, } } @@ -3701,7 +5571,72 @@ export class ClineProvider } public async setValue(key: K, value: RooCodeSettings[K]) { - await this.contextProxy.setValue(key, value) + 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 + const writtenSharedValues = { [key]: value } as RooCodeSettings + try { + await this.contextProxy.setValue(key, value) + } catch (error) { + // The shared write is inside the rollback scope too: ContextProxy persists + // keys one at a time, so a rejected initial write must not leave a durable + // subset behind without the compensating restore below. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, writtenSharedValues) + throw error + } + 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, writtenSharedValues) + 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. Only keys that still hold the value this call wrote are replayed: the shared + * store is host-wide, so a sibling view can write the same key while this call awaits, + * and replaying this call's snapshot over the newer value would silently undo the + * sibling's write. A failed rollback is logged; the original failure is what callers + * see. + */ + private async _restoreSharedValuesAfterFailedViewWrite( + previous: RooCodeSettings, + written: RooCodeSettings, + ): Promise { + const current = this.contextProxy.getValues() + const restore = Object.fromEntries( + Object.entries(previous).filter(([key]) => + deepEqual(current[key as keyof RooCodeSettings], written[key as keyof RooCodeSettings]), + ), + ) as RooCodeSettings + + if (Object.keys(restore).length === 0) { + return + } + + try { + await this.contextProxy.setValues(restore) + } 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) { @@ -3709,13 +5644,230 @@ export class ClineProvider } public getValues() { - return this.contextProxy.getValues() + return { ...this.contextProxy.getValues(), ...this.viewLocalState } } - public async setValues(values: RooCodeSettings) { - await this.contextProxy.setValues( - withoutInvalidDisplaySettings(values, (key) => this.log(`[setValues] Ignored an invalid value for ${key}`)), + public async setValues(values: RooCodeSettings, options?: { savedProfileName?: string }) { + const sanitizedValues = withoutInvalidDisplaySettings(values, (key) => + this.log(`[setValues] Ignored an invalid value for ${key}`), ) + + 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. + // A payload that carries its own customModes (createTask and setConfiguration + // call setValues before registering them) validates against those too, so a + // brand-new custom mode in the same payload is not silently dropped. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, [ + ...(await this.customModesManager.getCustomModes()), + ...(sanitizedValues.customModes ?? []), + ]) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } + } + + // A flat provider write replaces the shared store: a sibling pinned to a + // profile whose settings are not part of this payload would serve the + // caller's provider, model, or credentials on its next task, so pin its + // overlay before the shared write (the protection the activation paths + // take). When the caller saves a profile with this payload, siblings + // pinned to that profile must not be pinned to the pre-save snapshot: + // the write carries their profile's new settings, and their overlays are + // refreshed from the payload after the write instead. + const touchesProviderKeys = PROVIDER_SETTINGS_KEYS.some((key) => key in sanitizedValues) + if (touchesProviderKeys) { + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(options?.savedProfileName) + } + + const previousSharedValues = Object.fromEntries( + Object.keys(sanitizedValues).map((key) => [key, this.contextProxy.getValue(key as keyof RooCodeSettings)]), + ) as RooCodeSettings + try { + await this.contextProxy.setValues(sanitizedValues) + } catch (error) { + // Same scope rule as setValue: a multi-key write that fails partway has + // already persisted a durable subset, so the restore must cover the + // initial shared write, not only the per-view persistence below. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, sanitizedValues) + throw error + } + 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, sanitizedValues) + throw error + } + + if (touchesProviderKeys && options?.savedProfileName !== undefined) { + // Siblings pinned to the profile this payload saves: the shared store now + // carries the saved settings; refresh their overlays so they serve the + // fresh copy instead of a pre-save snapshot. + const savedSettings = Object.fromEntries( + PROVIDER_SETTINGS_KEYS.map((key) => [key, sanitizedValues[key]]).filter( + ([, value]) => value !== undefined, + ), + ) as ProviderSettings + await this.refreshViewLocalStateForUpdatedProfile(options.savedProfileName, savedSettings) + } + } + + /** + * 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 { + // A clear is not a field mutation, so the loadViewState field tracking cannot see + // it: bump the generation the load captured instead (see the field comment). + this.viewStateLoadGeneration += 1 + 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. + */ + 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(() => {}) + // Durable invalidation must not gate live invalidation: when the durable + // clear rejects, every live buffer still has to be cleared and every + // sibling still has to be posted, or the reset leaves views serving stale + // selections behind a failed shared slot. The error is re-thrown after the + // loop so callers still see the durable failure. + let durableClearError: unknown + try { + await clearWrite + } catch (error) { + durableClearError = error + this.log( + `[broadcastResetToAllInstances] durable viewStates clear failed; live state is still invalidated: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + for (const instance of allInstances) { + 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) + }`, + ) + } + } + } + + if (durableClearError) { + throw durableClearError + } } // dev @@ -3744,9 +5896,20 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Per-view state (this view's buffer and durable entry) is cleared by the + // broadcast below: it clears the durable map once through the serialized + // write queue and every live buffer, and only rethrows after that work, so + // a per-view clear here would only add a failure point that can strand the + // reset between the shared and provider resets. + 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" }) } @@ -4272,6 +6435,20 @@ export class ClineProvider configuration: RooCodeSettings = {}, ): Promise { if (configuration) { + // Capture both selection layers a registration failure must roll back to: + // setValues below persists the payload's mode to the shared store AND the + // per-view record while the payload's custom modes are still unregistered. + // The pin is read defensively because createTask is also driven through + // structural provider doubles that carry no view-local buffer. + const previousMode = this.contextProxy.getValue("mode") + // Whether the payload carried a mode of its own, and that value: the rollback + // below owns the mode layers only for a payload that wrote them, and only while + // they still hold this call's value. The pin is read defensively because + // createTask is also driven through structural provider doubles that carry no + // view-local buffer. + const modeWasProvided = Object.prototype.hasOwnProperty.call(configuration, "mode") + const requestedMode = configuration.mode + const previousViewMode = this.viewLocalState?.mode await this.setValues(configuration) if (configuration.allowedCommands) { @@ -4305,8 +6482,40 @@ export class ClineProvider // when it merges .roomodes + global settings on refresh. Persisting // via updateCustomMode ensures modes survive the merge cycle. if (configuration.customModes?.length) { - for (const mode of configuration.customModes) { - await this.customModesManager.updateCustomMode(mode.slug, mode) + try { + for (const mode of configuration.customModes) { + await this.customModesManager.updateCustomMode(mode.slug, mode) + } + } catch (error) { + // setValues already persisted the payload's mode to shared and per-view + // state; if registration fails the mode is not actually usable, so each + // layer rolls back to its own prior value (an unset pin stays unset) + // before the failure surfaces. Each restore is awaited on its own and + // logged on failure, never swallowed; the original failure is what + // callers see. + try { + // Restore each layer only while it still holds the value this call wrote, + // and only for a mode this payload actually carried: updateCustomMode awaits + // queued file I/O, so another view may have moved the layer in the meantime, + // and a payload that omitted mode never wrote one to roll back. + if (modeWasProvided && this.contextProxy.getValue("mode") === requestedMode) { + await this.contextProxy.setValue("mode", previousMode) + } + } catch (rollbackError) { + this.log( + `[ClineProvider#createTask] Failed to restore the shared mode after custom-mode registration failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + try { + if (modeWasProvided && this.viewLocalState?.mode === requestedMode) { + await this.saveViewState("mode", previousViewMode) + } + } catch (rollbackError) { + this.log( + `[ClineProvider#createTask] Failed to restore the view mode pin after custom-mode registration failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + throw error } } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index c2aec90dcd..1b6fc32f1d 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode" import { makeCompositeDisposable } from "../../../test-utils/vscode" import { TelemetryService } from "@roo-code/telemetry" -import { getModelId, RooCodeEventName } from "@roo-code/types" +import { getModelId, RooCodeEventName, type ProviderSettings } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" import type { Mode } from "../../../shared/modes" @@ -145,6 +145,11 @@ vi.mock("@roo-code/cloud", () => ({ getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) +// A same-name profile save joins the manager's cross-instance critical section, so a +// double that stubs the manager must still run the callback inline: the real lock does +// that whenever no other view holds the section. +const runManagerSectionInline = (cb: () => Promise): Promise => cb() + describe("ClineProvider - API Handler Rebuild Guard", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext @@ -240,9 +245,16 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { new WebviewFocusTracker(), ) - // Mock providerSettingsManager + // Mock providerSettingsManager. saveConfig repoints the CURRENT getProfile mock at + // the record it wrote: the real manager reads back the stored record, and + // persistProviderProfile builds its activation writes from that read-back. + const currentGetProfile = () => provider["providerSettingsManager"].getProfile as ReturnType ;(provider as any).providerSettingsManager = { - saveConfig: vi.fn().mockResolvedValue("test-id"), + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn().mockImplementation(async (name: string, config: Record) => { + currentGetProfile().mockResolvedValue({ name, id: "test-id", ...config }) + return "test-id" + }), listConfig: vi.fn().mockResolvedValue([ { name: "test-config", @@ -851,7 +863,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const first = provider.activateProviderProfile({ name: "first-profile" }) const second = provider.activateProviderProfile({ name: "second-profile" }) - await Promise.resolve() + // The activation now takes a sibling-overlay snapshot before it asks the manager to + // write, so flush past that pre-flight await rather than a single microtask: the + // assertions below still prove only the first mutation had started. + await new Promise((resolve) => setImmediate(resolve)) expect(events).toEqual(["first:start"]) resolveFirst() @@ -1011,6 +1026,117 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) }) + test("rebuilds the task API handler when the shared write fails after activation", async () => { + const mockTask = new Task({ + ...defaultTaskOptions, + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + }, + }) + await provider.addClineToStack(mockTask) + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") + provider["providerSettingsManager"].listConfig = vi + .fn() + .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }]) + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + // The manager has already written the activated profile when the shared provider-settings + // write rejects, so the switch stands and the caller delivers its pending message now. + const emitSpy = vi.spyOn(provider, "emit") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + // The mode the shared store held before this switch. The reconcile path writes it back, + // so the absence of that write is what separates "the switch stands" from a mode-only + // rollback - without pinning it, a regression that reconciled here would still pass the + // rebuild assertions below, because the rebuild runs before the failure surfaces. + const modeBeforeSwitch = provider.getValue("mode") + expect(modeBeforeSwitch).not.toBe("ask") + vi.spyOn(provider.contextProxy, "setProviderSettings").mockRejectedValueOnce( + new Error("shared write failed"), + ) + + await expect(provider.handleModeSwitch("ask" as Mode, undefined)).rejects.toThrow("shared write failed") + + // The task must run the profile the activation committed rather than the one it started on: + // the rebuild happens before the failure surfaces instead of being skipped by it. + expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ openRouterModelId: "openai/gpt-4.1-mini" }), + ) + + // And the switch itself stays committed. Once the activation has written the profile, a + // mode-only rollback would pair the old mode with the new profile, which is the outcome + // the keep-the-switch branch exists to prevent. + expect(provider.getValues().mode).toBe("ask") + expect(provider["viewLocalState"].mode).toBe("ask") + expect(mockTask["_taskMode"]).toBe("ask") + expect(setValueSpy).toHaveBeenCalledWith("mode", "ask") + expect(setValueSpy).not.toHaveBeenCalledWith("mode", modeBeforeSwitch) + expect(saveViewStateSpy).toHaveBeenCalledWith("mode", "ask") + expect(saveViewStateSpy).not.toHaveBeenCalledWith("mode", modeBeforeSwitch) + // Listeners must learn the committed mode before the error is rethrown. + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "ask") + }) + + test("rebuilds the task handler on a failed shared write when provider and model are unchanged", async () => { + // The rebuild guard keys on provider/model equality, so a switch to a profile that carries + // the same provider and model rebuilds only because the failure path forces it. The test + // above changes the model, so it would still pass if that force were dropped; this case is + // the one that would notice, because the values that differ here are credentials. + const mockTask = new Task({ + ...defaultTaskOptions, + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "task-key", + }, + }) + await provider.addClineToStack(mockTask) + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") + provider["providerSettingsManager"].listConfig = vi + .fn() + .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: providerIdentifiers.openrouter }]) + const activatedProfile = { + name: "ask-profile", + id: "ask-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "activated-key", + rateLimitSeconds: 7, + } + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue(activatedProfile) + provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue(activatedProfile) + vi.spyOn(provider.contextProxy, "setProviderSettings").mockRejectedValueOnce( + new Error("shared write failed"), + ) + + await expect(provider.handleModeSwitch("ask" as Mode, undefined)).rejects.toThrow("shared write failed") + + // Same provider, same model id: only the credentials differ. The handler still has to be + // rebuilt or the task keeps sending on the profile the switch replaced, with the old key. + expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + openRouterApiKey: "activated-key", + rateLimitSeconds: 7, + }), + ) + expect((mockTask as { apiConfiguration?: ProviderSettings }).apiConfiguration?.openRouterApiKey).toBe( + "activated-key", + ) + }) + test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { const mockTask = new Task({ ...defaultTaskOptions, 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..8a0e9cb9c6 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,3473 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.parallelMode.spec.ts + +import * as vscode from "vscode" + +import { + type HistoryItem, + type ProviderSettingsEntry, + type ProviderSettingsWithId, + PROVIDER_SETTINGS_KEYS, + RooCodeEventName, + type RooCodeSettings, + providerIdentifiers, +} from "@roo-code/types" + +import { ContextProxy } from "../../config/ContextProxy" +import { ProviderSettingsNotFoundError } from "../../config/ProviderSettingsManager" +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(), + } + }), +})) + +vi.mock("../../config/ProviderSettingsManager", async (importOriginal) => { + // The real error class, not a double: the snapshot discriminates a pin naming a deleted + // profile with `instanceof ProviderSettingsNotFoundError`. The original-module import can + // hand back a partially initialized module for this chain, which would leave the export + // undefined and make every production `instanceof` check throw, so declare a fallback the + // production import resolves to. + const actual = await importOriginal() + + class NotFoundFallback extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderSettingsNotFoundError" + } + } + + const ProviderSettingsNotFound = actual.ProviderSettingsNotFoundError ?? NotFoundFallback + + return { + ProviderSettingsNotFoundError: ProviderSettingsNotFound, + ProviderSettingsManager: vi.fn().mockImplementation(function () { + // The real manager keeps a gate of its own in the secrets-backed profiles file: the profile + // its store selects and the complete mode -> profile id mapping. This double carries the same + // two values so a test can read what the store reports after a partial mutation, which is + // what the delete compensation gates its own restore on. + const gate: { currentApiConfigName: string; modeApiConfigs: Record } = { + currentApiConfigName: "default", + modeApiConfigs: {}, + } + return { + // The profile writer joins the manager's cross-instance critical section around the store + // write; this double runs the callback inline because no second view competes here. + runExclusiveAcrossInstances: (cb: () => Promise) => cb(), + saveConfig: vi.fn().mockResolvedValue("test-id"), + listConfig: vi.fn().mockResolvedValue([]), + getProfile: vi.fn().mockResolvedValue({}), + activateProfile: vi.fn().mockImplementation(async (args: { name?: string; id?: string }) => { + const name = args.name ?? "default" + gate.currentApiConfigName = name + return { + name, + id: args.id ?? "test-id", + apiProvider: providerIdentifiers.anthropic, + } + }), + setModeConfig: vi.fn().mockImplementation(async (mode: string, configId: string | undefined) => { + if (configId === undefined) { + delete gate.modeApiConfigs[mode] + } else { + gate.modeApiConfigs[mode] = configId + } + }), + getModeConfigId: vi.fn().mockImplementation(async (mode: string) => gate.modeApiConfigs[mode]), + getSelectionAndModeConfigs: vi.fn().mockImplementation(async () => ({ + currentApiConfigName: gate.currentApiConfigName, + modeApiConfigs: { ...gate.modeApiConfigs }, + })), + resetAllConfigs: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + // The delete path classifies a rejected getProfile snapshot as absence with hasConfig; the + // per-test spies below reject lookups for profiles the scenario does not carry, so + // absence is what this double reports. + hasConfig: vi.fn().mockResolvedValue(false), + } + }), + } +}) + +// 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) + 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 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() + }) + + 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() + }) + }) + + 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 }, + ]) + + // The activation write is built from the stored record (saveConfig normalization + // contract): repoint getProfile at what saveConfig wrote, mirroring the real + // manager's round-trip. + const savedRecords = new Map>() + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async (configName, config) => { + savedRecords.set(configName, { name: configName, id: "test-id", ...config }) + return "test-id" + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => { + const record = savedRecords.get(String(args.name)) + return (record ?? { name: args.name, id: "test-id" }) as unknown as Awaited< + ReturnType + > + }, + ) + 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 activate branch, which may persist an extra + // temporary view entry, 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 activate the replacement profile 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") + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // A view without its own profile pin follows the deletion through the activation path, + // so the shared selection refreshes to the replacement profile via activation. + expect(activateSpy).toHaveBeenCalledWith({ name: "keeper-profile" }) + expect(await provider.getState()).toMatchObject({ currentApiConfigName: "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. The activation route refreshes the + // durable shared list from the settings store (listConfig), so the + // replacement-only durable value is the observable pin: a path that never + // resolved the activation list would leave the pre-deletion entries in place. + expect(listConfigSpy).toHaveBeenCalled() + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + + 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: name the lookup so a wrong or empty argument cannot be served + // the replacement's settings. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => + args.name === "replacement-profile" + ? ({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + } as unknown as Awaited>) + : // A profile the store no longer has fails with the manager's typed + // ProviderSettingsNotFoundError (getProfile does exactly that), and the + // sibling-overlay guard tolerates a pin naming such a profile because the + // deletion re-points that view itself; a plain Error here reads as a store + // failure and fails the whole deletion closed. + Promise.reject(new ProviderSettingsNotFoundError(`profile ${String(args.name)} not found`)), + ) + + 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() + }) + + // A4 regression: renaming a sibling's pin without the replacement's settings shows one + // profile while the sibling's requests keep using the deleted profile's credentials. + it("aborts the deletion when a live sibling's replacement settings cannot be resolved", 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(), + ) + const logSpy = vi.spyOn(providerA, "log") + + // 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 }, + ]) + + vi.spyOn(providerA.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + } as unknown as Awaited>) + vi.spyOn(providerA.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // The replacement’s settings cannot be resolved. Re-pointing by name alone is what the + // finding rejects, so the deletion fails closed and the compensation below runs. + // Only the replacement’s settings are unresolvable. The doomed profile’s own record is + // returned, so the pre-delete snapshot is a real record and the compensation below has + // something to restore; a blanket rejection made the record restore unobservable - the + // snapshot was classified absent and that leg never ran. + const doomedRecord = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-key", + } + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => { + if (args.name === "doomed-profile") { + return doomedRecord as unknown as Awaited< + ReturnType + > + } + return Promise.reject(new Error("profile replacement-profile not found")) + }, + ) + const saveConfigSpy = vi + .spyOn(providerA.providerSettingsManager, "saveConfig") + .mockResolvedValue("doomed-id") + + await expect( + providerA.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + }), + ).rejects.toThrow("Unable to resolve API profile 'replacement-profile'") + + // B is left whole: the deletion aborted, so its pin still names the profile it was + // pinned to and the overlay under that pin carries that profile’s own settings. + expect(providerB["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + // With the doomed record readable, B keeps its own pin *and* its own settings: the overlay + // carries the doomed profile’s credentials rather than the replacement’s. A sibling left + // naming its old profile while serving the replacement’s provider and key is the + // half-state this finding is about. + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject({ + id: "doomed-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-key", + }) + // The committed manager delete is compensated: the shared profile list is restored + // with the deleted profile back in it. + expect(providerA.getProviderProfileEntries().map(({ name }) => name)).toContain("doomed-profile") + // The list entry coming back is only half the restore: the secrets-backed record must come + // back too, under its original id, or the restored entry names a profile whose settings + // are gone. This is the leg the blanket rejection above used to skip. + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", apiKey: "doomed-key" }), + ) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("Unable to resolve API profile 'replacement-profile'"), + ) + + await providerA.dispose() + await providerB.dispose() + }) + + // A4 regression: the deletion's shared write must not leave a sibling pinned to a third + // profile reporting that pin's name together with the replacement's settings. + it("snapshots a sibling's own profile settings before rewriting shared settings on delete", 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(), + ) + const doomedEntry: ProviderSettingsEntry = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + } + const keeperEntry: ProviderSettingsEntry = { + id: "keeper-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + } + const thirdEntry: ProviderSettingsEntry = { + id: "third-id", + name: "third-profile", + apiProvider: providerIdentifiers.anthropic, + } + const keeperSettings = { apiProvider: providerIdentifiers.openrouter, openRouterBaseUrl: "keeper-url" } + const thirdSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "third-key" } + + await providerA.contextProxy.setValue("listApiConfigMeta", [doomedEntry, keeperEntry, thirdEntry]) + // The doomed profile is the globally selected one, so the shared provider keys below + // carry its settings until the deletion replaces them. + await providerA.contextProxy.setValue("currentApiConfigName", "doomed-profile") + // A pins a third profile: a view pinned to the deleted profile or with no pin of its + // own would have taken the activation branch instead. + await providerA.saveViewState("currentApiConfigName", "third-profile") + // B pins that same third profile and holds no API overlay of its own. saveViewState, not + // setValue: setValue writes the shared store first and only then the view-local pin, so + // this setup line would also move the shared selection onto the third profile. The + // deletion below reads that shared value fresh; with one shared cache the snapshot branch + // this test covers would stop running while the test still passed. + await providerB.saveViewState("currentApiConfigName", "third-profile") + + vi.spyOn(providerA.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(providerA.providerSettingsManager, "listConfig").mockResolvedValue([keeperEntry, thirdEntry]) + const getProfile = async (args: { name?: string; id?: string }) => { + if (args.name === "keeper-profile") { + return { name: "keeper-profile", id: "keeper-id", ...keeperSettings } as unknown as Awaited< + ReturnType + > + } + if (args.name === "third-profile") { + return { name: "third-profile", id: "third-id", ...thirdSettings } as unknown as Awaited< + ReturnType + > + } + return Promise.reject(new Error("profile doomed-profile not found")) + } + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation(getProfile) + vi.spyOn(providerB.providerSettingsManager, "getProfile").mockImplementation(getProfile) + + await providerA.deleteProviderProfile(doomedEntry) + + // The shared selection and shared provider keys moved onto the keeper, while B keeps + // its own pin together with its own settings: without the pre-write snapshot B would + // serve third-profile's name with keeper-profile's provider and credentials. + expect(providerA.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + expect(providerA.contextProxy.getProviderSettings()).toMatchObject(keeperSettings) + expect(providerB["viewLocalState"].currentApiConfigName).toBe("third-profile") + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject(thirdSettings) + const siblingState = await providerB.getState() + expect(siblingState.currentApiConfigName).toBe("third-profile") + expect(siblingState.apiConfiguration).toMatchObject(thirdSettings) + + await providerA.dispose() + await providerB.dispose() + }) + + // The manager owns a gate of its own - the profile its store selects and the mode -> profile + // id mapping - and activating the replacement rewrites both. The restores for the record, + // list, shared selection, shared settings and view pins cannot reach that gate, so this test + // reads all three surfaces the compensation owns after the replacement activation landed. + it("restores the manager selection and mode configs when a later delete write fails", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedEntry: ProviderSettingsEntry = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + } + // The double answers a name-only activation with "test-id", so the keeper entry carries + // that same id: the id the activation writes into the mode mapping is then the id the + // compensation compares the manager’s current report against. + const keeperEntry: ProviderSettingsEntry = { + id: "test-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + } + const doomedRecord: ProviderSettingsWithId & { name: string } = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-key", + } + const keeperRecord: ProviderSettingsWithId & { name: string } = { + id: "test-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "keeper-url", + } + await providerA.contextProxy.setValue("listApiConfigMeta", [doomedEntry, keeperEntry]) + // The doomed profile is the globally selected one, so the deletion activates the + // replacement, and that activation is what moves the manager gate. + await providerA.contextProxy.setValue("currentApiConfigName", "doomed-profile") + + const manager = providerA.providerSettingsManager + await manager.activateProfile({ name: "doomed-profile" }) + await manager.setModeConfig("code", "doomed-id") + await manager.setModeConfig("architect", "doomed-id") + // The ask mapping already names the id the replacement activation is about to write into + // the mode mapping. Nothing this deletion does change ask, so the restore has to leave it + // alone even though it points at the activated profile - only a mapping whose value this + // deletion actually moved is a restore target. + await manager.setModeConfig("ask", "test-id") + const gateBefore = await manager.getSelectionAndModeConfigs() + expect(gateBefore).toEqual({ + currentApiConfigName: "doomed-profile", + modeApiConfigs: { architect: "doomed-id", ask: "test-id", code: "doomed-id" }, + }) + + vi.spyOn(manager, "listConfig").mockResolvedValue([keeperEntry]) + vi.spyOn(manager, "getProfile").mockImplementation(async (args: { name?: string; id?: string }) => + args.name === "keeper-profile" ? keeperRecord : doomedRecord, + ) + const saveConfigSpy = vi.spyOn(manager, "saveConfig").mockResolvedValue("doomed-id") + // The activation commits its own gate writes first - the store selection, the shared + // provider keys, and the mode mapping for the mode it activated into - and only then posts + // state. Fail that post so the whole activation, gate included, had landed by the time + // compensation starts. (The view is unpinned, so the deletion takes the activation path and + // returns straight after it; this is the last write that path makes.) + // The seeded gate writes would otherwise count against the assertions below. + vi.mocked(manager.activateProfile).mockClear() + vi.mocked(manager.setModeConfig).mockClear() + + vi.spyOn(providerA, "postStateToWebview").mockRejectedValueOnce(new Error("state post failed")) + + await expect(providerA.deleteProviderProfile(doomedEntry)).rejects.toThrow("state post failed") + + // Surface one: the secrets-backed record, back under its original id. + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", apiKey: "doomed-key" }), + ) + // Surfaces two and three: the store’s own selection and every mode mapping, each put + // back through the manager’s own write paths - activateProfile for the selection, + // setModeConfig for the mappings - so the gate ends exactly where it was before the + // deletion moved it. + expect(await manager.getSelectionAndModeConfigs()).toEqual(gateBefore) + expect(manager.activateProfile).toHaveBeenLastCalledWith({ name: "doomed-profile" }) + + // Surfaces three, second half: the mapping this deletion moved comes back through the + // manager’s own write path, and the mapping it did not move is left untouched rather + // than replayed just because it happens to name the activated profile. + expect(manager.setModeConfig).toHaveBeenCalledWith("code", "doomed-id") + expect(manager.setModeConfig).not.toHaveBeenCalledWith("ask", "test-id") + + await providerA.dispose() + }) + + // The manager gate restores only while the manager still reports what this deletion wrote. A + // concurrent activation that lands between the replacement activation and the failure owns the + // gate by then, so replaying the pre-delete selection over it would undo that newer work. + it("leaves the manager gate alone when a concurrent activation moved it during the delete", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedEntry: ProviderSettingsEntry = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + } + const keeperEntry: ProviderSettingsEntry = { + id: "test-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + } + const doomedRecord: ProviderSettingsWithId & { name: string } = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-key", + } + const keeperRecord: ProviderSettingsWithId & { name: string } = { + id: "test-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "keeper-url", + } + const laterRecord: ProviderSettingsWithId & { name: string } = { + id: "test-id", + name: "later-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "later-url", + } + await providerA.contextProxy.setValue("listApiConfigMeta", [doomedEntry, keeperEntry]) + await providerA.contextProxy.setValue("currentApiConfigName", "doomed-profile") + + const manager = providerA.providerSettingsManager + await manager.activateProfile({ name: "doomed-profile" }) + await manager.setModeConfig("code", "doomed-id") + await manager.setModeConfig("architect", "doomed-id") + const gateBefore = await manager.getSelectionAndModeConfigs() + expect(gateBefore).toEqual({ + currentApiConfigName: "doomed-profile", + modeApiConfigs: { architect: "doomed-id", code: "doomed-id" }, + }) + // The seeded gate writes above are not part of the deletion under test, so the manager spy + // history starts here: the assertions below are about the writes the deletion and its + // compensation make, not about the setup that seeded the gate. + vi.mocked(manager.activateProfile).mockClear() + vi.mocked(manager.setModeConfig).mockClear() + vi.mocked(manager.getSelectionAndModeConfigs).mockClear() + const gateAfterConcurrentActivation = { + currentApiConfigName: "later-profile", + modeApiConfigs: { architect: "later-id", code: "later-id" }, + } + + vi.spyOn(manager, "listConfig").mockResolvedValue([keeperEntry]) + const saveConfigSpy = vi.spyOn(manager, "saveConfig").mockResolvedValue("doomed-id") + vi.spyOn(manager, "getProfile").mockImplementation(async (args: { name?: string; id?: string }) => { + if (args.name === "keeper-profile") { + return keeperRecord + } + if (args.name === "later-profile") { + return laterRecord + } + return doomedRecord + }) + const setModeConfigSpy = vi.spyOn(manager, "setModeConfig") + // The replacement activation lands on the gate, and then this view's own post fails. The + // post doubles as the window where a sibling activation lands on the same manager: it points + // the gate at a third profile (a different id, so the mode gate has something to compare + // against) and only then rejects, which is the order a concurrent write really has. + const postSpy = vi.spyOn(providerA, "postStateToWebview").mockImplementationOnce(async () => { + await manager.activateProfile({ id: "later-id", name: "later-profile" }) + await manager.setModeConfig("architect", "later-id") + await manager.setModeConfig("code", "later-id") + throw new Error("state post failed") + }) + const logSpy = vi.spyOn(providerA, "log") + + await expect(providerA.deleteProviderProfile(doomedEntry)).rejects.toThrow("state post failed") + expect(postSpy).toHaveBeenCalledOnce() + + // The record and the list come back - the deletion committed them - but the gate keeps the + // concurrent owner's selection and mappings: replaying this deletion's pre-delete values over + // them would silently revert a write the deletion never made. + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", apiKey: "doomed-key" }), + ) + expect(providerA.getProviderProfileEntries().map(({ name }) => name)).toContain("doomed-profile") + expect(await manager.getSelectionAndModeConfigs()).toEqual(gateAfterConcurrentActivation) + expect(manager.activateProfile).not.toHaveBeenCalledWith({ name: "doomed-profile" }) + expect(setModeConfigSpy).not.toHaveBeenCalledWith("code", "doomed-id") + expect(setModeConfigSpy).not.toHaveBeenCalledWith("architect", "doomed-id") + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("failed to restore the manager selection and mode configs"), + ) + + await providerA.dispose() + }) + + // Restoring the manager gate is best effort, like every other restore in this compensation: a + // manager that cannot be read must not stop the shared selection, the shared provider settings, + // the profile list or the view pin from being put back, and the caller still sees the deletion + // own failure. + it("keeps the other restores running when the manager gate cannot be read back", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedEntry: ProviderSettingsEntry = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + } + const ghostEntry: ProviderSettingsEntry = { + id: "test-id", + name: "ghost-profile", + apiProvider: providerIdentifiers.anthropic, + } + const doomedSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "doomed-key" } + const ghostSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "ghost-key" } + const doomedRecord: ProviderSettingsWithId & { name: string } = { + id: "doomed-id", + name: "doomed-profile", + ...doomedSettings, + } + const ghostRecord: ProviderSettingsWithId & { name: string } = { + id: "test-id", + name: "ghost-profile", + ...ghostSettings, + } + await providerA.contextProxy.setValue("listApiConfigMeta", [doomedEntry, ghostEntry]) + await providerA.contextProxy.setValue("modeApiConfigs", { architect: "doomed-id", code: "doomed-id" }) + await providerA.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await providerA.contextProxy.setProviderSettings(doomedSettings) + await providerA.saveViewState("currentApiConfigName", "doomed-profile") + providerA["viewLocalState"] = { + mode: "code", + currentApiConfigName: "doomed-profile", + apiConfiguration: doomedSettings, + } + + const manager = providerA.providerSettingsManager + vi.spyOn(manager, "listConfig").mockResolvedValue([ghostEntry]) + vi.spyOn(manager, "getProfile").mockImplementation(async (args: { name?: string; id?: string }) => + args.name === "ghost-profile" ? ghostRecord : doomedRecord, + ) + // The pre-delete snapshot read succeeds; the read that opens the compensation is the one + // that fails, so the gate restores cannot even be attempted. + let gateReads = 0 + const gateReadSpy = vi.spyOn(manager, "getSelectionAndModeConfigs").mockImplementation(async () => { + gateReads++ + if (gateReads === 2) { + throw new Error("gate read failed") + } + return { + currentApiConfigName: "doomed-profile", + modeApiConfigs: { architect: "doomed-id", code: "doomed-id" }, + } + }) + const setValueSpy = vi.spyOn(providerA.contextProxy, "setValue") + const setProviderSettingsSpy = vi.spyOn(providerA.contextProxy, "setProviderSettings") + const logSpy = vi.spyOn(providerA, "log") + // The view pins the doomed profile, so the deletion takes the activation branch, and that + // activation last write is the one that fails - the point at which the compensation runs. + vi.spyOn(providerA, "postStateToWebview").mockRejectedValueOnce(new Error("state post failed")) + + await expect(providerA.deleteProviderProfile(doomedEntry)).rejects.toThrow("state post failed") + + expect(gateReadSpy).toHaveBeenCalledTimes(2) + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("failed to restore the manager selection and mode configs: gate read failed"), + ) + expect(manager.activateProfile).not.toHaveBeenCalledWith({ name: "doomed-profile" }) + // The restores behind the gate are independent of it: the deleted record comes back, the + // shared metadata, selection and provider settings are put back, and this view pin is merged + // back onto the deleted profile rather than left on the replacement. + expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "doomed-profile") + expect(setValueSpy).toHaveBeenCalledWith( + "listApiConfigMeta", + expect.arrayContaining([ghostEntry, doomedEntry]), + ) + expect(setProviderSettingsSpy).toHaveBeenCalledWith(doomedSettings) + // The pin restore is a durable merge, not an in-memory rewrite: the entry this view pinned + // before the deletion is back on the deleted profile, while the live overlay of the view that + // ran the activation keeps the replacement it activated into. + const durablePins = mockContext.globalState.get("viewStates") as Record< + string, + { currentApiConfigName?: string } | undefined + > + expect(Object.values(durablePins).some((pin) => pin?.currentApiConfigName === "doomed-profile")).toBe(true) + + await providerA.dispose() + }) + + // The deletion activates whatever the shared selection names, even a profile the durable list + // does not carry - a stale selection is possible when another instance pruned a profile. The + // pre-delete gate snapshot reads that target back out of the pre-delete entries, so it has to + // tolerate the target being absent instead of assuming an id is there. + it("completes the deletion when the shared selection names a profile the list does not carry", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedEntry: ProviderSettingsEntry = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + } + const keeperEntry: ProviderSettingsEntry = { + id: "test-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + } + const doomedRecord: ProviderSettingsWithId & { name: string } = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-key", + } + const ghostRecord: ProviderSettingsWithId & { name: string } = { + id: "test-id", + name: "ghost-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "ghost-url", + } + await providerA.contextProxy.setValue("listApiConfigMeta", [doomedEntry, keeperEntry]) + // A selection the durable list does not name, and not the deleted profile, so it becomes the + // activation target as-is and the snapshot lookup for it comes up empty. + await providerA.contextProxy.setValue("currentApiConfigName", "ghost-profile") + + const manager = providerA.providerSettingsManager + vi.spyOn(manager, "listConfig").mockResolvedValue([keeperEntry]) + vi.spyOn(manager, "getProfile").mockImplementation(async (args: { name?: string; id?: string }) => + args.name === "ghost-profile" ? ghostRecord : doomedRecord, + ) + vi.spyOn(manager, "deleteConfig").mockResolvedValue(undefined) + const gateBefore = await manager.getSelectionAndModeConfigs() + + await providerA.deleteProviderProfile(doomedEntry) + + // The deletion completes and hands the manager the selection it was told to activate. + expect(manager.activateProfile).toHaveBeenLastCalledWith({ name: "ghost-profile" }) + expect(await manager.getSelectionAndModeConfigs()).toEqual({ + currentApiConfigName: "ghost-profile", + modeApiConfigs: { code: "test-id" }, + }) + // The snapshot the compensation would restore from is the pre-delete gate, and reading it + // alongside an absent activation target is the case that must not throw. + expect(gateBefore).toEqual({ currentApiConfigName: "default", modeApiConfigs: {} }) + expect(providerA.getProviderProfileEntries().map(({ name }) => name)).toEqual(["keeper-profile"]) + + await providerA.dispose() + }) + + // Two of the three manager-gate guards are about ownership rather than values: the gate is + // restored only while this deletion owns it. Here the manager already selected the profile + // the deletion goes on to activate, and the one mapping that changed between the snapshot + // and the failure was cleared by somebody else, so neither half of the gate is this + // deletion’s write to undo. + it("leaves the manager gate alone when it already selected the profile the deletion activates", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedEntry: ProviderSettingsEntry = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + } + const keeperEntry: ProviderSettingsEntry = { + id: "test-id", + name: "keeper-profile", + apiProvider: providerIdentifiers.openrouter, + } + const doomedRecord: ProviderSettingsWithId & { name: string } = { + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-key", + } + const ghostRecord: ProviderSettingsWithId & { name: string } = { + id: "test-id", + name: "ghost-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "ghost-url", + } + await providerA.contextProxy.setValue("listApiConfigMeta", [doomedEntry, keeperEntry]) + // The shared selection names a profile the durable entry list does not carry, so the + // deletion activates that profile and finds no pre-delete entry to read an id from. + await providerA.contextProxy.setValue("currentApiConfigName", "ghost-profile") + const manager = providerA.providerSettingsManager + // The manager already points at the very profile this deletion will activate, and it + // maps architect onto the doomed profile. + await manager.activateProfile({ name: "ghost-profile" }) + await manager.setModeConfig("architect", "doomed-id") + const gateBefore = await manager.getSelectionAndModeConfigs() + expect(gateBefore).toEqual({ + currentApiConfigName: "ghost-profile", + modeApiConfigs: { architect: "doomed-id" }, + }) + vi.spyOn(manager, "listConfig").mockResolvedValue([keeperEntry]) + vi.spyOn(manager, "getProfile").mockImplementation(async (args: { name?: string; id?: string }) => + args.name === "doomed-profile" ? doomedRecord : ghostRecord, + ) + const saveConfigSpy = vi.spyOn(manager, "saveConfig").mockResolvedValue("doomed-id") + vi.spyOn(manager, "deleteConfig").mockResolvedValue(undefined) + const logSpy = vi.spyOn(providerA, "log") + // The seeded gate writes would otherwise count against the assertions below. + vi.mocked(manager.activateProfile).mockClear() + vi.mocked(manager.setModeConfig).mockClear() + // The activation commits its own gate writes; before its last write an unrelated mutation + // clears the architect mapping, and that last write then fails, so compensation starts + // with a gate whose selection this deletion never moved and whose changed mapping it did + // not make. + vi.spyOn(providerA, "postStateToWebview").mockImplementationOnce(async () => { + await manager.setModeConfig("architect", undefined) + throw new Error("state post failed") + }) + await expect(providerA.deleteProviderProfile(doomedEntry)).rejects.toThrow("state post failed") + // The record restore is untouched by any of this. + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", apiKey: "doomed-key" }), + ) + // The selection half stays where the activation put it, and activateProfile ran once - + // the activation itself - instead of a second time with the snapshot value. + expect(manager.activateProfile).toHaveBeenCalledTimes(1) + expect(manager.activateProfile).toHaveBeenLastCalledWith({ name: "ghost-profile" }) + // The mapping half is left alone too: architect was cleared by the other mutation, and + // with no pre-delete entry for the activated profile there is nothing that identifies any + // mapping as this deletion’s write to replay. + expect(manager.setModeConfig).not.toHaveBeenCalledWith("architect", "doomed-id") + expect(await manager.getSelectionAndModeConfigs()).toEqual({ + currentApiConfigName: "ghost-profile", + modeApiConfigs: { code: "test-id" }, + }) + expect(logSpy).not.toHaveBeenCalledWith( + expect.stringContaining("failed to restore the manager selection and mode configs"), + ) + await providerA.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", + } + // Repoint the manager round-trip: the activation write reads the stored + // record back after saveConfig (normalization contract), so getProfile must + // serve what saveConfig wrote. + const savedRecords = new Map>() + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async (configName, config) => { + savedRecords.set(configName, { name: configName, id: "zai-profile-id", ...config }) + return "zai-profile-id" + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => { + const record = savedRecords.get(String(args.name)) + return (record ?? { name: args.name, id: "zai-profile-id" }) as unknown as Awaited< + ReturnType + > + }, + ) + 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", + }) + // The snapshot is the profile-y settings resolved through A's manager: + // pin the known values, not just the presence of an overlay. + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject({ + id: "y-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "y-key", + }) + + await providerA.dispose() + await providerB.dispose() + }) + + it("should leave a sibling's pre-existing overlay untouched when 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 and already carries a custom overlay on top of the pin: + // the overlay is newer than any shared write, so the snapshot must not + // replace it with the pin's stock settings. + await providerB.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "custom-model", + openRouterApiKey: "custom-key", + }) + await providerB.setValue("currentApiConfigName", "profile-y") + + 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 }, + ]) + 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" }) + + // B keeps its own pin and its custom overlay: the snapshot excludes views + // that already buffer an overlay, so the activation never replaces the + // customization with the pin's stock settings. + expect(providerB["viewLocalState"].currentApiConfigName).toBe("profile-y") + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "custom-model", + openRouterApiKey: "custom-key", + }) + + await providerA.dispose() + await providerB.dispose() + }) + + it("should not snapshot the activating view's own overlay during its activation", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A pins profile-y without an overlay and activates profile-x. A self + // snapshot would write y's stock settings into A's buffer and post A's + // state before the activation's own overlay clear and post: A must end + // the activation with a cleared buffer and exactly its own single post. + await providerA.setValue("currentApiConfigName", "profile-y") + + 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 }, + ]) + 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>), + ) + + const postSpy = vi.spyOn(providerA, "postStateToWebview").mockResolvedValue(undefined) + + await providerA.activateProviderProfile({ name: "profile-x" }) + + expect(providerA["viewLocalState"].currentApiConfigName).toBe("profile-x") + expect(providerA["viewLocalState"].apiConfiguration).toBeUndefined() + expect(postSpy).toHaveBeenCalledTimes(1) + + postSpy.mockRestore() + await providerA.dispose() + }) + + it("should serve a sibling pinned to the activated profile through the refresh, not the snapshot", 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 is pinned to the very profile A activates, with no overlay. The + // refresh path (after the shared write), not the pre-write snapshot, + // must bring B's buffer up to date: B receives exactly one state post. + await providerB.setValue("currentApiConfigName", "profile-x") + + 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: "x-id", name: "profile-x", apiProvider: providerIdentifiers.anthropic }, + { id: "y-id", name: "profile-y", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "profile-x", + id: "x-id", + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + } as unknown as Awaited>) + + const postB = vi.spyOn(providerB, "postStateToWebview").mockResolvedValue(undefined) + + await providerA.activateProviderProfile({ name: "profile-x" }) + + // B's buffer holds the activated profile's settings... + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + }) + // ...and the refresh is the sole writer: exactly one post, not a snapshot + // plus refresh double write. + expect(postB).toHaveBeenCalledTimes(1) + + postB.mockRestore() + await providerA.dispose() + await providerB.dispose() + }) + + it("should abort the shared settings write when a sibling's pin cannot be resolved", 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(), + ) + await providerB.setValue("currentApiConfigName", "profile-y") + + 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 sibling's pin cannot be resolved, so no overlay can shield it from the write. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockRejectedValue( + new Error("profile store unavailable"), + ) + const setSharedSettingsSpy = vi.spyOn(providerA.contextProxy, "setProviderSettings") + + // The activation must fail instead of writing the shared store: B would otherwise + // report pin "profile-y" with profile-x's settings and start tasks on them. + await expect(providerA.activateProviderProfile({ name: "profile-x" })).rejects.toThrow( + /pinned profile\(s\) could not be resolved: profile-y/, + ) + + expect(setSharedSettingsSpy).not.toHaveBeenCalled() + expect(providerA.contextProxy.getValue("currentApiConfigName")).not.toBe("profile-x") + // B keeps its own pin and no overlay: unchanged from before the aborted write. + expect(providerB["viewLocalState"].currentApiConfigName).toBe("profile-y") + expect(providerB["viewLocalState"].apiConfiguration).toBeUndefined() + + await providerA.dispose() + await providerB.dispose() + }) + + it("should still write the shared store when a sibling pins a deleted profile", 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(), + ) + await providerB.setValue("currentApiConfigName", "deleted-profile") + + 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: "x-id", name: "profile-x", apiProvider: providerIdentifiers.anthropic }, + ]) + // The sibling pins a profile the store no longer has: the deletion path re-points + // that view itself, so this activation must not be blocked by one stale pin. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockRejectedValue( + new ProviderSettingsNotFoundError("Config 'deleted-profile' not found"), + ) + const setSharedSettingsSpy = vi.spyOn(providerA.contextProxy, "setProviderSettings") + + await expect(providerA.activateProviderProfile({ name: "profile-x" })).resolves.not.toThrow() + + // The shared write proceeded, so the activation is not hostage to the stale pin. + expect(setSharedSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + }), + ) + expect(providerA.contextProxy.getValue("currentApiConfigName")).toBe("profile-x") + + 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() + 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() + }) + + // A4 regression: the history write runs before the TaskModeSwitched emit, so a listener + // that throws on that emit must not leave the persisted item on the new mode. + it("should roll task history back when a TaskModeSwitched listener throws", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const task = { + taskId: "emit-throw-task", + _taskMode: "code", + // The mode-switch emit is the reachable thrower here: it runs on the mode that the + // history write above already persisted. addClineToStack emits on the same task, so + // only the mode-switch event throws. + emit: vi.fn((event: RooCodeEventName) => { + if (event === RooCodeEventName.TaskModeSwitched) { + throw new Error("TaskModeSwitched listener down") + } + }), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } + // Minimal Task double: handleModeSwitch only touches the fields above. + await provider.addClineToStack(task as unknown as Task) + await provider["setViewStateId"]("emit-throw-view") + await provider.saveViewState("mode", "code") + const seedItem: HistoryItem = { + id: "emit-throw-task", + number: 1, + ts: 1, + task: "seed prompt", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + mode: "code", + } + // Double assertion: getTaskHistoryItem is private and has no typed accessor, and the + // seeded item is what makes the history write land before the throw. The spy is taken + // on this instance rather than the prototype so it cannot leak into a later test. + const historyApi = provider as unknown as { + getTaskHistoryItem: (id: string) => HistoryItem | undefined + } + const historySpy = vi.spyOn(historyApi, "getTaskHistoryItem").mockReturnValue(seedItem) + const updateSpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([seedItem]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + + await expect(provider.handleModeSwitch("architect", task as unknown as Task)).rejects.toThrow( + "TaskModeSwitched listener down", + ) + + // The switch wrote architect into history first and then undid it: the last write + // carries the pre-switch mode, so history cannot keep a mode the task never took. + expect(updateSpy).toHaveBeenCalledTimes(2) + expect(updateSpy.mock.calls[0][0]).toMatchObject({ mode: "architect" }) + expect(updateSpy.mock.calls[1][0]).toMatchObject({ mode: "code" }) + // The in-memory task mode was never moved: the emit threw before that write. + expect(task._taskMode).toBe("code") + + updateSpy.mockRestore() + historySpy.mockRestore() + 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() + }) + + it("should ignore a custom mode deleted while the switch waits for the mutation queue", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + await provider["setViewStateId"]("deleted-mode-view") + await provider.setValue("mode", "code") + const logSpy = vi.spyOn(provider, "log") + + // The file-level getModeBySlug mock ignores its customModes argument (and returns + // null for unknown slugs), so make the lookup custom-mode aware for this test. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalGetModeBySlug = modesModule.getModeBySlug.getMockImplementation() + type TestMode = { slug: string; name: string; roleDefinition: string; groups: string[] } + let customModes: TestMode[] = [ + { slug: "ephemeral-mode", name: "Ephemeral Mode", roleDefinition: "Test Role", groups: [] }, + ] + modesModule.getModeBySlug.mockImplementation( + ((slug: string, custom?: TestMode[]) => + custom?.find((mode) => mode.slug === slug) ?? + modesModule.modes.find((mode) => mode.slug === slug) ?? + null) as typeof modesModule.getModeBySlug, + ) + const getCustomModesSpy = vi + .spyOn(provider.customModesManager, "getCustomModes") + .mockImplementation(async () => customModes as never) + + try { + // Hold the queue so the switch is queued while the mode is still present. + let releaseQueue!: () => void + const queueGate = new Promise((resolve) => { + releaseQueue = resolve + }) + const queueHolder = provider["enqueueProviderProfileMutation"]( + async () => { + await queueGate + }, + { runToCompletion: true }, + ) + + const switchRun = provider.handleModeSwitch("ephemeral-mode", null) + + // The mode is deleted while the switch is still waiting for the queue: the re-read + // inside the queued callback is what notices, a pre-queue check cannot. + customModes = [] + releaseQueue() + await queueHolder + await switchRun + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ignoring unknown mode "ephemeral-mode"')) + // Nothing was pinned or written for the deleted slug. + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider.getValue("mode")).toBe("code") + } finally { + getCustomModesSpy.mockRestore() + + if (originalGetModeBySlug) { + modesModule.getModeBySlug.mockImplementation(originalGetModeBySlug) + } + } + + await provider.dispose() + }) + + it("should route a custom-mode deletion through the mutation queue", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + // The CustomModesManager double in this file has no deleteCustomMode member, so it is + // installed on the instance instead of spied. + const deleteSpy = vi.fn().mockResolvedValue(undefined) + Object.assign(provider.customModesManager, { deleteCustomMode: deleteSpy }) + + // Hold the queue: a deletion that bypasses it would run here, while a queued + // mode switch is still pending. + let releaseQueue!: () => void + const queueGate = new Promise((resolve) => { + releaseQueue = resolve + }) + const queueHolder = provider["enqueueProviderProfileMutation"]( + async () => { + await queueGate + }, + { runToCompletion: true }, + ) + + const deletion = provider.deleteCustomMode("ephemeral-mode") + await Promise.resolve() + expect(deleteSpy).not.toHaveBeenCalled() + + releaseQueue() + await queueHolder + await deletion + expect(deleteSpy).toHaveBeenCalledWith("ephemeral-mode") + + 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") + // The mock mirrors the double's setValue and fails only at the durable flush, after the + // state cache and the store both hold the new value: that is the half-applied shape the + // shared restore compensates (a key the failed write never landed is left alone). Only + // the fresh mode write fails; the rollback write succeeds. Delegating to the double's own + // setValue instead would recurse, because spying on an existing mock reuses it. + // Double assertion: the proxy double keeps its state cache in a private field, and the + // failure has to land after that write. + const stateCache = provider.contextProxy["stateCache"] as unknown as Record + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + if (value === undefined || value === null) { + delete stateCache[key] + } else { + stateCache[key] = value + } + await mockContext.globalState.update(key, value) + 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"], + ]) + + setValueSpy.mockRestore() + 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") + // Same shape as the test above - the value lands in the cache and the store, and the + // durable flush fails - except that every mode write fails here, the rollback's own + // attempt included. Only a shared value of ours can need a rollback, and only a failing + // rollback can need logging. + const stateCache = provider.contextProxy["stateCache"] as unknown as Record + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + if (value === undefined || value === null) { + delete stateCache[key] + } else { + stateCache[key] = value + } + await mockContext.globalState.update(key, value) + 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. + // The shared restore now runs inside setValue's own compensation (the + // reconcile skips it once the setter already compensated), so the failure + // surfaces under the setter's rollback log. + expect(appendLine).toHaveBeenCalledWith(expect.stringContaining("Shared-state rollback after failed")) + expect(appendLine).toHaveBeenCalledWith(expect.stringContaining('Failed to persist mode "ask"')) + + setValueSpy.mockRestore() + appendLine.mockRestore() + 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("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() + }) + + 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() + }) + }) + + 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 97a25a7038..fbcdbaeb97 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -25,17 +25,23 @@ import { openAiModelInfoSaneDefaults, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { CloudService } from "@roo-code/cloud" 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 { ClineProvider } from "../ClineProvider" +import { McpServerManager } from "../../../services/mcp/McpServerManager" import { webviewMessageHandler } from "../webviewMessageHandler" +import type { MdmService } from "../../../services/mdm/MdmService" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -425,6 +431,53 @@ 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, + runExclusiveAcrossInstances: runManagerSectionInline, + } + // 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) } +} + +// A same-name profile save joins the manager's cross-instance critical section, so a +// double that stubs the manager must still run the callback inline: the real lock does +// that whenever no other view holds the section. +const runManagerSectionInline = (cb: () => Promise): Promise => cb() + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -462,6 +515,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() @@ -600,6 +660,40 @@ 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() + }) + + it("ignores an instance whose disposal is already in flight", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Hold the disposal after _disposed is set: mcpHub.unregisterClient is awaited + // inside dispose(), so the view is still registered while the provider is + // already closing. The double is structural because only unregisterClient is + // reached before the gate is released. + let releaseDisposal!: () => void + const disposalGate = new Promise((resolve) => { + releaseDisposal = resolve + }) + Object.assign(provider, { mcpHub: { unregisterClient: () => disposalGate } }) + + const disposal = provider.dispose() + await vi.waitFor(() => expect(provider["_disposed"]).toBe(true)) + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBeUndefined() + + releaseDisposal() + await disposal + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -1058,7 +1152,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: { @@ -3233,6 +3327,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) @@ -3352,10 +3466,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, @@ -3367,7 +3484,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() @@ -3382,9 +3503,11 @@ describe("ClineProvider", () => { }) let statePostSettled = false - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - taskHistory: [], - } as unknown as ExtensionState) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + taskHistory: [], + }), + ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) const statePost = provider.postStateToWebviewWithoutTaskHistory() @@ -3482,13 +3605,4759 @@ describe("ClineProvider", () => { return [historyItem] }) - const state = await provider.getStateToPostToWebview() + 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 gets a unique viewId, prefixed by the render context it was built for. + expect(provider1.viewId).toMatch(/^sidebar-\d+$/) + expect(provider2.viewId).toMatch(/^editor-\d+$/) + 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("active instance tracking", () => { + it("removes the provider from active instances when dispose completes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A live provider is tracked... + expect(ClineProvider.getAllInstances()).toContain(provider) + + await provider.dispose() + + // ...and disposal releases it. + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + // Measured before the fix: one dispose() ran each of these releases twice - the teardown + // body performed the broker release, the listener removal and the MCP unregistration after + // the caller's finally had already done them. The second dispose() was already a no-op, so + // the existing guard test could not see the doubling; only a count across the pair can. + it("releases the broker, the listeners and the MCP registration exactly once across a double dispose", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await new Promise((resolve) => setImmediate(resolve)) + + const broker = vi.spyOn(provider["diagnosticsRequestBroker"], "dispose") + const listeners = vi.spyOn(provider, "removeAllListeners") + const unregister = vi.spyOn(McpServerManager, "unregisterProvider") + + await provider.dispose() + await provider.dispose() + + expect(broker).toHaveBeenCalledTimes(1) + expect(listeners).toHaveBeenCalledTimes(1) + expect(unregister).toHaveBeenCalledTimes(1) + expect(ClineProvider.getAllInstances()).not.toContain(provider) + + broker.mockRestore() + listeners.mockRestore() + unregister.mockRestore() + }) + + // The dispose-time hydrate was a merge artifact that neither parent carried: it started a + // state load against a provider that is already closing, with no live view to deliver to. + it("does not start a view-state load while disposing", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await new Promise((resolve) => setImmediate(resolve)) + + // loadViewState is private and private keys sit outside keyof, so the spy needs one + // assertion onto the method shape it actually has (AGENTS.md: last resort, commented). + const load = vi.spyOn( + ClineProvider.prototype as unknown as { loadViewState: () => Promise }, + "loadViewState", + ) + + await provider.dispose() + + expect(load).not.toHaveBeenCalled() + + load.mockRestore() + }) + + it("releases the provider and reports the failure when a cleanup step rejects", async () => { + // Force a cleanup step inside dispose() to reject. _disposed is set + // before cleanup runs, so the provider must still be released from + // activeInstances and the error must surface to the caller instead + // of being swallowed. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const taskHistoryDispose = vi.spyOn(provider.taskHistoryStore, "dispose").mockImplementation(() => { + throw new Error("task history store dispose failed") + }) + + expect(ClineProvider.getAllInstances()).toContain(provider) + + await expect(provider.dispose()).rejects.toThrow("task history store dispose failed") + + // The failing step is reported with its label and the original message. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "Provider cleanup step 'taskHistoryStore' failed: task history store dispose failed", + ) + + expect(ClineProvider.getAllInstances()).not.toContain(provider) + // The idempotency guard still holds: a second dispose is a no-op. + await provider.dispose() + + taskHistoryDispose.mockRestore() + }) + + it("runs every later cleanup step when an earlier step rejects", async () => { + // A single failing cleanup step must not skip the remaining releases: + // _disposed is already set, so skipped steps would leak for the life of + // the extension host. mcpHub.unregisterClient rejects; the manager and + // store releases that come after it must still run, and the first error + // must still surface to the caller. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // The constructor resolves McpServerManager.getInstance asynchronously and + // assigns this.mcpHub in its .then; let that settle first so the double + // below is not overwritten by the hub before dispose reads it. + await new Promise((resolve) => setImmediate(resolve)) + + // Structural double: dispose() only ever calls unregisterClient on mcpHub. + provider["mcpHub"] = { + unregisterClient: vi.fn().mockRejectedValue(new Error("mcp unregister failed")), + } as unknown as (typeof provider)["mcpHub"] + + const customModesDispose = vi.spyOn(provider["customModesManager"], "dispose") + const taskHistoryDispose = vi.spyOn(provider.taskHistoryStore, "dispose") + + await expect(provider.dispose()).rejects.toThrow("mcp unregister failed") + + expect(customModesDispose).toHaveBeenCalledOnce() + expect(taskHistoryDispose).toHaveBeenCalledOnce() + + customModesDispose.mockRestore() + taskHistoryDispose.mockRestore() + }) + + it("runs every cleanup branch when the provider is fully wired", async () => { + // Exercises the cleanup branches dispose() skips in the default unit harness: a + // task on the stack, a live view, registered disposables, and an initialized + // CloudService. Spies and exact log strings pin the cleanup sequence so a + // removed or reordered step fails the test. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A task on the stack forces the eviction branch (evictCurrentTask). drain + // calls task.dispose(), so a task double with a spied dispose() proves the + // drain ran without spying the private method itself. + const fakeTask = { + taskId: "dispose-test-task", + dispose: vi.fn().mockResolvedValue(undefined), + } as unknown as Task + provider["taskRegistry"].push(fakeTask) + const evictCurrentTask = vi.spyOn(provider, "evictCurrentTask").mockImplementation(async () => { + provider["taskRegistry"].pop() + }) + // Defensive pop: if the eviction branch is mutated away, the stack-drain while + // loop still terminates instead of spinning on a task that is never removed. + const removeClineFromStack = vi.spyOn(provider, "removeClineFromStack").mockImplementation(async () => { + provider["taskRegistry"].pop() + }) + const clearAllPendingEditOperations = vi.spyOn(provider["pendingEditOperations"], "clearAll") + + // A live view and registered disposables exercise the webview teardown branches. + const viewDispose = vi.fn() + // @ts-ignore - accessing private property for testing + provider.view = { dispose: viewDispose, visible: true } + const webviewDisposableDispose = vi.fn() + provider["webviewDisposables"].push({ dispose: webviewDisposableDispose }) + const disposableDispose = vi.fn() + provider["disposables"].push({ dispose: disposableDispose }) + + // An initialized CloudService instance exercises the listener-detach branch. + // The @roo-code/cloud mock above reports hasInstance() === true by default, + // so only the static instance getter needs swapping. It uses the same + // defineProperty style the getTelemetryProperties tests use (and restores + // the original descriptor exactly) so no mock getter leaks into later tests. + // Spying on the mock's hasInstance instead would restore a cleared + // implementation and break those tests. + const cloudOff = vi.fn() + const originalInstanceDescriptor = Object.getOwnPropertyDescriptor(CloudService, "instance")! + // Minimal EventEmitter stand-in: only off() is observed. + Object.defineProperty(CloudService, "instance", { + get: () => ({ off: cloudOff }) as unknown as CloudService, + configurable: true, + }) + + try { + await provider.dispose() + + // The eviction branch ran, and only the eviction branch (not the stack drain); + // drainTaskDisposal() completed through the task's own dispose(). + expect(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(fakeTask.dispose).toHaveBeenCalledTimes(1) + expect(removeClineFromStack).not.toHaveBeenCalled() + // Webview teardown and registered disposables ran. + expect(viewDispose).toHaveBeenCalledTimes(1) + expect(webviewDisposableDispose).toHaveBeenCalledTimes(1) + expect(disposableDispose).toHaveBeenCalledTimes(1) + expect(clearAllPendingEditOperations).toHaveBeenCalledTimes(1) + // The cloud service listener was detached with the exact event and handler. + expect(cloudOff).toHaveBeenCalledWith("settings-updated", provider["handleCloudSettingsUpdate"]) + // The exact log strings pin the cleanup sequence. + for (const line of [ + "Cleared all tasks", + "Cleared pending operations", + "Disposed webview", + "Disposed all disposables", + ]) { + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(line) + } + } finally { + Object.defineProperty(CloudService, "instance", originalInstanceDescriptor) + } + + evictCurrentTask.mockRestore() + removeClineFromStack.mockRestore() + clearAllPendingEditOperations.mockRestore() + }) + + it("completes disposal when the optional manager instances are absent", async () => { + // The constructor always initializes the manager instances, so the skip branches + // of the optional call sites in dispose() are unreachable otherwise: null them + // to prove disposal completes when they are absent instead of throwing on the + // de-referenced call. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + Object.assign(provider, { + _workspaceTracker: undefined, + skillsManager: undefined, + marketplaceManager: undefined, + customModesManager: undefined, + }) + + await expect(provider.dispose()).resolves.toBeUndefined() + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + + it("skips falsy disposable entries instead of calling dispose on them", async () => { + // The disposal loop guards each popped entry with a falsy check: a falsy + // entry must be skipped so disposal still resolves. Calling dispose() on it + // would throw and make dispose() reject instead. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const realDispose = vi.fn() + provider["disposables"].push({ dispose: realDispose }) + // @ts-ignore - push a falsy entry to exercise the falsy guard + provider["disposables"].push(undefined) + + await expect(provider.dispose()).resolves.toBeUndefined() + // The real entry was still disposed; the falsy one was skipped. + expect(realDispose).toHaveBeenCalledTimes(1) + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + }) + + 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("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") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + + 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("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() + }) + }) + + 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("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() + }) + + it("discards an in-flight load when a reset clears view state mid-load", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("reset-view") + + await provider.contextProxy.setValue("viewStates", { + "reset-view": { currentApiConfigName: "stale-profile", updatedAt: 1 }, + }) + + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + + const loading = provider["loadViewState"]() + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // A reset/import invalidation clears the view-local buffer mid-load (the + // broadcast path does exactly this). The clear is not a field mutation, so + // the mutation tracking above cannot protect it: the generation must. + provider["_clearViewLocalState"]() + + resolveProfile({ + name: "stale-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await loading + + // The stale load must not resurrect the selections the reset cleared. + expect(provider["viewLocalState"]).toEqual({}) + + 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() + }) + }) + + 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("setter rollback scope", () => { + it("restores the shared store when the initial multi-key shared write fails", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + await contextProxy.setValue("mode", "code") + + // The value reaches the store and only the flush fails: that is the partial-durability + // shape the rollback must compensate, and it is what the shared restore below checks + // against (a key the failed call never wrote is left alone). + const setValuesOriginal = contextProxy.setValues.bind(contextProxy) + const setValuesSpy = vi.spyOn(contextProxy, "setValues").mockImplementationOnce(async (values) => { + await setValuesOriginal(values) + throw new Error("shared write failed") + }) + + await expect(provider.setValues({ mode: "architect" })).rejects.toThrow("shared write failed") + + // The first call is the failed forward write; the second is the rollback + // restoring the prior shared value through the same proxy. + expect(setValuesSpy).toHaveBeenCalledTimes(2) + expect(setValuesSpy.mock.calls[1][0]).toEqual({ mode: "code" }) + // The per-view buffer never saw the failed value. + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.dispose() + }) + + it("restores the shared store when the initial single-key shared write fails", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + await contextProxy.setValue("mode", "code") + + // Same partial-durability shape as the multi-key case above: the value lands in the + // store and the write still rejects, so the shared restore has a value of ours to undo. + const setValueOriginal = contextProxy.setValue.bind(contextProxy) + const setValueSpy = vi.spyOn(contextProxy, "setValue").mockImplementationOnce(async (key, value) => { + await setValueOriginal(key, value) + throw new Error("shared write failed") + }) + const restoreSpy = vi.spyOn(contextProxy, "setValues") + + await expect(provider.setValue("mode", "architect")).rejects.toThrow("shared write failed") + + // The rollback routes through setValues (shared restore helper), and it + // must carry the prior value, not the failed one. (Later setValue calls + // belong to teardown; the forward write is the first call.) + expect(setValueSpy.mock.calls[0]).toEqual(["mode", "architect"]) + expect(restoreSpy).toHaveBeenCalledWith({ mode: "code" }) + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("broadcastResetToAllInstances durable failure", () => { + it("clears every live buffer and posts siblings even when the durable clear fails", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Live selections that a failed durable clear must not strand. + provider["viewLocalState"] = { mode: "architect" } + sibling["viewLocalState"] = { mode: "architect" } + const siblingPost = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + // The first proxy write is the queued durable clear of the shared slot. + const setValueSpy = vi + .spyOn(contextProxy, "setValue") + .mockRejectedValueOnce(new Error("durable clear failed")) + + await expect(provider.broadcastResetToAllInstances()).rejects.toThrow("durable clear failed") + + // Live invalidation ran despite the durable failure, and the caller still + // sees the durable error after the loop completed. + expect(provider["viewLocalState"]).toEqual({}) + expect(sibling["viewLocalState"]).toEqual({}) + expect(siblingPost).toHaveBeenCalledTimes(1) + expect(setValueSpy).toHaveBeenCalledWith("viewStates", undefined) + + setValueSpy.mockRestore() + await provider.dispose() + await sibling.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(), + ) + // Author the pre-launch entry through the real write path so this instance + // owns it: unowned temporary entries are never re-keyed. + + // 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("restores the previous view id when the persisted load fails, so a later launch reloads it", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + await contextProxy.setValue("viewStates", { "view-b": { mode: "architect", updatedAt: 1 } }) + + // getCustomModes is read before the pinned mode can be restored and its rejection + // is swallowed inside loadViewState (only logged), so this is the load failure that + // the throw-based rollback never saw. + const getCustomModesSpy = vi + .spyOn(provider["customModesManager"], "getCustomModes") + .mockRejectedValueOnce(new Error("modes unavailable")) + + await provider["setViewStateId"]("view-b") + + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(provider["viewLocalState"]).toEqual({}) + + // The retried registration loads the pin instead of early-returning on the id the + // failed load left behind. + getCustomModesSpy.mockRestore() + await provider["setViewStateId"]("view-b") + expect(provider["viewStateId"]).toBe("view-b") + expect(provider["viewLocalState"].mode).toBe("architect") + + 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() + }) + + 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("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("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("aborts the shared settings write when a pinned sibling's profile cannot be snapshotted", async () => { + 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 { + sibling["viewLocalState"].currentApiConfigName = "sibling-profile" + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "sibling-profile", id: "sibling-id", apiProvider: providerIdentifiers.openrouter }, + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (params) => { + if ("name" in params && params.name === "activated-profile") { + return { + name: "activated-profile", + id: "activated-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "activated-key", + } + } + throw new Error("sibling profile read failed") + }) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("mode-id") + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + // The snapshot must abort before the manager is asked to switch: the store's + // selected profile and the mode that follows it stay the pre-activation pair. + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const sharedSettingsBefore = provider.contextProxy.getProviderSettings() + + // The abort names the pin it could not snapshot. Match the pin rather than the + // wording: the sibling branches that carry this rule phrase the abort differently, + // and the rule - not the sentence - is what must stay covered. + await expect(provider.activateProviderProfile({ name: "activated-profile" })).rejects.toThrow( + /sibling-profile/, + ) + + expect(activateProfileSpy).not.toHaveBeenCalled() + + // The sibling is still pinned to its own profile: if the shared write had + // landed, its getState() would report that pin with the activated profile's + // settings and credentials. + expect(sibling["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.contextProxy.getProviderSettings()).toEqual(sharedSettingsBefore) + } finally { + await sibling.dispose() + 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=architect 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, + runExclusiveAcrossInstances: runManagerSectionInline, + } + // 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")), + runExclusiveAcrossInstances: runManagerSectionInline, + } + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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(), + ) + stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + let flipped = false + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (args) => { + const name = "name" in args ? args.name : undefined + if (name === "cfg-a" && !flipped) { + flipped = true + // Only the profile name changes while the lookup is in flight. + await provider.saveViewState("currentApiConfigName", "cfg-b") + } + return Object.assign({} as Awaited>, { + name, + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: name === "cfg-a" ? "model-x" : "model-y", + }) + }) + + await provider["setViewStateId"]("stable-sidebar-view") + + // The in-flight selection wins over the loaded state, and the settings follow it: + // getState() pairs this view pin with this overlay and createTask hands that pair to the new + // task, which records the same name - pairing cfg-b with cfg-a settings would run the new task + // on a profile the view does not pin. Dropping the settings is not safe either: the view would + // fall back to shared settings that can belong to a third profile. + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-b") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.providerSettingsManager.getProfile).toHaveBeenCalledWith({ name: "cfg-b" }) + await provider.dispose() + }) + it("drops the loaded settings when a rename during the load no longer resolves", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const logSpy = vi.spyOn(provider, "log") + let flipped = false + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (args) => { + const name = "name" in args ? args.name : undefined + if (name === "cfg-a" && !flipped) { + flipped = true + await provider.saveViewState("currentApiConfigName", "gone-profile") + } + if (name === "gone-profile") { + throw new Error("profile deleted") + } + return Object.assign({} as Awaited>, { + name, + id: `${name}-id`, + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + }) + + await provider["setViewStateId"]("stable-sidebar-view") + + // The reapplied name is what the view holds, so it stays; only the settings that no longer + // belong to it are dropped, and the view falls back to the shared store for them. + expect(provider["viewLocalState"].currentApiConfigName).toBe("gone-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'gone-profile'")) + 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 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() + }) + + 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() + }) + }) + + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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, + } + const savedForReadBack1: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack1[name] = settings + return "fresh-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "fresh-id", + ...savedForReadBack1[name], + })), + 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("waits for another view's critical section before saving the profile record", async () => { + const events: string[] = [] + const savedForRival: Record> = {} + let releaseOtherView!: () => void + const otherViewHold = new Promise((resolve) => { + releaseOtherView = resolve + }) + // A promise-chain double, matching the real shared lock: a caller queues behind + // whoever holds the section instead of running inline. + let section: Promise = Promise.resolve() + const runExclusiveAcrossInstances = (cb: () => Promise): Promise => { + const run = section.then(cb) + section = run.then( + () => {}, + () => {}, + ) + return run + } + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + saveConfig: vi.fn(async (savedName: string, savedSettings: Record) => { + events.push("saveConfig") + savedForRival[savedName] = savedSettings + return "rival-id" + }), + getProfile: vi.fn(async ({ name: readName }: { name: string }) => ({ + name: readName, + id: "rival-id", + ...savedForRival[readName], + })), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // Another view's rename holds the section across its name check, its save, and its + // compensation delete. + const heldSection = runExclusiveAcrossInstances(async () => { + events.push("otherViewEnter") + await otherViewHold + events.push("otherViewExit") + }) + const upsert = provider.upsertProviderProfile("rival-profile", { + apiProvider: providerIdentifiers.anthropic, + }) + await new Promise((resolve) => setImmediate(resolve)) + + // The rename's rollback deletes whatever the new name holds, so a save that skipped + // the section would be deleted by the other view's compensation. + expect(events).toEqual(["otherViewEnter"]) + + releaseOtherView() + await upsert + await heldSection + + expect(events).toEqual(["otherViewEnter", "otherViewExit", "saveConfig"]) + 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, + } + const savedForReadBack2: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack2[name] = settings + return "fresh-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "fresh-id", + ...savedForReadBack2[name], + })), + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi + .fn() + .mockRejectedValue(new Error("Failed to get profile: Config with name 'old-profile' not found")), + // getProfile wraps absence and a storage read failure in one plain Error, so the + // deletion classifies absence with hasConfig: the record here is absent. + hasConfig: vi.fn().mockResolvedValue(false), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + // The delete compensation snapshots the manager gate (its selection and mode map) before + // committing; this double reports a gate the deletion never moved, so the gated restore + // legs stay inert here. + getSelectionAndModeConfigs: vi + .fn() + .mockResolvedValue({ currentApiConfigName: "default", modeApiConfigs: {} }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // 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("fails the upsert and compensates the record when the post-save read-back fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The pre-save snapshot sees the prior record; the post-save read-back + // fails: the raw caller payload must never stand in for the validated + // stored record. + let saved = false + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async () => { + saved = true + return "p-id" + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async () => { + if (saved) { + throw new Error("read-back down") + } + return { + name: "p", + id: "p-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + } + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + + await expect( + provider["persistProviderProfile"]( + "p", + { apiProvider: providerIdentifiers.anthropic, apiKey: "raw-key" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("could not be read back") + + // The landed save was compensated with the prior record, and no + // activation write carried the raw payload into the shared store. + expect(saveSpy).toHaveBeenLastCalledWith("p", expect.objectContaining({ apiKey: "prior-key" })) + expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "p") + + await provider.dispose() + }) + + it("aborts the save when the pre-save profile lookup fails for a reason other than absence", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The record exists but its storage read fails. getProfile wraps absence and read + // failure in one plain Error, so treating every rejection as absence would take the + // create path here, and the compensation below would then deleteConfig a profile + // that exists and take its credentials with it. + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue( + new Error("Failed to get profile: secret store read down"), + ) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("p-id") + const deleteSpy = vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + await expect( + provider["persistProviderProfile"]( + "p", + { apiProvider: providerIdentifiers.anthropic, apiKey: "raw-key" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("secret store read down") + + // Nothing was written and nothing was compensated: the failure surfaced before + // saveConfig, so there was no landed save to roll back. + expect(saveSpy).not.toHaveBeenCalled() + expect(deleteSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("aborts the deletion when the pre-delete lookup fails for a reason other than absence", 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") + // The record is present but unreadable: continuing would delete the secrets-backed + // record and then skip its restore, because the snapshot came back empty. + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue( + new Error("Failed to get profile: secret store read down"), + ) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + const deleteSpy = vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("x") + + await expect(provider.deleteProviderProfile(oldProfile)).rejects.toThrow("secret store read down") + + expect(deleteSpy).not.toHaveBeenCalled() + expect(saveSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("restores the manager record and shared state when a post-delete write 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.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (params) => { + if ("name" in params && params.name === "old-profile") { + return { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "old-key", + } + } + return { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + } + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("x") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The first write after the committed manager delete fails: the + // deletion must be compensated, not left half-applied. + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key) => { + if (key === "listApiConfigMeta") { + throw new Error("state write down") + } + }) + + await expect(provider.deleteProviderProfile(oldProfile)).rejects.toThrow("state write down") + + // The secrets-backed record was restored with its pre-delete settings and its + // original id: the record is gone at that point, so a save that drops the id mints a + // new one while mode mappings and task pins still name the original. + expect(saveSpy).toHaveBeenCalledWith( + "old-profile", + expect.objectContaining({ apiKey: "old-key", id: "old-id" }), + ) + + // The secrets-backed record was restored with its pre-delete settings. + expect(saveSpy).toHaveBeenCalledWith("old-profile", expect.objectContaining({ apiKey: "old-key" })) + + await provider.dispose() + }) + + it("keeps the mutation queue owned by a mode switch past its timeout", async () => { + // The fake timers, the provider, the gate and the prototype spy are declared + // before the try below so its finally block can name them: a failed assertion + // must not leave the gate unresolved, the spy installed, or the timers faked + // for every later test in this file. + vi.useFakeTimers() + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + // Bracket access: handleModeSwitchUnlocked is private; the double + // assertion is the only way to install the gate on the prototype. + const proto = ClineProvider.prototype as unknown as { + handleModeSwitchUnlocked: (...args: unknown[]) => Promise + } + const switchSpy = vi.spyOn(proto, "handleModeSwitchUnlocked").mockReturnValue(gate) + try { + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) + // The caller-side timeout rejection is expected: runToCompletion only + // keeps the QUEUE waiting for the run, it does not silence the caller. + const switchSettled = provider.handleModeSwitch("programmer").catch(() => undefined) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1000) + + // The queue tail is still the mode switch: a later mutation must + // not start while the switch's writes are still in flight. + let secondStarted = false + void provider["enqueueProviderProfileMutation"](async () => { + secondStarted = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(secondStarted).toBe(false) + + release() + await switchSettled + await vi.advanceTimersByTimeAsync(0) + expect(secondStarted).toBe(true) + } finally { + release() + switchSpy.mockRestore() + vi.useRealTimers() + await provider.dispose() + } + }) + + it("leaves a sibling view's newer shared write alone when rolling back a failed write", async () => { + // Production shares one ContextProxy across views (ContextProxy.getInstance); the two + // views here share one too, so a sibling write lands in the same shared store. + const sharedProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + sharedProxy, + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + sharedProxy, + new WebviewFocusTracker(), + ) + await sharedProxy.setValue("mode", "code") + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) + // The shared write lands, then a sibling view moves the same shared key while this + // view's paired per-view write is still in flight and then fails. + // Double assertion: _saveViewLocalStateFromMutation is private and has no typed + // accessor, and the failure has to be injected inside the paired write. + const proto = ClineProvider.prototype as unknown as { + _saveViewLocalStateFromMutation: (values: Record) => Promise + } + const saveSpy = vi.spyOn(proto, "_saveViewLocalStateFromMutation").mockImplementation(async () => { + await sibling.contextProxy.setValue("mode", "programmer") + throw new Error("view write down") + }) + + await expect(provider.setValues({ mode: "architect" })).rejects.toThrow("view write down") + + // The rollback replays only keys that still hold what this call wrote, so the sibling's + // newer shared value survives instead of being reverted to the captured "code". + expect(provider.contextProxy.getValue("mode")).toBe("programmer") + expect(sibling.contextProxy.getValue("mode")).toBe("programmer") + + saveSpy.mockRestore() + await provider.dispose() + await sibling.dispose() + }) + it("should refresh the view-local apiConfiguration when deleting the active 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, + } + const keeperSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "keeper-key" } + // @ts-ignore - Replace providerSettingsManager with a test double: the view was + // pinned to the deleted profile, so the deletion takes the activation path and + // reads the survivor's settings through activateProfile. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi + .fn() + .mockRejectedValue(new Error("Failed to get profile: Config with name 'old-profile' not found")), + // getProfile wraps a missing record and a storage read failure in one plain Error, + // so the deletion classifies absence with hasConfig: this record is absent. + hasConfig: vi.fn().mockResolvedValue(false), + + runExclusiveAcrossInstances: runManagerSectionInline, + activateProfile: vi + .fn() + .mockResolvedValue({ name: "keeper-profile", id: "keeper-id", ...keeperSettings }), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + // The delete compensation snapshots the manager gate (its selection and mode map) before + // committing; this double reports a gate the deletion never moved, so the gated restore + // legs stay inert here. + getSelectionAndModeConfigs: vi + .fn() + .mockResolvedValue({ currentApiConfigName: "default", modeApiConfigs: {} }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + // A stale view-local apiConfiguration (the deleted profile's settings) that would + // keep shadowing the surviving profile's settings in getState() if the activation + // path did not clear 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.deleteProviderProfile(oldProfile) + + // The shared provider keys are rewritten with the survivor's settings and the + // view's stale overlay is cleared, so getState() serves the survivor's + // settings instead of the deleted profile's. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration).toMatchObject(keeperSettings) + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + await provider.dispose() + }) + + it("should swallow only the typed not-found signal when pruning a stale profile entry", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const staleProfile: ProviderSettingsEntry = { + name: "stale-profile", + id: "stale-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [staleProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The secret was already pruned: the typed not-found must be an idempotent + // success so the stale list entry is still removed. + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockRejectedValue( + new ProviderSettingsNotFoundError(`Config 'stale-profile' not found`), + ) + + await provider.deleteProviderProfile(staleProfile) + + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keeperProfile]) + await provider.dispose() + }) + + it("should propagate a non-not-found deletion failure for a profile named like the not-found message", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profile: ProviderSettingsEntry = { + name: "not found config", + id: "nf-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [profile, keeperProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // An unrelated failure (wrapped the way deleteConfig wraps storage errors) + // must not be mistaken for the idempotent not-found path just because the + // profile name contains "not found". + const deleteConfigSpy = vi + .spyOn(provider.providerSettingsManager, "deleteConfig") + .mockRejectedValue( + new Error(`Failed to delete config: Error: storage write failed for 'not found config'`), + ) + + await expect(provider.deleteProviderProfile(profile)).rejects.toThrow("storage write failed") + + // The list entry must remain untouched when the deletion failed. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([profile, keeperProfile]) + expect(deleteConfigSpy).toHaveBeenCalledTimes(1) + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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), + getProfile: vi + .fn() + .mockRejectedValue(new Error("Failed to get profile: Config with name 'old-profile' not found")), + // getProfile wraps a missing record and a storage read failure in one plain Error, + // so the deletion classifies absence with hasConfig: this record is absent. + hasConfig: vi.fn().mockResolvedValue(false), + // The delete compensation snapshots the manager gate (its selection and mode map) before + // committing; this double reports a gate the deletion never moved, so the gated restore + // legs stay inert here. + getSelectionAndModeConfigs: vi + .fn() + .mockResolvedValue({ currentApiConfigName: "default", modeApiConfigs: {} }), + } + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + // The delete compensation snapshots the manager gate (its selection and mode map) before + // committing; this double reports a gate the deletion never moved, so the gated restore + // legs stay inert here. + getSelectionAndModeConfigs: vi + .fn() + .mockResolvedValue({ currentApiConfigName: "default", modeApiConfigs: {} }), + } + 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() + }) + + 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(), + // The settled delete classifies record absence with hasConfig rather than by + // message text, snapshots the manager's own selection/mode gate before the + // delete commits, and restores through saveConfig, so the double answers all + // three reads. + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + getSelectionAndModeConfigs: vi.fn().mockResolvedValue({ + currentApiConfigName: undefined, + modeApiConfigs: {}, + }), + saveConfig: vi.fn().mockResolvedValue("old-id"), + } + 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("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(), + // Same settled delete reads as above: the record is present here, so the + // classification read never runs and the gate snapshot resolves. + getProfile: vi.fn().mockResolvedValue(oldProfile), + hasConfig: vi.fn().mockResolvedValue(true), + getSelectionAndModeConfigs: vi.fn().mockResolvedValue({ + currentApiConfigName: undefined, + modeApiConfigs: {}, + }), + saveConfig: vi.fn().mockResolvedValue("old-id"), + } + 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("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), + // The settled delete also snapshots the manager's selection/mode gate before + // committing and can restore through saveConfig; the double answers both. + hasConfig: vi.fn().mockResolvedValue(true), + getSelectionAndModeConfigs: vi.fn().mockResolvedValue({ + currentApiConfigName: undefined, + modeApiConfigs: {}, + }), + saveConfig: vi.fn().mockResolvedValue("victim-id"), + } + 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() + }) + }) + + // The two deletions an unpinned view can meet, measured separately rather than averaged. + // This one removes a profile the shared selection does not name, so nothing this view + // resolves through disappears; the deletion still hands the view the selection through + // activation, and the durable record gains a pin the view never authored. + it("anchors a view with no pin of its own on the shared selection when an unrelated profile is deleted", 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", "keeper-profile") + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-shared", + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const keeperSettings = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const activateProfile = vi.fn().mockResolvedValue(keeperSettings) + // @ts-ignore - Replace the manager with a structural double for this deletion. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + activateProfile, + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi + .fn() + .mockImplementation(async ({ name }: { name: string }) => + name === "keeper-profile" + ? keeperSettings + : { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.openrouter }, + ), + hasConfig: vi.fn().mockResolvedValue(false), + getSelectionAndModeConfigs: vi.fn().mockResolvedValue({ + currentApiConfigName: "keeper-profile", + modeApiConfigs: {}, + }), + } + + await provider.deleteProviderProfile(oldProfile) + + const served = await provider.getState({ includeTaskHistory: false }) + + // Measured on the current head: the deletion still takes the activation path here, so the + // view comes back anchored to the selection it was following. What it serves is unchanged - + // the shared store already held that profile's settings - but the pin is new. The case + // below isolates the deletion the activation path is actually for. + expect(activateProfile).toHaveBeenCalledWith({ name: "keeper-profile" }) + expect(provider.pinnedProfileName).toBe("keeper-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + expect(served.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(served.currentApiConfigName).toBe("keeper-profile") + await provider.dispose() + }) + + // The matching leg for the same unpinned view: when the deleted profile is the shared + // selection this view resolves through, the replacement must be activated for it - the + // shared store still carries the deleted profile's settings until this path replaces + // them. Measured identical before and after the tightened condition below. + it("activates the replacement for a view with no pin when the deleted profile is the shared selection", 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.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "old-shared", + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const keeperSettings = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const activateProfile = vi.fn().mockResolvedValue(keeperSettings) + // @ts-ignore - Replace the manager with a structural double for this deletion. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + activateProfile, + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi + .fn() + .mockImplementation(async ({ name }: { name: string }) => + name === "keeper-profile" + ? keeperSettings + : { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.openrouter }, + ), + hasConfig: vi.fn().mockResolvedValue(false), + getSelectionAndModeConfigs: vi.fn().mockResolvedValue({ + currentApiConfigName: "keeper-profile", + modeApiConfigs: {}, + }), + } + + await provider.deleteProviderProfile(oldProfile) + + const served = await provider.getState({ includeTaskHistory: false }) + + expect(activateProfile).toHaveBeenCalledWith({ name: "keeper-profile" }) + expect(provider.pinnedProfileName).toBe("keeper-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + expect(served.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + 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(), + ) + const savedForReadBack3: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack3[name] = settings + return "activated-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "activated-id", + ...savedForReadBack3[name], + })), + 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", + }) + + const savedForReadBack4: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack4[name] = settings + return "profile-b-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "profile-b-id", + ...savedForReadBack4[name], + })), + 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("restores the cached viewStates map when a persisted view-state write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.contextProxy.setValue("viewStates", { + "other-view": { mode: "code", updatedAt: 1 }, + }) + // The next durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["savePersistedViewState"]({ mode: "architect", updatedAt: 2 })).rejects.toThrow( + "durable write failed", + ) + + // The in-memory cache must match durable storage: an entry that never + // reached disk cannot stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "other-view": { mode: "code", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a re-key write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id through the real write + // path, so the re-key has a real entry to move and this instance authored it. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The re-key's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["rekeyPersistedViewStateEntry"]("stable-sidebar-view")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the stable entry never reached disk, + // and the retry path depends on the temporary entry still being visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + }) + // Authorship survives the failure so a later registration can re-key again. + expect(provider["wroteUnderTemporaryViewStateId"]).toBe(true) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("upserts activation writes from the stored normalized record, not the raw payload", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + // The stored record is the normalized shape saveConfig persists: the schema + // parse strips the stray cross-provider field and keeps the stored model. + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + id: "profile-id", + name: "profile-a", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-a", { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "raw-model", + openRouterApiKey: "cross-provider-stray", + }) + + // The shared store receives the stored record: the raw payload's stray + // cross-provider field must not reach it (or the sibling overlays and + // task handler refreshed from the same value). + expect(setProviderSettingsSpy).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + await provider.dispose() + }) + + it("restores the cached viewStates map when a clear write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The clear's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["clearPersistedViewState"]()).rejects.toThrow("durable write failed") + + // The cache must match durable storage: the entry was never removed on + // disk, so it must stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted re-point write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + + // The re-point's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["repointPersistedViewStates"]("profile-old", "profile-new")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the re-pointed pins never reached disk, + // so the pre-re-point map must stay visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("re-points live views pinned to a renamed profile onto the new name", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "profile-old") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "old-key", + }) + + await provider["repointViewLocalStateAfterProfileRename"]("profile-old", "profile-new", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "renamed-key", + }) + + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-new") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "renamed-key", + }) + const viewStates = mockContext.globalState.get("viewStates") as Record> + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-new") + await provider.dispose() + }) + it("keeps the applied profile mutation when a sibling's state post fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const postError = new Error("sibling post failed") + const sibling = { + viewId: "sibling-view", + pinnedProfileName: "profile-new", + _saveViewLocalStateFromMutation: vi.fn().mockResolvedValue(undefined), + getCurrentTask: vi.fn().mockReturnValue(undefined), + postStateToWebview: vi.fn().mockRejectedValue(postError), + } + const logSpy = vi.spyOn(provider, "log") + const instancesSpy = vi + .spyOn(ClineProvider, "getAllInstances") + .mockReturnValue([provider, sibling] as unknown as ClineProvider[]) + try { + // A sibling whose UI refresh fails must not reject the applied mutation; the + // failure is logged and the remaining siblings still refresh. + await expect( + provider["refreshViewLocalStateForUpdatedProfile"]("profile-new", { + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.toBeUndefined() + + expect(sibling._saveViewLocalStateFromMutation).toHaveBeenCalledOnce() + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("failed to post state to sibling view sibling-view"), + ) + } finally { + instancesSpy.mockRestore() + logSpy.mockRestore() + } + await provider.dispose() + }) + + it("re-points persisted pins of closed views onto the renamed profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A closed view's durable pin names the profile about to be renamed; no + // live instance carries it, so only the persisted map can repair it. + await provider.contextProxy.setValue("viewStates", { + "closed-view": { currentApiConfigName: "profile-old", mode: "code", updatedAt: 1 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "profile-old") + + await provider["repointViewLocalStateAfterProfileRename"]("profile-old", "profile-new", { + apiProvider: providerIdentifiers.openrouter, + }) + + const viewStates = mockContext.globalState.get("viewStates") as Record> + // The closed view must launch onto the renamed profile instead of dangling at + // the deleted name and being repaired to whatever the shared selection became + // in the meantime. + expect(viewStates["closed-view"].currentApiConfigName).toBe("profile-new") + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-new") + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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, + openRouterApiKey: "view-key", + }) + + await provider.setValues({ apiProvider: providerIdentifiers.anthropic, apiKey: "flat-key" }) + + // The overlay is removed, not merely out-shadowed: the merged read serves this view's + // flat write and carries no per-view apiConfiguration left behind, so a later shared + // update from another view cannot be hidden behind an older copy of this view's own. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.getValues().apiConfiguration).toBeUndefined() + expect(provider.getValues().apiProvider).toBe(providerIdentifiers.anthropic) + expect(provider.getValues().apiKey).toBe("flat-key") + + await provider.dispose() + }) + + it("pins a sibling's overlay before a flat provider write in setValues", async () => { + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("flat-write-view") + await sibling.saveViewState("currentApiConfigName", "pinned-profile") + const siblingPostSpy = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "pinned-profile", + id: "pinned-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "pinned-key", + }) + + await provider.setValues({ apiProvider: providerIdentifiers.anthropic, apiKey: "caller-key" }) + + // The sibling keeps serving its own profile's credentials instead of + // reading the caller's flat write back from the shared store. + expect(sibling["viewLocalState"].apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "pinned-key", + }) + expect(siblingPostSpy).toHaveBeenCalled() + + await sibling.dispose() + await provider.dispose() + }) + + it("refreshes siblings pinned to the profile a setValues payload saves", async () => { + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("saved-profile-view") + await sibling.saveViewState("currentApiConfigName", "saved-profile") + await sibling.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + const siblingPostSpy = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.setValues( + { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" }, + { savedProfileName: "saved-profile" }, + ) + + // The payload saves the sibling's own profile: the sibling serves the + // fresh copy rather than a snapshot of the pre-save settings. + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + expect(siblingPostSpy).toHaveBeenCalled() + + await sibling.dispose() + await provider.dispose() + }) + }) + + it("drops the sibling overlay snapshot when the sibling re-pins before the commit", async () => { + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("repin-view") + await sibling.saveViewState("currentApiConfigName", "pinned-profile") + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(sibling.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "other-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(sibling.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(sibling.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(sibling.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + // The sibling activates a different profile while the snapshot's profile + // read is in flight: the captured overlay must not land on the new pin. + let flipped = false + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async () => { + const settings = { + name: "pinned-profile", + id: "pinned-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "pinned-key", + } + if (!flipped) { + flipped = true + await sibling.activateProviderProfile({ name: "other-profile" }) + } + return settings + }) + + await provider.setValues({ apiProvider: providerIdentifiers.anthropic, apiKey: "caller-key" }) + + // The commit-time pin re-check skipped the stale snapshot: the sibling + // keeps serving the profile it activated, not the captured overlay. + expect(sibling["viewLocalState"].apiConfiguration).toBeUndefined() + expect(sibling["pinnedProfileName"]).toBe("other-profile") + + await sibling.dispose() + 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 keep a mode whose custom definition arrives in the same setValues payload", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // createTask and setConfiguration call setValues(configuration) before + // registering configuration.customModes: the payload's own modes must count + // as valid even though the manager has not registered them yet. + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string, customModes?: { slug: string }[]) => + customModes?.some((mode) => mode.slug === slug) + ? { slug } + : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ + customModes: [{ slug: "new-mode", name: "New Mode", roleDefinition: "test", groups: ["read"] }], + mode: "new-mode", + }) + + expect(provider.contextProxy.getValue("mode")).toBe("new-mode") + expect(provider["viewLocalState"].mode).toBe("new-mode") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("restores the previous mode when custom-mode registration fails during createTask", async () => { + await provider.setValues({ mode: "architect" }) + // setValues mirrors the write into the view buffer; drop it so this case + // models a view that never pinned a mode of its own. + delete provider["viewLocalState"].mode + provider["customModesManager"].updateCustomMode = vi.fn().mockRejectedValue(new Error("registration down")) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + mode: "reviewer", + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // The payload's mode reached shared + per-view state through setValues + // before registration failed; the failure path must roll both back. + expect(provider.getValues().mode).toBe("architect") + // The view had no pin before, and an unset pin must stay unset rather + // than becoming an explicit copy of the shared value. + expect(provider["viewLocalState"].mode).toBeUndefined() + }) + + it("restores the view's own mode pin separately when custom-mode registration fails", async () => { + await provider.setValues({ mode: "code" }) + provider["viewLocalState"].mode = "architect" + provider["customModesManager"].updateCustomMode = vi.fn().mockRejectedValue(new Error("registration down")) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + mode: "reviewer", + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // Shared and per-view selections roll back to their own prior values: + // the view keeps its architect pin, the shared store keeps code. + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("mode")).toBe("code") + expect(provider.getValues().mode).toBe("architect") + }) + + 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 () => { + // Hold the first *viewStates* write specifically. The constructor writes other + // global-state keys synchronously (codebaseIndexModels at ClineProvider.ts:509), + // so an unqualified once-only mock is consumed by that write: the viewStates + // write then resolves immediately and is never held across the id switch, which + // is the in-flight behaviour this test claims to prove. + let releaseFirstViewStatesWrite!: () => void + let viewStatesWriteHeld = false + let markFirstWriteStarted!: () => void + const firstWriteStarted = new Promise((resolve) => { + markFirstWriteStarted = resolve + }) + + mockContext.globalState.update = vi.fn().mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + + if (key !== "viewStates" || viewStatesWriteHeld) { + return Promise.resolve() + } + + viewStatesWriteHeld = true + markFirstWriteStarted() + + return new Promise((writeResolve) => { + releaseFirstViewStatesWrite = writeResolve + }) + }) + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + // The first write is held, so the second one is captured under "view-a" but only + // executes after the id switch: that is the interleaving this test names. + const heldSave = provider.saveViewState("currentApiConfigName", "cfg-a") + await firstWriteStarted + const queuedSave = provider.saveViewState("mode", "architect") + const idSwitch = provider["setViewStateId"]("view-b") + releaseFirstViewStatesWrite() + await heldSave + await queuedSave + await idSwitch + + // Both writes stay under the id active when each change was made, even though the + // second one ran after "view-b" had been registered. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect", currentApiConfigName: "cfg-a" }, + }) + 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() + }) + + it("deregisters the provider when an early cleanup step throws during dispose", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // The first cleanup step throwing used to skip the deregistration below + // it: the provider stayed in activeInstances while _disposed was already + // set, so no later retry could repair the registration. The finally now + // covers the whole disposal operation. + provider["taskScheduler"] = { + cancelQueued: vi.fn(() => { + throw new Error("early cleanup boom") + }), + } as unknown as ClineProvider["taskScheduler"] + expect(ClineProvider.getAllInstances()).toContain(provider) + + await expect(provider.dispose()).rejects.toThrow("early cleanup boom") + + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + + it("keeps a newer view pin written during custom-mode registration when rollback runs", async () => { + await provider.setValues({ mode: "code" }) + provider["viewLocalState"].mode = "code" + // Another view moves this view's pin while registration is in flight; + // the rollback must not overwrite that newer per-view choice. + provider["customModesManager"].updateCustomMode = vi.fn().mockImplementation(async () => { + await provider.saveViewState("mode", "architect") + throw new Error("registration down") + }) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + mode: "reviewer", + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // The view layer no longer holds this call's "reviewer", so the rollback + // leaves the newer architect pin alone; the shared layer still does and + // rolls back to its own prior value. + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("mode")).toBe("code") + }) + + it("does not touch the shared mode on rollback when the configuration omitted mode", async () => { + await provider.setValues({ mode: "code" }) + delete provider["viewLocalState"].mode + provider["customModesManager"].updateCustomMode = vi.fn().mockImplementation(async () => { + await provider.contextProxy.setValue("mode", "architect") + throw new Error("registration down") + }) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // This call never wrote a mode, so the rollback must leave the shared + // store alone even though the captured previous value differs. + expect(provider.contextProxy.getValue("mode")).toBe("architect") + }) + + it("keeps a newer shared mode written by another view when custom-mode registration fails", async () => { + await provider.setValues({ mode: "code" }) + delete provider["viewLocalState"].mode + // updateCustomMode awaits queued file I/O: while it is in flight another + // view moves the shared mode, and only then does registration fail. + provider["customModesManager"].updateCustomMode = vi.fn().mockImplementation(async () => { + await provider.contextProxy.setValue("mode", "architect") + throw new Error("registration down") + }) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + mode: "reviewer", + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // The rollback owns the shared layer only while it still holds the value + // this call wrote; the other view's newer choice must survive. + expect(provider.contextProxy.getValue("mode")).toBe("architect") + // The view layer still held this call's value, so its own rollback ran: + // the pre-call absence is restored rather than left as "reviewer". + expect(provider["viewLocalState"].mode).toBeUndefined() + }) + + it("deregisters the provider even when MCP cleanup rejects during dispose", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // An MCP client whose disposal rejects used to abort dispose() before + // the registry cleanup below it: the provider stayed in activeInstances + // while _disposed was already set, so no later retry could repair the + // registration. Deregistration now runs in a finally path. + // The constructor resolves McpServerManager.getInstance asynchronously and + // assigns this.mcpHub in its .then, so let that settle first: installing the + // double before the hub lands would have it overwritten before dispose reads it. + await new Promise((resolve) => setImmediate(resolve)) + + // Structural double: dispose() only ever calls unregisterClient on mcpHub. + provider["mcpHub"] = { + unregisterClient: vi.fn().mockRejectedValue(new Error("mcp disposal down")), + } as unknown as ClineProvider["mcpHub"] + expect(ClineProvider.getAllInstances()).toContain(provider) + + await expect(provider.dispose()).rejects.toThrow("mcp disposal down") + + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + }) + + 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() + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + await provider.dispose() + }) + + 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(), + ) - expect(getStateSpy).toHaveBeenCalledOnce() - expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) - expect(getAllSpy).toHaveBeenCalledOnce() - expect(historyReadPhases).toEqual([true]) - expect(state.taskHistory).toEqual([historyItem]) + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bogus-provider") + + await provider.dispose() + }) + + 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(), + ) + + // 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) + + const state = await provider.getState() + + expect(state.codebaseIndexModels).toBe(EMBEDDING_MODEL_PROFILES) + + await provider.dispose() + }) }) describe("postStateToWebviewThrottled", () => { @@ -3669,6 +8538,22 @@ describe("ClineProvider", () => { expect(mockPostMessage).not.toHaveBeenCalled() }) + test("postMessageToWebview logs a delivery that resolves false", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockResolvedValue(false) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + // A resolved false is a drop (the page is not deliverable), not a success: it must + // reach the log so a wedged channel is diagnosable. + await vi.waitFor(() => { + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[postMessageToWebview] undelivered message type=action"), + ) + }) + }) + test("dispose is idempotent β€” second call is a no-op", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -3682,6 +8567,27 @@ describe("ClineProvider", () => { expect(disposeCalls).toHaveLength(1) }) + test("dispose still deregisters the provider when task eviction fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + Object.assign(provider, { taskRegistry: new TaskRegistry() }) + provider["taskRegistry"].push({ + taskId: "stuck-task", + instanceId: "stuck-instance", + emit: vi.fn(), + abortTask: vi.fn(), + dispose: vi.fn(), + } as unknown as Task) + // Bracket notation keeps the private member accessible without a cast. + provider["evictCurrentTask"] = vi.fn().mockRejectedValue(new Error("eviction failed")) + + await expect(provider.dispose()).rejects.toThrow("eviction failed") + + // _disposed is already set, so a second dispose() returns early: a teardown failure + // must deregister the provider instead of stranding a live-looking entry that + // setViewStateId would keep treating as a claim on its viewStateId. + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + test("dispose drains every task in abort-then-cleanup order", async () => { let resolveCurrentAbort!: () => void let resolveCurrentCleanup!: () => void @@ -3821,6 +8727,47 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) + it("should re-pin the view to a valid global selection when the first listed profile has no name", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double: the view's pinned config no + // longer exists, the shared global selection is still valid, and the only listed profile is a + // legacy entry without a name. + provider.providerSettingsManager = { + hasConfig: vi.fn(async (name: string) => name === "global-valid"), + listConfig: vi.fn(async () => [{ id: "legacy-id", apiProvider: providerIdentifiers.openai }]), + saveConfig: vi.fn(async () => "legacy-id"), + dispose: vi.fn(), + } + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The view's own pin points at a deleted profile; the shared global selection is still valid. + await provider.saveViewState("currentApiConfigName", "gone-pin") + await provider.contextProxy.setValue("currentApiConfigName", "global-valid") + + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview") + const contextProxySetValueSpy = vi.spyOn(provider.contextProxy, "setValue") + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + // The launch flow's profile-list sync is fire-and-forget; wait for its final post. + await vi.waitFor(() => + expect(postMessageSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "listApiConfig" })), + ) + + // The valid shared selection is adopted by this view only: the view-local pin switches to it + // while the shared global selection is left untouched for the other views. + expect(provider["viewLocalState"].currentApiConfigName).toBe("global-valid") + expect(mockContext.globalState.get("currentApiConfigName")).toBe("global-valid") + // A nameless first listed profile must never clear the shared selection with undefined. + expect(contextProxySetValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", undefined) + + await provider.dispose() + }) + test("logs detached workspace initialization failures", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -4560,38 +9507,12 @@ describe("ClineProvider", () => { expect(state.diffFuzzyThreshold).toBe(0.5) }) - it("loads saved API config when switching modes", async () => { - await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - - const profile: ProviderSettingsEntry = { - name: "test-config", - id: "test-id", - apiProvider: providerIdentifiers.anthropic, - } - - ;(provider as any).providerSettingsManager = { - getModeConfigId: vi.fn().mockResolvedValue("test-id"), - listConfig: vi.fn().mockResolvedValue([profile]), - activateProfile: vi.fn().mockResolvedValue(profile), - setModeConfig: vi.fn(), - getProfile: vi.fn().mockResolvedValue(profile), - } as any - - // Switch to architect mode - await messageHandler({ type: "mode", text: "architect" }) - - // Should load the saved config for architect mode - expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") - expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "test-config" }) - expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") - }) - it("saves current config when switching to mode without config", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -4621,6 +9542,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), @@ -4648,6 +9570,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), @@ -4823,13 +9746,23 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack5: Record> = {} ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi .fn() .mockResolvedValue([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), - saveConfig: vi.fn().mockResolvedValue("test-id"), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack5[name] = settings + return "test-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "test-id", + ...savedForReadBack5[name], + })), setModeConfig: vi.fn(), } as any @@ -5174,6 +10107,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue("saved-config-id"), listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), @@ -5181,11 +10115,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") @@ -5198,6 +10140,7 @@ describe("ClineProvider", () => { test("saves current config when switching to mode without config", async () => { ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -5207,19 +10150,25 @@ describe("ClineProvider", () => { setModeConfig: vi.fn(), } as any - // Mock the ContextProxy's getValue method to return the current config name - const contextProxy = (provider as any).contextProxy - const getValueSpy = vi.spyOn(contextProxy, "getValue") - getValueSpy.mockImplementation((key: any) => { - if (key === "currentApiConfigName") return "current-config" - return undefined - }) + // Seed the shared current config name: the mode-default recording reads the + // view's effective profile, which falls back to the shared slot when this + // view pins no profile of its own. + const contextProxy = provider.contextProxy + await contextProxy.setValue("currentApiConfigName", "current-config") + + // 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") @@ -5260,6 +10209,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -5286,8 +10236,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'.", ) @@ -5324,6 +10276,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() @@ -5359,8 +10312,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 @@ -5388,6 +10342,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -5407,8 +10362,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") @@ -5419,6 +10375,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -5463,6 +10420,7 @@ describe("ClineProvider", () => { // Mock provider settings manager to throw error ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() @@ -5559,12 +10517,165 @@ describe("ClineProvider", () => { }) }) + // A custom-mode deletion or save writes "mode" straight into the shared store, so this + // view's durable pin keeps the slug the call just wrote away from it: a view pinned to a + // deleted mode goes on reporting a slug that no longer exists. The pinned and the + // unpinned view are held apart here instead of averaged into one case. + describe("custom-mode deletion and the mode pin", () => { + beforeEach(() => { + // The harness CustomModesManager double has no deleteCustomMode member, so it is + // installed on the instance the way the mutation-queue test installs it. + Object.assign(provider.customModesManager, { + deleteCustomMode: vi.fn().mockResolvedValue(undefined), + getCustomModes: vi.fn().mockResolvedValue([ + { + slug: "gone-mode", + name: "Gone Mode", + roleDefinition: "A mode that is about to be deleted", + groups: ["read"] as const, + }, + ]), + }) + }) + + const handlerForView = async (viewStateId: string) => { + await provider.resolveWebviewView(mockWebviewView) + await provider["setViewStateId"](viewStateId) + return vi.mocked(mockWebviewView.webview.onDidReceiveMessage).mock.calls[0][0] + } + + // getState() is what the fan-out serializes for this view: it merges the view's pin + // over the shared store, so it is the mode the webview actually renders. + const servedMode = async () => (await provider.getState()).mode + // Both slugs the cases below name, so the mode the provider validates against always + // carries the mode the view under test is pinned to. + const seedTwoModes = () => { + Object.assign(provider.customModesManager, { + getCustomModes: vi.fn().mockResolvedValue([ + { + slug: "gone-mode", + name: "Gone Mode", + roleDefinition: "A mode that is about to be deleted", + groups: ["read"] as const, + }, + { + slug: "keep-mode", + name: "Keep Mode", + roleDefinition: "A mode this view is pinned to", + groups: ["read"] as const, + }, + ]), + }) + } + + it("moves a view pinned to the deleted mode onto the default mode", async () => { + const messageHandler = await handlerForView("pinned-deleted-view") + await provider.saveViewState("mode", "gone-mode") + + await messageHandler({ type: "deleteCustomMode", slug: "gone-mode" }) + + expect(provider.customModesManager.deleteCustomMode).toHaveBeenCalledWith("gone-mode") + // Before the routing fix the shared store moved to the default slug while this view + // kept pinning the deleted one, so the webview was shown "gone-mode" and the next + // task would record a slug no mode list contains. + expect(await servedMode()).toBe("code") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "pinned-deleted-view": { mode: "code" }, + }) + }) + + it("reports the default mode to a view that has no pin of its own", async () => { + const messageHandler = await handlerForView("unpinned-deleted-view") + // The shared selection is the slug being deleted - the selection this reset exists + // for - and the view has no pin of its own to keep it away from it. + await provider.contextProxy.setValue("mode", "gone-mode") + + await messageHandler({ type: "deleteCustomMode", slug: "gone-mode" }) + + // An unpinned view follows the shared store either way; what routing adds is the + // record. A mode write through the provider pins this view's copy to the slug the + // shared store now holds, which is the rule the updateSettings path already follows. + expect(await servedMode()).toBe("code") + expect(provider.contextProxy.getValue("mode")).toBe("code") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "unpinned-deleted-view": { mode: "code" }, + }) + }) + + // Deleting one mode must not move a view that is pinned to a different one: the reset + // keys off the mode this view actually resolves to - its pin, or the shared selection + // when it has no pin of its own. + it("keeps a view pinned to another mode on that mode when a different mode is deleted", async () => { + const messageHandler = await handlerForView("pinned-other-view") + seedTwoModes() + await provider.saveViewState("mode", "keep-mode") + + await messageHandler({ type: "deleteCustomMode", slug: "gone-mode" }) + + expect(await servedMode()).toBe("keep-mode") + expect(provider["viewLocalState"].mode).toBe("keep-mode") + // The shared selection was never the deleted slug, so nothing writes over it here. + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + }) + + it("resets the shared selection without moving a view pinned to another mode", async () => { + const messageHandler = await handlerForView("pinned-other-shared-view") + seedTwoModes() + // The shared selection is the mode being deleted while this view pins another one: + // the shared layer must move so unpinned views stop resolving to the deleted slug, + // while this view keeps its own pin. + await provider.contextProxy.setValue("mode", "gone-mode") + await provider.saveViewState("mode", "keep-mode") + + await messageHandler({ type: "deleteCustomMode", slug: "gone-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBe("code") + expect(provider["viewLocalState"].mode).toBe("keep-mode") + expect(await servedMode()).toBe("keep-mode") + }) + + it("moves the pin onto the mode a pinned view just saved", async () => { + const messageHandler = await handlerForView("pinned-saved-view") + await provider.saveViewState("mode", "gone-mode") + Object.assign(provider.customModesManager, { + getCustomModes: vi.fn().mockResolvedValue([ + { + slug: "new-mode", + name: "New Mode", + roleDefinition: "A custom mode this view just saved", + groups: ["read"] as const, + }, + ]), + }) + + await messageHandler({ + type: "updateCustomMode", + modeConfig: { + slug: "new-mode", + name: "New Mode", + roleDefinition: "A custom mode this view just saved", + groups: ["read"] as const, + }, + }) + + // Saving a mode makes this view use it; a view pinned elsewhere must follow, not + // stay on the earlier slug while the shared store moved on. + expect(await servedMode()).toBe("new-mode") + expect(provider["viewLocalState"].mode).toBe("new-mode") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "pinned-saved-view": { mode: "new-mode" }, + }) + }) + }) + describe("upsertApiConfiguration", () => { test("handles error in upsertApiConfiguration gracefully", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), listConfig: vi .fn() @@ -5597,9 +10708,19 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack6: Record> = {} ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack6[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack6[name], + })), listConfig: vi .fn() .mockResolvedValue([ @@ -5642,9 +10763,19 @@ describe("ClineProvider", () => { ;(buildApiHandler as any).mockImplementationOnce(() => { throw new Error("API handler error") }) + const savedForReadBack7: Record> = {} ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack7[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack7[name], + })), listConfig: vi .fn() .mockResolvedValue([ @@ -5685,9 +10816,19 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack8: Record> = {} ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack8[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack8[name], + })), listConfig: vi .fn() .mockResolvedValue([ @@ -6473,6 +11614,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 @@ -6491,6 +11637,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 @@ -6532,6 +11683,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 @@ -8121,6 +13277,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi.fn().mockResolvedValue([]), } @@ -8152,6 +13309,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const saveConfig = vi.fn().mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi.fn().mockResolvedValue([ { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, @@ -8180,12 +13338,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, ) }) @@ -8193,6 +13354,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider, "getState").mockRejectedValue(new Error("state unavailable")) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi.fn().mockResolvedValue([]), } @@ -8211,6 +13373,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi.fn(), } @@ -8226,6 +13389,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), @@ -8247,6 +13411,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), @@ -8267,6 +13432,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { const handleSpy = vi.spyOn(provider, "handleZooCodeCallback").mockResolvedValue(undefined) ;(provider as any).providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), @@ -8282,4 +13448,286 @@ 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) + const savedForReadBack9: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack9[name] = settings + return "saved-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "saved-id", + ...savedForReadBack9[name], + })), + 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) + const savedForReadBack10: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack10[name] = settings + return "saved-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "saved-id", + ...savedForReadBack10[name], + })), + 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" + const savedForReadBack11: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack11[name] = settings + return "saved-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "saved-id", + ...savedForReadBack11[name], + })), + 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 = { + runExclusiveAcrossInstances: runManagerSectionInline, + 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 47fcfd11f0..9cacc7373f 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([]) } @@ -362,11 +387,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( @@ -381,7 +414,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: [], @@ -415,8 +448,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") @@ -466,6 +499,1042 @@ 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("rolls back every mode-switch write when the mode-linked profile activation 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, pin, and shared writes all land; the mode-linked profile + // activation then rejects. The caller (submitUserMessage) logs the failure + // and delivers the message, so no write may stand: the message must run the + // pre-switch mode against the unchanged profile, not a half-applied switch + // that pairs the new task mode with the old profile's settings. + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + // Seed both pre-switch mode stores explicitly: the pin buffer and the + // shared ContextProxy value, so the restore assertions have a value that + // differs from the switched "architect". + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockRejectedValue( + new Error("activation read failed"), + ) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation read failed", + ) + + // No partial application: the task keeps its previous mode, the history + // entry is restored, and the view's durable pin and the shared mode are + // rolled back too. + expect(mockTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).toHaveBeenLastCalledWith(historyItem) + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider.getValue("mode")).toBe("code") + }) + + it("keeps the committed mode switch when the post-activation state refresh fails", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The mode-linked profile resolves and its activation lands (writing the + // shared profile like the real activation does); only the trailing state + // post rejects. By then the switch has fully committed - ModeChanged was + // emitted and the activation persisted the new profile - so a UI refresh + // failure must not roll the mode back: that would pair the old mode with + // the new profile, the very mismatch the reconcile exists to prevent. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + provider["activateProviderProfileUnlocked"] = vi.fn().mockImplementation(async () => { + await provider.contextProxy.setValue("currentApiConfigName", "architect-profile") + }) + vi.spyOn(provider, "postStateToWebview").mockRejectedValue(new Error("post failed")) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("architect-profile") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("keeps the switched mode when the mode-linked activation fails after writing", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The mode-linked activation lands its shared writes and only then rejects + // (e.g. its own state post or setModeConfig step failing). A mode-only rollback + // would pair the old mode with the profile the activation already switched to, + // the mismatch the reconcile exists to prevent: the switch must stand and the + // error must still surface. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + provider["activateProviderProfileUnlocked"] = vi.fn().mockImplementation(async (_args, options) => { + // The real activation flips the committed-switch guard when it writes + // the activated profile; mirror that here. + options?.onSharedWriteStart?.() + await provider.contextProxy.setValue("currentApiConfigName", "architect-profile") + throw new Error("activation step failed") + }) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation step failed", + ) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("architect-profile") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("keeps the switched mode when the mutation signal aborted before the failure", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The pin and shared writes land; then the mutation timeout aborts this + // switch and a later step rejects. The queue has already advanced past the + // timed-out switch, so a newer mutation may own the mode stores: the late + // rollback must not replay the pre-switch values over them. + const controller = new AbortController() + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + controller.abort() + throw new Error("late activation read failure") + }) + await provider.addClineToStack(mockTask) + + await expect( + provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal), + ).rejects.toThrow("late activation read failure") + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("does not report ModeChanged for a mode switch that gets reconciled", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + const emitSpy = vi.spyOn(provider, "emit") + + // The task, pin, and shared writes land and the activation read then rejects: + // the reconcile rolls every store back, so listeners must not be left tracking + // the rolled-back mode: ModeChanged must not have fired for it, and the task's + // TaskModeSwitched must be re-emitted with the restored mode. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockRejectedValue( + new Error("activation read failed"), + ) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation read failed", + ) + + expect(emitSpy).not.toHaveBeenCalledWith("modeChanged", "architect") + expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", "test-task-id", "architect") + expect(mockTask.emit).toHaveBeenLastCalledWith("taskModeSwitched", "test-task-id", "code") + }) + + it("keeps the committed switch when the lock-path state refresh fails", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // With the workspace profile lock on the switch commits without a mode-linked + // activation; the trailing post now runs outside the reconciled region, so its + // failure must leave the committed switch standing instead of rolling the mode + // stores back. + vi.spyOn(provider["context"].workspaceState, "get").mockImplementation((key: unknown, def?: unknown) => + key === "lockApiConfigAcrossModes" ? true : def, + ) + vi.spyOn(provider, "postStateToWebview").mockRejectedValue(new Error("post failed")) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("records the mode default from the view's pinned profile, not the shared slot", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The view pins profile-x while a sibling view's activation moved the shared + // slot to profile-y. Recording the mode default from the shared slot would make + // profile-y the default for the new mode in every view, though this view was + // running profile-x. + await provider.setValue("currentApiConfigName", "profile-x") + await provider.contextProxy.setValue("currentApiConfigName", "profile-y") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "x-id", name: "profile-x", apiProvider: providerIdentifiers.openrouter }, + { id: "y-id", name: "profile-y", apiProvider: providerIdentifiers.anthropic }, + ]) + const setModeConfig = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask) + + expect(setModeConfig).toHaveBeenCalledWith("architect", "x-id") + }) + + it("reconciles the switch when the mode-linked profile lookup fails", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The REAL activation is exercised: its profile lookup throws before the + // activated profile is written, so the committed-switch guard must still be + // off (the callback fires only after activateProfile resolves) and the + // reconcile must restore every mode write. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockRejectedValue( + new Error("profile not found"), + ) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "profile not found", + ) + + expect(provider.getValue("mode")).toBe("code") + expect(provider["viewLocalState"].mode).toBe("code") + expect(mockTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("keeps the switch when the activation fails after the profile write", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The real activation resolves its profile lookup - flipping the + // committed-switch guard at that point - and only a later shared write + // fails: the activated profile is already in place, so the switch must + // be kept rather than reconciled. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + id: "architect-profile-id", + name: "architect-profile", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("set-mode-config failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "set-mode-config failed", + ) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("architect-profile") + // The kept switch must leave the task state and history on the + // committed mode too: rolling the task-side writes back here would + // diverge the task from the mode stores. + expect(mockTask["_taskMode"]).toBe("architect") + expect(updateTaskHistorySpy).toHaveBeenLastCalledWith(expect.objectContaining({ mode: "architect" })) + }) + + it("reports ModeChanged for a switch the catch keeps after activation wrote", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The activation writes the activated profile and only then rejects: the catch + // keeps the switch, and listeners plus the webview must still learn the + // committed mode before the error surfaces. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + const emitSpy = vi.spyOn(provider, "emit") + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["activateProviderProfileUnlocked"] = vi.fn().mockImplementation(async (_args, options) => { + options?.onSharedWriteStart?.() + await provider.contextProxy.setValue("currentApiConfigName", "architect-profile") + throw new Error("activation step failed") + }) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation step failed", + ) + + expect(emitSpy).toHaveBeenCalledWith("modeChanged", "architect") + expect(postSpy).toHaveBeenCalled() + expect(provider.getValue("mode")).toBe("architect") + }) + + it("reports the committed mode when the signal aborts after the mode writes", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The pin and shared mode writes land, then the mutation signal aborts during + // the activation reads. The activation is skipped, but the committed mode + // must still reach listeners and the webview through the emit/post tail. + const emitSpy = vi.spyOn(provider, "emit") + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const controller = new AbortController() + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + controller.abort() + return undefined + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + expect(provider.getValue("mode")).toBe("architect") + expect(emitSpy).toHaveBeenCalledWith("modeChanged", "architect") + expect(postSpy).toHaveBeenCalled() + }) + + it("stays silent when a newer mutation supersedes the aborted switch", async () => { + // Same typed double as the activation-failure rollback test above. + 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) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The abort lands after this switch's mode writes, and a newer mutation + // immediately moves the mode stores to its own mode: this switch's commit + // is superseded, so announcing it would clobber the newer owner - the + // emit/post tail must stay silent. + const emitSpy = vi.spyOn(provider, "emit") + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const controller = new AbortController() + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + controller.abort() + await provider.setValue("mode", "ask") + return undefined + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + expect(provider.getValue("mode")).toBe("ask") + expect(emitSpy).not.toHaveBeenCalledWith("modeChanged", "architect") + expect(postSpy).not.toHaveBeenCalled() + }) + 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", () => { @@ -486,14 +1555,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 () => { @@ -690,11 +1759,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 () => { @@ -770,7 +1847,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") @@ -858,6 +1937,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() @@ -870,12 +1952,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] @@ -936,16 +2022,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", @@ -959,14 +2043,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 () => { @@ -1219,22 +2312,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 () => { @@ -1275,4 +2381,162 @@ 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" }, + }) + + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + try { + expect(fresh.viewId).toBe(tempId) + + // Await the load itself instead of a fixed timer: the negative + // assertion must run after the load has definitely finished. + await fresh["loadViewState"]() + + // 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 { + 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()) + // Captured before the try below so its finally block can put the mock back. + const originalUpdate = mockContext.globalState.update + try { + expect(fresh.viewId).toBe(tempId) + + mockContext.globalState.update = vi.fn().mockImplementation((key: string, _value: unknown) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(fresh.saveViewState("mode", "code")).rejects.toThrow("storage unavailable") + expect(fresh["wroteUnderTemporaryViewStateId"]).toBe(false) + } finally { + // Restored first: dispose below writes globalState, and the rejecting + // mock would make that teardown reject and swallow the assertion failure. + 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 11a3fafeaf..8e66b5d914 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -324,7 +324,15 @@ describe("ClineProvider - Sticky Provider Profile", () => { }) }) - afterEach(() => { + afterEach(async () => { + // Dispose the provider this test created: a leaked instance stays in the static + // registry with the profile pin it activated, and a later test's fresh settings store + // cannot resolve that pin - which now (correctly) aborts that test's shared write. + // Teardown errors are swallowed because dispose() deregisters in its finally even when + // a double in this file is incomplete (the CloudService mock has no off()), and a + // teardown failure must not fail assertions that already ran. + await provider?.dispose().catch(() => undefined) + if (originalRooCliRuntimeEnv === undefined) { delete process.env.ROO_CLI_RUNTIME } else { @@ -1110,4 +1118,126 @@ 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"]) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index bf50f08786..6dcb4c29e4 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 * as vsCodeLmProvider from "../../../api/providers/vscode-lm" import * as copilotProvider from "../../../api/providers/github-copilot" @@ -107,13 +109,37 @@ const mockGetAccountId = vi.mocked(openAiCodexOAuthManager.getAccountId) const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInfo) // Mock ClineProvider +// The Zoo Gateway sign-out tests hand the handler a per-scenario proxy / manager / upsert stub on +// the provider double. The real members are readonly, so the tests assign through a mutable view +// of the provider type instead of `as any` (AGENTS.md: no new `as any`); only the members the +// sweep touches are declared, which keeps the doubles honest about the rest. +type Mutable = { -readonly [K in keyof T]: T[K] } +type MutableSignOutProvider = Mutable> & { + contextProxy: { + setValue: (key: string, value: unknown) => Promise + getValue: (key: string) => unknown + getProviderSettings: () => Record + getValues: () => { currentApiConfigName?: string } & Record + } + providerSettingsManager: { + listConfig: () => Promise> + getProfile: (params: { name?: string; id?: string }) => Promise> + saveConfig: (name: string, config: Record) => Promise + } + upsertProviderProfile: (settings: Record, profileId?: string) => Promise +} const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + saveViewState: vi.fn(), customModesManager: { getCustomModes: vi.fn(), + updateCustomMode: vi.fn(), deleteCustomMode: vi.fn(), }, + // Custom-mode deletion is routed through the provider so it joins the profile mutation + // queue; the manager call itself moves inside that method. + deleteCustomMode: vi.fn(), context: { extensionPath: "/mock/extension/path", globalStorageUri: { fsPath: "/mock/global/storage" }, @@ -125,7 +151,21 @@ const mockClineProvider = { }, setValue: vi.fn(), getValue: vi.fn(), + // Declared on the double so the sign-out tests can hand the handler the reads it makes on + // the proxy, and re-satisfy the proxy shape when they replace it. + getProviderSettings: vi.fn(), + getValues: 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(), @@ -134,6 +174,7 @@ const mockClineProvider = { createTaskWithHistoryItem: vi.fn(), renameTaskWithId: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -865,6 +906,188 @@ 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 + + // The fixture is module-level and earlier tests in this describe run the launch path, + // so reset the flag or the launched assertion below cannot fail. + ;(mockClineProvider as { isViewLaunched: boolean }).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) + }) + + // Capture the fixture's pre-suite values for the members this suite reassigns: + // the module-level fixture does not declare them, and vi.clearAllMocks() only + // resets call history β€” it never restores property assignments, so without this + // restore the launch doubles leak into every later suite in this file. + const originalLaunchMembers = { + setViewStateId: double.setViewStateId, + workspaceTracker: double.workspaceTracker, + providerSettingsManager: double.providerSettingsManager, + activateProviderProfile: double.activateProviderProfile, + getMcpHub: double.getMcpHub, + getStateToPostToWebview: double.getStateToPostToWebview, + } + + afterEach(() => { + Object.assign(double, originalLaunchMembers) + }) + + it("validates the view-local currentApiConfigName on launch", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(double.setViewStateId).toHaveBeenCalledWith("view-1") + + // The merged (view-local) name is validated first; the shared global is only + // consulted when the view-local name is invalid. + expect(double.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(mockClineProvider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("shared-profile") + // Both names are invalid in this setup, so the shared global is repaired. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith({ name: "shared-profile" }) + }) + + it("re-pins only the view when its profile is missing but the shared global is still valid", async () => { + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The view pin is re-pinned to the first available profile, + // and the shared global selection is left untouched: no global write, no global activation. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + // The stale view-local overlay is cleared with the pin: a buffer still + // holding the missing profile's settings would keep serving them on top of + // the re-pinned selection. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("apiConfiguration", undefined) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "currentApiConfigName", + "shared-profile", + ) + expect(mockClineProvider.activateProviderProfile).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.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.saveViewState).not.toHaveBeenCalledWith("currentApiConfigName", "first-listed") + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("clears the stale overlay before persisting the re-pin so a failed pin stays retryable", async () => { + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + const calls: Array<[string, unknown]> = [] + const originalSaveViewState = mockClineProvider.saveViewState + mockClineProvider.saveViewState = vi.fn(async (key: string, value: unknown) => { + calls.push([key, value]) + if (key === "currentApiConfigName") { + throw new Error("pin write failed") + } + }) + try { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + } finally { + mockClineProvider.saveViewState = originalSaveViewState + } + + // The overlay clear must land first: if the pin write then fails, the merged + // selection stays invalid and the repair retries on the next launch, instead of + // a valid pin serving the deleted profile's buffered settings. + expect(calls[0]).toEqual(["apiConfiguration", undefined]) + expect(calls).toContainEqual(["currentApiConfigName", "shared-profile"]) + }) + + 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() @@ -951,6 +1174,16 @@ describe("webviewMessageHandler - image mentions", () => { const mockHandleWebviewAskResponse = vi.fn() vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ cwd: "/mock/workspace", + // Declared on the double: the sign-out sweep reaches the manager for the profile records, and + // the tests hand it a per-scenario record store. + providerSettingsManager: { + listConfig: vi.fn(), + getProfile: vi.fn(), + saveConfig: vi.fn(), + }, + // The sweep needs the upsert’s confirmation value (the saved profile id), so each test hands + // in its own stub instead of casting the provider. + upsertProviderProfile: vi.fn(), rooIgnoreController: undefined, handleWebviewAskResponse: mockHandleWebviewAskResponse, } as any) @@ -1706,13 +1939,13 @@ describe("webviewMessageHandler - deleteCustomMode", () => { } as ModeConfig, ]) vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true) - vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.deleteCustomMode).mockResolvedValue(undefined) await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug }) // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() - expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug) + expect(mockClineProvider.deleteCustomMode).toHaveBeenCalledWith(slug) expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true }) }) @@ -1731,13 +1964,13 @@ describe("webviewMessageHandler - deleteCustomMode", () => { } as ModeConfig, ]) vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true) - vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.deleteCustomMode).mockResolvedValue(undefined) await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug }) // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() - expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug) + expect(mockClineProvider.deleteCustomMode).toHaveBeenCalledWith(slug) expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true }) }) @@ -1753,13 +1986,13 @@ describe("webviewMessageHandler - deleteCustomMode", () => { } as ModeConfig, ]) vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false) - vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.deleteCustomMode).mockResolvedValue(undefined) await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug }) // The confirmation dialog is now handled in the webview, so we don't expect showInformationMessage to be called expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() - expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug) + expect(mockClineProvider.deleteCustomMode).toHaveBeenCalledWith(slug) expect(fs.rm).not.toHaveBeenCalled() }) @@ -1778,12 +2011,12 @@ describe("webviewMessageHandler - deleteCustomMode", () => { } as ModeConfig, ]) vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true) - vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.deleteCustomMode).mockResolvedValue(undefined) vi.mocked(fs.rm).mockRejectedValue(error) await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug }) - expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug) + expect(mockClineProvider.deleteCustomMode).toHaveBeenCalledWith(slug) expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true }) // Verify error message is shown to the user expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( @@ -1974,6 +2207,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, @@ -2010,6 +2257,36 @@ it("persists alwaysDenyUnapprovedCommands through the generic updateSettings loo expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) +// A mode slug reaches the shared store and this view's durable pin through the +// per-key updateSettings path, so an unknown slug must be rejected there: only +// setValues validates modes, and this loop does not go through it. +describe("webviewMessageHandler - mode through updateSettings", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + }) + + it("persists a mode that resolves", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { mode: "architect" }, + }) + + expect(mockClineProvider.setValue).toHaveBeenCalledWith("mode", "architect") + }) + + it("drops an unknown mode instead of writing it to the shared store and the view pin", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { mode: "no-such-mode" }, + }) + + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith("mode", "no-such-mode") + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith("mode", expect.anything()) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Ignoring invalid mode")) + }) +}) + // Both allowlists are normalized by the same branch, so both are held to the // same contract. describe.each(["allowedReadFiles", "allowedWriteFiles"] as const)("webviewMessageHandler - %s", (key) => { @@ -2229,6 +2506,16 @@ describe("webviewMessageHandler - requestCommands", () => { const getTaskMode = vi.fn().mockResolvedValue("code") vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ cwd: "/mock/workspace", + // Declared on the double: the sign-out sweep reaches the manager for the profile records, and + // the tests hand it a per-scenario record store. + providerSettingsManager: { + listConfig: vi.fn(), + getProfile: vi.fn(), + saveConfig: vi.fn(), + }, + // The sweep needs the upsert’s confirmation value (the saved profile id), so each test hands + // in its own stub instead of casting the provider. + upsertProviderProfile: vi.fn(), getTaskMode, } as unknown as ReturnType) @@ -2303,6 +2590,16 @@ describe("webviewMessageHandler - requestCommands", () => { const getTaskMode = vi.fn().mockResolvedValue("code") vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ cwd: "/mock/workspace", + // Declared on the double: the sign-out sweep reaches the manager for the profile records, and + // the tests hand it a per-scenario record store. + providerSettingsManager: { + listConfig: vi.fn(), + getProfile: vi.fn(), + saveConfig: vi.fn(), + }, + // The sweep needs the upsert’s confirmation value (the saved profile id), so each test hands + // in its own stub instead of casting the provider. + upsertProviderProfile: vi.fn(), getTaskMode, } as unknown as ReturnType) @@ -2373,6 +2670,16 @@ describe("webviewMessageHandler - requestCommands", () => { vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ cwd: "/mock/workspace", + // Declared on the double: the sign-out sweep reaches the manager for the profile records, and + // the tests hand it a per-scenario record store. + providerSettingsManager: { + listConfig: vi.fn(), + getProfile: vi.fn(), + saveConfig: vi.fn(), + }, + // The sweep needs the upsert’s confirmation value (the saved profile id), so each test hands + // in its own stub instead of casting the provider. + upsertProviderProfile: vi.fn(), } as unknown as ReturnType) vi.mocked(mockClineProvider.getSkillsManager).mockReturnValue(undefined) @@ -2488,21 +2795,42 @@ describe("webviewMessageHandler - downloadErrorDiagnostics", () => { }) describe("zooCodeSignOut", () => { + // The suite reassigns three members of the module-level fixture in every one of + // its tests. vi.clearAllMocks() resets call history only - it never restores a + // property assignment - so without this snapshot the sign-out doubles leak into + // every later suite in the file (mockClineProvider.contextProxy alone is read 28 + // times by the eight describes that follow). Same capture-and-restore pattern the + // launch and rename suites already use. + const signOutDouble = mockClineProvider as unknown as MutableSignOutProvider + const originalSignOutMembers = { + contextProxy: signOutDouble.contextProxy, + providerSettingsManager: signOutDouble.providerSettingsManager, + upsertProviderProfile: signOutDouble.upsertProviderProfile, + } + + afterEach(() => { + Object.assign(signOutDouble, originalSignOutMembers) + }) + beforeEach(() => { vi.clearAllMocks() }) it("disconnects Zoo Code and clears tokens from all Zoo Gateway profiles", async () => { const { disconnectZooCode } = await import("../../../services/zoo-code-auth") - const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) + // The resolved value is the saved profile's id: it is the store's confirmation that the + // token write landed, which the handler now requires before it logs a clear. + const upsertProviderProfile = vi.fn().mockResolvedValue("profile-id") const saveConfig = vi.fn().mockResolvedValue(undefined) - ;(mockClineProvider as any).contextProxy = { - ...mockClineProvider.contextProxy, + const providerWithSignOutStubs = mockClineProvider as unknown as MutableSignOutProvider + ;(mockClineProvider as unknown as MutableSignOutProvider).contextProxy = { + // Spread carries the double’s own setValue/getValue across the replacement. + ...providerWithSignOutStubs.contextProxy, getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), } - ;(mockClineProvider as any).providerSettingsManager = { + ;(mockClineProvider as unknown as MutableSignOutProvider).providerSettingsManager = { listConfig: vi.fn().mockResolvedValue([ { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, @@ -2520,7 +2848,7 @@ describe("zooCodeSignOut", () => { }), saveConfig, } - ;(mockClineProvider as any).upsertProviderProfile = upsertProviderProfile + ;(mockClineProvider as ClineProvider).upsertProviderProfile = upsertProviderProfile await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) @@ -2530,22 +2858,99 @@ describe("zooCodeSignOut", () => { expect.not.objectContaining({ zooSessionToken: expect.anything() }), true, ) - expect(saveConfig).toHaveBeenCalledWith( + // The non-active profile goes through the provider too: a direct saveConfig rewrites the + // record only, so a live view pinned to it keeps buffering the token that was just cleared, + // and its getState() hands that token to new tasks. activate=false keeps the shared + // selection on the profile that was actually active. + expect(upsertProviderProfile).toHaveBeenCalledWith( "Backup Zoo", expect.not.objectContaining({ zooSessionToken: expect.anything() }), + false, + ) + expect(saveConfig).not.toHaveBeenCalled() + + // The success line is the user-visible claim that the credential is gone, so its wording is + // pinned per profile: the active profile also had its in-memory handler updated, and a + // persisted-only profile must not claim that it did. + expect(mockClineProvider.log).toHaveBeenCalledWith( + '[zooCodeSignOut] Cleared zooSessionToken from "Zoo Gateway" profile and updated in-memory handler', + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + '[zooCodeSignOut] Cleared zooSessionToken from "Backup Zoo" profile', ) expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() }) - it("still clears the in-memory handler when the active profile token is already empty on disk", async () => { + it("reports a failed token cleanup when the profile upsert confirms nothing", async () => { + // upsertProviderProfile owns its failure: it logs, shows an error message and resolves to + // undefined instead of rejecting, so the sign-out sweep cannot separate a clear from a + // swallowed write by control flow alone. An undefined profile id has to travel through the + // per-profile error path, otherwise sign-out announces "Cleared zooSessionToken" while the + // stored profile still holds the credential. const upsertProviderProfile = vi.fn().mockResolvedValue(undefined) - ;(mockClineProvider as any).contextProxy = { - ...mockClineProvider.contextProxy, + const providerWithSignOutStubs = mockClineProvider as unknown as MutableSignOutProvider + ;(mockClineProvider as unknown as MutableSignOutProvider).contextProxy = { + // Spread carries the double’s own setValue/getValue across the replacement. + ...providerWithSignOutStubs.contextProxy, getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), } - ;(mockClineProvider as any).providerSettingsManager = { + ;(mockClineProvider as unknown as MutableSignOutProvider).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, + { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, + ]), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "token-active", + }) + .mockResolvedValueOnce({ + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "token-backup", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + } + ;(mockClineProvider as ClineProvider).upsertProviderProfile = upsertProviderProfile + + await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) + + // The sweep keeps going after an unconfirmed clear: the second profile must still be + // attempted, or one failed write leaves a later profile holding its token. + expect(upsertProviderProfile).toHaveBeenCalledTimes(2) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining('Failed to clear profile token for "Zoo Gateway"'), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining('Failed to clear profile token for "Backup Zoo"'), + ) + + // Pinned in full, not just the prefix: this line is the only record that the stored profile may + // still hold the credential, so it has to name the profile and say why the clear is unconfirmed. + expect(mockClineProvider.log).toHaveBeenCalledWith( + '[zooCodeSignOut] Failed to clear profile token for "Zoo Gateway": upsertProviderProfile returned no profile id for "Zoo Gateway"; the stored profile may still hold zooSessionToken', + ) + // No success line may be claimed for a write the store did not confirm. + expect(mockClineProvider.log).not.toHaveBeenCalledWith(expect.stringContaining("Cleared zooSessionToken")) + // State is still pushed so the webview stops showing the signed-in account. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("still clears the in-memory handler when the active profile token is already empty on disk", async () => { + // The resolved value is the saved profile's id: it is the store's confirmation that the + // token write landed, which the handler now requires before it logs a clear. + const upsertProviderProfile = vi.fn().mockResolvedValue("profile-id") + + const providerWithSignOutStubs = mockClineProvider as unknown as MutableSignOutProvider + ;(mockClineProvider as unknown as MutableSignOutProvider).contextProxy = { + // Spread carries the double’s own setValue/getValue across the replacement. + ...providerWithSignOutStubs.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), + } + ;(mockClineProvider as unknown as MutableSignOutProvider).providerSettingsManager = { listConfig: vi .fn() .mockResolvedValue([{ name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }]), @@ -2555,15 +2960,519 @@ describe("zooCodeSignOut", () => { }), saveConfig: vi.fn(), } - ;(mockClineProvider as any).upsertProviderProfile = upsertProviderProfile + ;(mockClineProvider as ClineProvider).upsertProviderProfile = upsertProviderProfile + + await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) + + expect(upsertProviderProfile).toHaveBeenCalledWith( + "Zoo Gateway", + expect.not.objectContaining({ zooSessionToken: expect.anything() }), + true, + ) + }) + + it("leaves a profile that is neither active nor token-bearing out of the cleanup sweep", async () => { + // The sweep rewrites the active profile (to clear its in-memory handler) and any persisted-only + // profile that had a token to drop. A zoo-gateway profile that is neither of those has nothing + // to clear: routing it through the queue anyway would rewrite a profile sign-out never touched. + const upsertProviderProfile = vi.fn().mockResolvedValue("profile-id") + + const providerWithSignOutStubs = mockClineProvider as unknown as MutableSignOutProvider + ;(mockClineProvider as unknown as MutableSignOutProvider).contextProxy = { + // Spread carries the double’s own setValue/getValue across the replacement. + ...providerWithSignOutStubs.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Zoo Gateway" }), + } + ;(mockClineProvider as unknown as MutableSignOutProvider).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, + { name: "Quiet Zoo", apiProvider: providerIdentifiers.zooGateway }, + ]), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "token-active", + }) + .mockResolvedValueOnce({ apiProvider: providerIdentifiers.zooGateway }), + saveConfig: vi.fn(), + } + ;(mockClineProvider as ClineProvider).upsertProviderProfile = upsertProviderProfile await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) + expect(upsertProviderProfile).toHaveBeenCalledTimes(1) expect(upsertProviderProfile).toHaveBeenCalledWith( "Zoo Gateway", expect.not.objectContaining({ zooSessionToken: expect.anything() }), true, ) + expect(upsertProviderProfile).not.toHaveBeenCalledWith("Quiet Zoo", expect.anything(), expect.anything()) + expect(mockClineProvider.log).not.toHaveBeenCalledWith(expect.stringContaining("Quiet Zoo")) + }) + + it("logs the in-memory handler update only for the profile that is actually active", async () => { + // The shared selection names "Backup Zoo", so that profile reaches the in-memory handler + // path while "Zoo Gateway" stays persisted-only: its clear must be reported as a record + // rewrite and nothing more. + const upsertProviderProfile = vi.fn().mockResolvedValue("profile-id") + + const providerWithSignOutStubs = mockClineProvider as unknown as MutableSignOutProvider + ;(mockClineProvider as unknown as MutableSignOutProvider).contextProxy = { + // Spread carries the double’s own setValue/getValue across the replacement. + ...providerWithSignOutStubs.contextProxy, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.zooGateway }), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "Backup Zoo" }), + } + ;(mockClineProvider as unknown as MutableSignOutProvider).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "Zoo Gateway", apiProvider: providerIdentifiers.zooGateway }, + { name: "Backup Zoo", apiProvider: providerIdentifiers.zooGateway }, + ]), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "token-gateway", + }) + .mockResolvedValueOnce({ + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "token-backup", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + } + ;(mockClineProvider as ClineProvider).upsertProviderProfile = upsertProviderProfile + + await webviewMessageHandler(mockClineProvider, { type: "zooCodeSignOut" }) + + expect(upsertProviderProfile).toHaveBeenCalledWith("Zoo Gateway", expect.anything(), false) + expect(upsertProviderProfile).toHaveBeenCalledWith("Backup Zoo", expect.anything(), true) + expect(mockClineProvider.log).toHaveBeenCalledWith( + '[zooCodeSignOut] Cleared zooSessionToken from "Zoo Gateway" profile', + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + '[zooCodeSignOut] Cleared zooSessionToken from "Backup Zoo" profile and updated in-memory handler', + ) + expect(mockClineProvider.log).not.toHaveBeenCalledWith(expect.stringContaining("Failed to clear profile token")) + }) +}) + +describe("webviewMessageHandler - renameApiConfiguration", () => { + // Double assertion: the spec-wide provider double only declares the members + // most tests drive; this structural widening keeps these tests free of any. + const provider = mockClineProvider as unknown as { + providerSettingsManager: { + getProfile: ReturnType + saveConfig: ReturnType + deleteConfig: ReturnType + hasConfig: ReturnType + runExclusiveAcrossInstances: (cb: () => Promise) => Promise + } + repointViewLocalStateAfterProfileRename: ReturnType + activateProviderProfile: ReturnType + } + // Capture the pre-suite values for the members this suite reassigns: + // vi.clearAllMocks() only resets call history - it never restores property + // assignments, so without this restore the rename doubles leak into every + // later suite in this file. + const originalRenameMembers = { + providerSettingsManager: provider.providerSettingsManager, + repointViewLocalStateAfterProfileRename: provider.repointViewLocalStateAfterProfileRename, + activateProviderProfile: provider.activateProviderProfile, + } + + afterEach(() => { + Object.assign(mockClineProvider, originalRenameMembers) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + // The rename runs its name check, save, and compensation inside the manager's + // cross-instance critical section; this double runs the callback inline. + const runExclusiveAcrossInstances = (cb: () => Promise) => cb() + + it("runs the name check, the save, and the compensation inside the shared critical section", async () => { + const events: string[] = [] + provider.providerSettingsManager = { + runExclusiveAcrossInstances: async (cb: () => Promise) => { + events.push("enter") + try { + return await cb() + } finally { + events.push("exit") + } + }, + hasConfig: vi.fn(async () => { + events.push("hasConfig") + return false + }), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn(async () => { + events.push("saveConfig") + }), + deleteConfig: vi.fn(async () => { + events.push("deleteConfig") + }), + } + provider.repointViewLocalStateAfterProfileRename = vi.fn().mockResolvedValue(undefined) + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // Separate locked calls let another view's same-name create fit between the + // existence check and the save, and the rollback then deletes that view's + // record: the check, the save, and the compensation share one section. + expect(events[0]).toBe("enter") + expect(events.filter((event) => event === "enter")).toHaveLength(1) + expect(events).toContain("hasConfig") + expect(events).toContain("saveConfig") + expect(events).toContain("deleteConfig") + // The activation that follows takes the provider's mutation queue, not the + // manager lock, so it runs after the critical section closed. + expect(events.at(-1)).toBe("exit") + }) + + it("re-points views pinned to the old name before the old name is deleted", async () => { + const order: string[] = [] + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async () => { + order.push("delete") + }), + } + const repoint = vi.fn(async () => { + order.push("repoint") + }) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + expect(repoint).toHaveBeenCalledWith("old-profile", "new-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + }) + // The re-point must precede the deletion: a pinned view switches while the + // old name still resolves, instead of dangling for a window. + expect(order).toEqual(["repoint", "delete"]) + }) + + it("rolls the new profile back when the re-point fails", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const repoint = vi.fn().mockRejectedValueOnce(new Error("repoint failed")) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // A failed re-point must abort before deleting the old name: deleting it + // while views still pin it would dangle every affected view. The rename's + // own durable write is rolled back instead - views back to the old name and + // the new record removed - so a failed rename cannot leave a duplicate. + expect(repoint).toHaveBeenCalledTimes(2) + expect(repoint).toHaveBeenLastCalledWith("new-profile", "old-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + }) + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalledWith("old-profile") + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(provider.activateProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("restores the old pin and removes the new profile when the deletion fails", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // A half-finished rename must not leave views pinned to the new name while + // both profiles exist: re-point them back and drop the new record. + expect(repoint).toHaveBeenCalledWith("new-profile", "old-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + }) + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(provider.activateProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("re-points views with the stored record instead of the raw webview payload", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValueOnce({ id: "profile-id" }).mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // saveConfig normalizes the record; the views and task handlers must receive + // that stored copy, not the raw webview payload with its unvalidated fields. + expect(repoint).toHaveBeenCalledWith("old-profile", "new-profile", { + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + }) + + it("rejects a rename onto an existing profile without saving", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(true), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + provider.repointViewLocalStateAfterProfileRename = vi.fn().mockResolvedValue(undefined) + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "Foo", newName: "foo" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // The webview's name validation treats a case-only difference from the + // current profile as the current profile, so a case-only collision would + // otherwise overwrite the existing record - and the delete-failure rollback + // would then delete a profile the rename never owned. + expect(provider.providerSettingsManager.saveConfig).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("removes the new profile when the normalized read-back fails", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi + .fn() + .mockResolvedValueOnce({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }) + .mockRejectedValueOnce(new Error("read failed")), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // The save landed but the normalized record is unreadable: the new record + // is removed so the failed rename cannot leave both names resolving to the + // same id, and no view is re-pointed onto an unverifiable record. + expect(repoint).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(provider.activateProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("restores the previously stored settings when the deletion fails", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "old-model", + }) + .mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "new-model", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "new-model" }, + }) + + // Views rolled back to oldName must serve what oldName actually stores: the + // attempted settings were never persisted under the old name. + expect(repoint).toHaveBeenLastCalledWith("new-profile", "old-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + apiModelId: "old-model", + }) + }) + + it("logs a failed rename compensation", async () => { + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + const repoint = vi.fn(async (from: string) => { + if (from === "new-profile") { + throw new Error("compensation failed") + } + }) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // When the compensation itself fails both names can remain stored; the log + // must record that, not only the original deletion error. + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Rename compensation failed after delete rejection"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) +}) + +describe("webviewMessageHandler - saveApiConfiguration", () => { + // Double assertion: the spec-wide provider double only declares the members + // most tests drive; this structural widening keeps these tests free of any. + const provider = mockClineProvider as unknown as { + providerSettingsManager: { + saveConfig: ReturnType + listConfig: ReturnType + runExclusiveAcrossInstances: (cb: () => Promise) => Promise + } + } + const originalSaveMembers = { + providerSettingsManager: provider.providerSettingsManager, + } + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => { + Object.assign(mockClineProvider, originalSaveMembers) + }) + + // A promise-chain double, matching the real shared lock: a caller queues behind + // whoever holds the section instead of running inline. + let section: Promise = Promise.resolve() + const runExclusiveAcrossInstances = (cb: () => Promise): Promise => { + const run = section.then(cb) + section = run.then( + () => {}, + () => {}, + ) + return run + } + + it("waits for another view's critical section before saving the profile record", async () => { + const events: string[] = [] + let releaseOtherView!: () => void + const otherViewHold = new Promise((resolve) => { + releaseOtherView = resolve + }) + section = Promise.resolve() + provider.providerSettingsManager = { + runExclusiveAcrossInstances, + saveConfig: vi.fn(async () => { + events.push("saveConfig") + }), + listConfig: vi.fn(async () => { + events.push("listConfig") + return [] + }), + } + // Another view's rename holds the section across its name check, its save, and its + // compensation delete. + const heldSection = runExclusiveAcrossInstances(async () => { + events.push("otherViewEnter") + await otherViewHold + events.push("otherViewExit") + }) + const saveWrite = webviewMessageHandler(mockClineProvider, { + type: "saveApiConfiguration", + text: "rival-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + await new Promise((resolve) => setImmediate(resolve)) + + // The rollback deletes whatever the new name holds at that moment, so a save that + // landed while the section was held would be deleted with the failed rename. + expect(events).toEqual(["otherViewEnter"]) + + releaseOtherView() + await saveWrite + await heldSection + + expect(events).toEqual(["otherViewEnter", "otherViewExit", "saveConfig", "listConfig"]) }) }) @@ -2804,6 +3713,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 @@ -2971,6 +3899,17 @@ 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 + }) }) describe("webviewMessageHandler - collapseCommandOutputByDefault", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4adb29264b..6ba49cf638 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -94,7 +94,7 @@ import { resolveImageMentions } from "../mentions/resolveImageMentions" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { getWorkspacePath } from "../../utils/path" import { isPathOutsideWorkspace, decodeUntrustedPathToStable, isRealPathOutsideWorkspace } from "../../utils/pathUtils" -import { Mode, defaultModeSlug } from "../../shared/modes" +import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" import { buildApiHandler, type ApiHandler } from "../../api" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" @@ -593,7 +593,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) @@ -642,17 +655,45 @@ 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) so the view adopts the shared choice; the + // global selection itself is left untouched. + // Clear the buffered overlay first: it belongs to the profile that + // no longer resolves. If the pin write below then fails, the invalid + // selection stays retryable on the next launch; pinning first would + // leave a valid pin serving the stale profile's provider, model, and + // keys with the repair skipped. + await provider.saveViewState("apiConfiguration", undefined) + await provider.saveViewState("currentApiConfigName", globalConfigName) + // The re-pin must reach this webview before the profile-list + // continuation below, otherwise it keeps rendering the stale name. + await provider.postStateToWebview() + // 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 + } } } } @@ -702,6 +743,7 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + } case "webviewHeartbeat": // Confirm renderer liveness and advance the watchdog recovery health window. provider.updateWebviewHeartbeat() @@ -887,9 +929,23 @@ export const webviewMessageHandler = async ( if (!value) { continue } + } else if (key === "mode") { + // provider.setValue has no mode validation (setValues does): an unknown or + // non-string slug would land in the shared store and in this view's durable + // pin, where the next task reads it and records it in task history. + const customModes = await provider.customModesManager.getCustomModes() + + if (typeof value !== "string" || !getModeBySlug(value, customModes)) { + provider.log(`[updateSettings] Ignoring invalid mode "${String(value)}"`) + continue + } + + newValue = value } - 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) } } catch (error) { failed = true @@ -1709,9 +1765,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 } @@ -2473,10 +2527,19 @@ export const webviewMessageHandler = async ( } case "saveApiConfiguration": if (message.text && message.apiConfiguration) { + const profileName = message.text + const profileSettings = message.apiConfiguration try { - await provider.providerSettingsManager.saveConfig(message.text, message.apiConfiguration) - const listApiConfig = await provider.providerSettingsManager.listConfig() - await updateGlobalState("listApiConfigMeta", listApiConfig) + // The profile rename holds the manager's cross-instance critical section across its + // existence check, its save, and its compensation delete. A same-name save that + // skipped that section can land between another view's check and its save and then + // be removed by that rename's compensation, so this write joins the same section. + // The rename callback never calls this writer, so the wrap cannot nest on itself. + await provider.providerSettingsManager.runExclusiveAcrossInstances(async () => { + await provider.providerSettingsManager.saveConfig(profileName, profileSettings) + const listApiConfig = await provider.providerSettingsManager.listConfig() + await updateGlobalState("listApiConfigMeta", listApiConfig) + }) } catch (error) { provider.log( `Error save api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2513,14 +2576,98 @@ export const webviewMessageHandler = async ( break } - // Load the old configuration to get its ID. - const { id } = await provider.providerSettingsManager.getProfile({ name: oldName }) + // The webview's name validation treats a case-only difference from the + // current profile as the current profile, so a case-only collision reaches + // here as a rename onto an existing record. Reject it instead of + // overwriting that profile: the rollback below would otherwise delete a + // record the rename never owned. + + // The name check, the save, and the compensation run in one critical + // section shared with every other view's manager (each view builds its own + // `ProviderSettingsManager` over the same secrets): with separate locked + // calls, a same-name create from another view fits between the check and + // the save, and the rollback then deletes that view's record. + await provider.providerSettingsManager.runExclusiveAcrossInstances(async () => { + if (await provider.providerSettingsManager.hasConfig(newName)) { + throw new Error(`A profile named '${newName}' already exists`) + } - // Create a new configuration with the new name and old ID. - await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) + // Load the old configuration to get its ID, and keep its full stored + // record: a rollback must re-point views to the settings oldName actually + // persists, not to the payload the rename attempted. + const { name: _previousName, ...previousRecord } = + await provider.providerSettingsManager.getProfile({ name: oldName }) + const { id } = previousRecord + + // Create a new configuration with the new name and old ID. + await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) + + // saveConfig normalizes the record (legacy downgrades, schema parse): hand + // the stored copy to the views and task handlers instead of the raw + // webview payload, so unvalidated fields cannot bypass the profile-schema + // normalization on the way into view state. When the read-back fails the + // rename cannot proceed on a trustworthy record: remove the new record + // before reporting so the failed rename cannot leave both names resolving + // to the same id. + let normalizedRenamed: typeof previousRecord + try { + const { name: _renamedName, ...renamedRecord } = + await provider.providerSettingsManager.getProfile({ name: newName }) + normalizedRenamed = renamedRecord + } catch (error) { + try { + await provider.providerSettingsManager.deleteConfig(newName) + } catch (cleanupError) { + provider.log( + `Rename rollback failed to remove '${newName}': ${JSON.stringify(cleanupError, Object.getOwnPropertyNames(cleanupError), 2)}`, + ) + } + throw error + } - // Delete the old configuration. - await provider.providerSettingsManager.deleteConfig(oldName) + // Re-point live views pinned to the old name before it stops + // resolving: the rename keeps the id, so their pins should follow the + // record instead of dangling, and their overlays must serve the + // renamed settings rather than the pre-rename copy. A failed re-point can + // leave views already following newName, so undo this rename's own + // durable write before reporting: the reverse re-point returns the views + // that moved (the others never left oldName) and the new record goes + // away, leaving the pre-rename state intact. + try { + await provider.repointViewLocalStateAfterProfileRename(oldName, newName, normalizedRenamed) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await provider.providerSettingsManager.deleteConfig(newName) + } catch (compensationError) { + provider.log( + `Rename compensation failed after re-point rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) + } + throw error + } + + // Delete the old configuration. A failure here would leave both names + // resolving and every live view pinned to the new name: undo the re-point + // with the previously stored record - not the attempted payload, which was + // never persisted under the old name - and drop the new record so the + // pre-rename state survives intact. A failed compensation is logged: both + // names can then remain stored, and the log is the only trace of that + // inconsistency. + try { + await provider.providerSettingsManager.deleteConfig(oldName) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await provider.providerSettingsManager.deleteConfig(newName) + } catch (compensationError) { + provider.log( + `Rename compensation failed after delete rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) + } + throw error + } + }) // Re-activate to update the global settings related to the // currently activated provider profile. @@ -2656,10 +2803,14 @@ export const webviewMessageHandler = async ( const isNewMode = !existingModes.some((mode) => mode.slug === message.modeConfig?.slug) await provider.customModesManager.updateCustomMode(message.modeConfig.slug, message.modeConfig) - // Update state after saving the mode + // Update state after saving the mode. Both keys go through provider.setValue so + // this view's mode pin and local buffer follow the slug this call just wrote, as in + // the updateSettings path; a direct ContextProxy write moves the shared store and + // leaves a view pinned elsewhere on that earlier slug. The webview refresh stays + // after the writes so the fan-out still reports what was stored. const customModes = await provider.customModesManager.getCustomModes() - await updateGlobalState("customModes", customModes) - await updateGlobalState("mode", message.modeConfig.slug) + await provider.setValue("customModes", customModes) + await provider.setValue("mode", message.modeConfig.slug) await provider.postStateToWebview() // Track telemetry for custom mode creation or update @@ -2733,8 +2884,10 @@ export const webviewMessageHandler = async ( break } - // Delete the mode - await provider.customModesManager.deleteCustomMode(message.slug) + // Delete the mode through the provider-profile mutation queue: a queued mode + // switch must not read the mode list first and then persist a slug this + // deletion removed while it waited. + await provider.deleteCustomMode(message.slug) // Delete the rules folder if it exists if (rulesFolderExists) { @@ -2754,8 +2907,21 @@ export const webviewMessageHandler = async ( } } - // Switch back to default mode after deletion - await updateGlobalState("mode", defaultModeSlug) + // Move onto the default mode only for the view this call applies to: the one whose + // effective mode - its durable pin, or the shared selection when it has no pin of + // its own - is the slug being deleted. Routing that write through the provider keeps + // this view's pin and the shared store on the same slug. A view pinned to a mode this + // deletion did not touch keeps its pin: moving it here used to overwrite an unrelated + // selection with the default. When the shared selection is the deleted slug but this + // view pins something else, only the shared layer moves, so the views that follow it + // do not resolve to a slug that no longer exists while this view keeps its own. + const pinnedMode = provider.pinnedModeName + const sharedMode = getGlobalState("mode") + if ((pinnedMode ?? sharedMode) === message.slug) { + await provider.setValue("mode", defaultModeSlug) + } else if (sharedMode === message.slug) { + await updateGlobalState("mode", defaultModeSlug) + } await provider.postStateToWebview() } break @@ -3209,15 +3375,37 @@ export const webviewMessageHandler = async ( // rewritten only when they previously had a token to avoid no-op disk writes. const isThisProfileActive = isZooGatewayActive && currentApiConfigName === entry.name - if (isThisProfileActive) { - await provider.upsertProviderProfile(entry.name, cleanedProfile, true) - provider.log( - `[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile and updated in-memory handler`, + if (!isThisProfileActive && !profile.zooSessionToken) { + continue + } + + // Route through the provider's mutation queue so live views pinned to this profile drop the + // token too: a direct saveConfig rewrites the record only, and a pinned view keeps buffering + // the cleared token, which its getState() reports and new tasks send. activate=false keeps the + // shared selection on the profile that was actually active. + // + // The queue result is the store's confirmation of the clear: upsertProviderProfile catches + // its own failure and resolves to undefined - persistProviderProfile only ever resolves to + // the id that saveConfig returned - so an undefined id here means the write group failed and + // the stored profile can still hold the token. Reporting the clear anyway would log a cleanup + // that never happened and leave a reader trusting a credential that is still live. A failed + // cleanup belongs to the per-profile error path below, which logs it and moves on to the + // next profile. + const clearedProfileId = await provider.upsertProviderProfile( + entry.name, + cleanedProfile, + isThisProfileActive, + ) + + if (clearedProfileId === undefined) { + throw new Error( + `upsertProviderProfile returned no profile id for "${entry.name}"; the stored profile may still hold zooSessionToken`, ) - } else if (profile.zooSessionToken) { - await provider.providerSettingsManager.saveConfig(entry.name, cleanedProfile) - provider.log(`[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile`) } + + provider.log( + `[zooCodeSignOut] Cleared zooSessionToken from "${entry.name}" profile${isThisProfileActive ? " and updated in-memory handler" : ""}`, + ) } catch (profileError) { // Log but continue to the next profile so one failure doesn't // leave other profiles holding a stale token. diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 42be14463c..78a2dd35d5 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -519,11 +519,6 @@ "count": 24 } }, - "core/config/ContextProxy.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "core/config/CustomModesManager.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 @@ -921,12 +916,12 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 195 + "count": 188 } }, "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": { @@ -996,7 +991,7 @@ }, "core/webview/__tests__/webviewMessageHandler.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 35 + "count": 29 } }, "core/webview/messageEnhancer.ts": { diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index a10bfe60ac..3c0f70f1b8 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,16 +1,56 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" +import { providerIdentifiers } from "@roo-code/types" import type { RooCodeSettings } 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 {}, })) +// A manager double runs a cross-instance critical section inline, which is what the real queue +// does whenever no other view holds it. The queue itself is exercised by the interleaving test +// below, which installs its own. +const runManagerSectionInline = (cb: () => Promise): Promise => cb() + +// The manager double for the profile store: hasConfig, getProfile, saveConfig and deleteConfig all +// read and write one map, so the existence re-read that the compensation runs inside the critical +// section sees the record this call saved - which is what the section orders in production. A +// static hasConfig mock cannot model that: it answers "absent" both before the save and after it. +const makeProfileStoreDouble = (seed: Record }> = {}) => { + const entries = new Map(Object.entries(seed)) + const hasConfig = vi.fn((name: string) => Promise.resolve(entries.has(name))) + const getProfile = vi.fn(({ name }: { name: string }) => { + const entry = entries.get(name) + if (!entry) { + return Promise.reject(new Error(`Profile "${name}" does not exist`)) + } + return Promise.resolve({ name, id: entry.id, ...entry.settings }) + }) + const saveConfig = vi.fn((name: string, settings: Record) => { + const id = entries.get(name)?.id ?? "created-id" + entries.set(name, { id, settings }) + return Promise.resolve(id) + }) + const deleteConfig = vi.fn((name: string) => { + entries.delete(name) + return Promise.resolve() + }) + return { entries, hasConfig, getProfile, saveConfig, deleteConfig } +} + describe("API - configuration", () => { it("persists every supplied mode API config mapping", async () => { const setValues = vi.fn().mockResolvedValue(undefined) @@ -21,8 +61,21 @@ describe("API - configuration", () => { context: {}, on: vi.fn(), log: vi.fn(), - contextProxy: { setValues }, - providerSettingsManager: { saveConfig, setModeConfig }, + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({}), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({}), + }, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + setModeConfig, + hasConfig: vi.fn().mockResolvedValue(false), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, postStateToWebview, } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel @@ -39,12 +92,69 @@ describe("API - configuration", () => { currentApiConfigName: "default", modeApiConfigs: expect.anything(), }), + // The payload saves this profile: siblings pinned to it are refreshed + // from the payload instead of pinned to a pre-save snapshot. + { savedProfileName: "default" }, ) expect(setModeConfig).toHaveBeenCalledTimes(2) expect(setModeConfig).toHaveBeenCalledWith("code", "code-config") expect(setModeConfig).toHaveBeenCalledWith("architect", "architect-config") expect(postStateToWebview).toHaveBeenCalledOnce() }) + it("aborts before the profile save when the prior mode mapping read fails", async () => { + const store = makeProfileStoreDouble({ + default: { id: "prior-id", settings: { apiProvider: providerIdentifiers.openrouter } }, + }) + const setValues = vi.fn().mockResolvedValue(undefined) + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + // The prior mapping of each mode the payload touches is read before anything is written, so + // the read joins the fail-closed reads the critical section below already runs. + const getModeConfigId = vi.fn().mockRejectedValue(new Error("mode mapping read failed")) + const provider = { + context: {}, + on: vi.fn(), + log: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({}), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + }, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + ...store, + setModeConfig, + getModeConfigId, + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openrouter, + modeApiConfigs: { code: "code-config" }, + }), + ).rejects.toThrow("mode mapping read failed") + + // Nothing may have been mutated: the record this call found must still be the record it found, + // with its own id, and no shared write may have run. A rejection after the save would leave a + // created-or-overwritten record that the compensation never sees, because the compensation + // only runs for failures of the writes that follow the capture. + expect(store.saveConfig).not.toHaveBeenCalled() + expect(setValues).not.toHaveBeenCalled() + expect(setModeConfig).not.toHaveBeenCalled() + expect(postStateToWebview).not.toHaveBeenCalled() + expect(store.entries.get("default")).toEqual({ + id: "prior-id", + settings: { apiProvider: providerIdentifiers.openrouter }, + }) + }) it("does not persist mode mappings when none are supplied", async () => { const setValues = vi.fn().mockResolvedValue(undefined) @@ -55,8 +165,21 @@ describe("API - configuration", () => { context: {}, on: vi.fn(), log: vi.fn(), - contextProxy: { setValues }, - providerSettingsManager: { saveConfig, setModeConfig }, + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({}), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({}), + }, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + setModeConfig, + hasConfig: vi.fn().mockResolvedValue(false), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, postStateToWebview, } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel @@ -67,19 +190,830 @@ describe("API - configuration", () => { expect(setModeConfig).not.toHaveBeenCalled() expect(postStateToWebview).toHaveBeenCalledOnce() }) + it("does not refresh shared settings or sibling overlays when the profile save rejects", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockRejectedValue(new Error("invalid profile")) + const deleteConfig = vi.fn().mockResolvedValue(undefined) + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const setProviderSettings = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({}), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({}), + setProviderSettings, + }, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + deleteConfig, + setModeConfig, + hasConfig: vi.fn().mockResolvedValue(false), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect(api.setConfiguration({ currentApiConfigName: "pinned" })).rejects.toThrow("invalid profile") + + // saveConfig validates before storing: a rejected save must leave the + // shared settings and the sibling overlays pinned to the profile + // untouched, so no sibling serves settings the stored profile lacks. + expect(setValues).not.toHaveBeenCalled() + // saveConfig validates before storing: a rejected save wrote nothing, so + // rollback must not run at all - no delete, no shared-settings restore. + expect(deleteConfig).not.toHaveBeenCalled() + expect(setProviderSettings).not.toHaveBeenCalled() + expect(postStateToWebview).not.toHaveBeenCalled() + }) + it("restores the stored profile and shared settings when the refresh fails after the save", async () => { + const setValues = vi.fn().mockRejectedValue(new Error("refresh down")) + // The store double keeps the compensation re-read honest: this call creates the record, so the + const { saveConfig, deleteConfig, hasConfig, getProfile } = makeProfileStoreDouble() + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const setProviderSettings = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + // Real provider-setting keys: getProviderSettings only returns + // PROVIDER_SETTINGS_KEYS, so the restore assertion must use provider keys. + const sharedBefore = { apiProvider: providerIdentifiers.anthropic, apiModelId: "model-old" } + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({}), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue(sharedBefore), + setProviderSettings, + }, + providerSettingsManager: { + saveConfig, + deleteConfig, + hasConfig, + getProfile, + setModeConfig, + runExclusiveAcrossInstances: runManagerSectionInline, + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect(api.setConfiguration({ currentApiConfigName: "pinned" })).rejects.toThrow("refresh down") + + // The save landed but the shared/sibling refresh rejected: the round trip + // is one transaction, so the created record is deleted and the shared + // provider settings are restored before the caller sees the failure. + expect(saveConfig).toHaveBeenCalledWith("pinned", expect.objectContaining({ currentApiConfigName: "pinned" })) + expect(deleteConfig).toHaveBeenCalledWith("pinned") + expect(setProviderSettings).toHaveBeenCalledWith(sharedBefore) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + + // Rollback regression: getConfiguration() merges this view's overlay, so its mode and pin + // are not the shared values the rollback should put back for every other view. + it("restores the shared mode and profile pin from shared state, not this view's overlay", async () => { + // Both the failing refresh and the compensation replay go through provider.setValues, and the + // shared-pair restore goes through contextProxy.setValues: tag every call so the test can + // assert the order of the two channels, not merely that each one happened. + const writeOrder: string[] = [] + const setValues = vi.fn().mockImplementation(() => { + writeOrder.push("provider.setValues") + return Promise.reject(new Error("refresh down")) + }) + const sharedSetValues = vi.fn().mockImplementation(() => { + writeOrder.push("contextProxy.setValues") + return Promise.resolve(undefined) + }) + // The store double keeps the compensation re-read honest: this call creates the record, so the + const { saveConfig, deleteConfig, hasConfig, getProfile } = makeProfileStoreDouble() + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + // The shared store carries one mode and pin; this view's overlay carries another, and + // getConfiguration() - which the rollback replays - merges the overlay over shared state. + const sharedBefore = { mode: "code", currentApiConfigName: "shared-profile" } + const mergedBefore = { mode: "programmer", currentApiConfigName: "view-pinned-profile" } + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue(mergedBefore), + setValues, + contextProxy: { + getValue: vi.fn((key: string) => sharedBefore[key as keyof typeof sharedBefore]), + setValues: sharedSetValues, + getProviderSettings: vi.fn().mockReturnValue({}), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + saveConfig, + deleteConfig, + hasConfig, + getProfile, + setModeConfig: vi.fn().mockResolvedValue(undefined), + runExclusiveAcrossInstances: runManagerSectionInline, + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect(api.setConfiguration({ currentApiConfigName: "pinned", mode: "architect" })).rejects.toThrow( + "refresh down", + ) + + // The replayed configuration carries this view's own mode and pin - that is the merged + // copy, and the per-view half of the restore needs it. + expect(setValues).toHaveBeenCalledWith( + expect.objectContaining({ mode: "programmer", currentApiConfigName: "view-pinned-profile" }), + expect.anything(), + ) + // The shared pair is restored from the pre-write shared snapshot instead, after that + // replay, so other unpinned views end on the mode and profile they had before this call + // rather than on this view's. + expect(sharedSetValues).toHaveBeenCalledWith({ mode: "code", currentApiConfigName: "shared-profile" }) + // Order is the fix, and presence alone cannot prove it. The replay above writes this view’s + // merged mode and pin into the shared store; the shared pair must be written after that + // replay, so moving the shared restore earlier - where the replay would overwrite it - + // fails here instead of leaving every unpinned view on this view’s mode and profile. + expect(writeOrder.filter((entry) => entry === "provider.setValues").length).toBe(2) + expect(writeOrder.indexOf("contextProxy.setValues")).toBeGreaterThan( + writeOrder.lastIndexOf("provider.setValues"), + ) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + it("restores the previous record when rolling back a save over an existing profile", async () => { + // The shared refresh succeeds and the mode fan-out fails: the overlays + // pinned to the saved profile already hold the new settings, so rollback + // must replay the restored record to those overlays as well. + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("pinned-id") + const deleteConfig = vi.fn().mockResolvedValue(undefined) + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const refreshSpy = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const previousRecord = { + name: "pinned", + id: "pinned-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + const provider = { + context: {}, + on: vi.fn(), + // The profile entry exists before the call: rollback must restore the + // prior record, never delete the profile. + getProviderProfileEntries: vi.fn().mockReturnValue([{ name: "pinned", id: "pinned-id" }]), + getValues: vi.fn().mockReturnValue({}), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: refreshSpy, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + deleteConfig, + hasConfig: vi.fn().mockResolvedValue(true), + getProfile: vi.fn().mockResolvedValue(previousRecord), + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // Rollback restores the prior record (minus name/id bookkeeping) instead of + // deleting the profile that existed before the call, and replays it to the + // sibling overlays pinned to the saved profile. + expect(saveConfig).toHaveBeenLastCalledWith("pinned", expect.objectContaining({ apiKey: "old-key" })) + expect(deleteConfig).not.toHaveBeenCalled() + expect(refreshSpy).toHaveBeenCalledWith("pinned", expect.objectContaining({ apiKey: "old-key" })) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + + it("rolls back the complete mutation when a mode mapping write fails", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + // The store double keeps the compensation re-read honest: this call creates the record, so + // the re-read inside the critical section must find it rather than repeat the pre-call answer. + const { saveConfig, deleteConfig, hasConfig, getProfile } = makeProfileStoreDouble() + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const setProviderSettings = vi.fn().mockResolvedValue(undefined) + // The shared-pair restore below writes the shared store directly, so it gets its own + // spy and cannot disturb the last-call assertion on the configuration round trip. + const sharedSetValues = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const sharedBefore = { apiProvider: providerIdentifiers.anthropic, apiModelId: "model-old" } + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "old-profile" }), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: sharedSetValues, + getProviderSettings: vi.fn().mockReturnValue(sharedBefore), + setProviderSettings, + }, + refreshViewLocalStateForUpdatedProfile: vi.fn().mockResolvedValue(undefined), + providerSettingsManager: { + saveConfig, + deleteConfig, + hasConfig, + getProfile, + setModeConfig, + // The code mode had no mapping before: rollback must clear the + // mapping this call created rather than leave it pointing at the + // config the failed fan-out chose. + getModeConfigId: vi.fn().mockResolvedValue(undefined), + runExclusiveAcrossInstances: runManagerSectionInline, + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // The shared refresh had already succeeded when the mode write failed, so + // rollback re-applies the complete prior configuration (global values, + // view-local state, and sibling overlays), the shared provider settings, + // deletes the record the save created, and restores the prior mode + // mapping (undefined clears the mapping this call created). + expect(setValues).toHaveBeenLastCalledWith(expect.objectContaining({ currentApiConfigName: "old-profile" }), { + savedProfileName: "old-profile", + }) + expect(setProviderSettings).toHaveBeenCalledWith(sharedBefore) + expect(deleteConfig).toHaveBeenCalledWith("pinned") + // The profile did not exist before: its overlay is restored to empty. + expect(provider["refreshViewLocalStateForUpdatedProfile"]).toHaveBeenCalledWith("pinned", {}) + expect(setModeConfig).toHaveBeenLastCalledWith("code", undefined) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + + it("keeps host-owned viewStates out of both the settings write and the rollback replay", async () => { + // Same rollback scenario as the mode-mapping test above, but the caller payload and + // the pre-call snapshot each carry a viewStates map: the forward write would pin other + // views where this caller found them, and the replay would roll them back to where this + // call found them. Both are machine-local host state, excluded by + // NON_PORTABLE_SETTINGS_KEYS - the same list the export and import paths use, and the + // list #2226 filters the read side with. getValues() itself keeps the key: the host's + // own consumers still need it. + const setValues = vi.fn().mockResolvedValue(undefined) + // The store double keeps the compensation re-read honest: this call creates the record, so + // the re-read inside the critical section must find it rather than repeat the pre-call answer. + const { saveConfig, deleteConfig, hasConfig, getProfile } = makeProfileStoreDouble() + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const setProviderSettings = vi.fn().mockResolvedValue(undefined) + const sharedSetValues = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn().mockReturnValue({ + currentApiConfigName: "old-profile", + viewStates: { "sidebar-1": { currentApiConfigName: "snapshot-pin", updatedAt: 1 } }, + }), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: sharedSetValues, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings, + }, + refreshViewLocalStateForUpdatedProfile: vi.fn().mockResolvedValue(undefined), + providerSettingsManager: { + saveConfig, + deleteConfig, + hasConfig, + getProfile, + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + runExclusiveAcrossInstances: runManagerSectionInline, + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ + currentApiConfigName: "pinned", + modeApiConfigs: { code: "new-code-config" }, + viewStates: { "sidebar-9": { currentApiConfigName: "payload-pin", updatedAt: 2 } }, + }), + ).rejects.toThrow("mode write failed") + + // Two settings writes happen in this call: the forward payload, then the rollback + // replay of the pre-call snapshot. The shared-pair restore writes the shared store + // directly (sharedSetValues), so it is not counted here. + const written = setValues.mock.calls.map((call: unknown[]) => call[0] as Record) + expect(written).toHaveLength(2) + expect(written[0]).toEqual(expect.objectContaining({ currentApiConfigName: "pinned" })) + expect(written[1]).toEqual(expect.objectContaining({ currentApiConfigName: "old-profile" })) + written.forEach((values: Record) => { + expect(values).not.toHaveProperty("viewStates") + expect(JSON.stringify(values)).not.toContain("payload-pin") + expect(JSON.stringify(values)).not.toContain("snapshot-pin") + }) + }) + + it("restores a same-name record another view created instead of deleting it", async () => { + // The interleaving this critical section exists to close. A second view enters the manager + // cross-instance queue and parks there while it creates the profile this call is about to + // save. Our call must queue behind it, so the create lands before our existence check, our + // own write then fails, and the rollback restores that view record instead of deleting it + // together with its secret. + let queue: Promise = Promise.resolve() + const runExclusiveAcrossInstances = (cb: () => Promise): Promise => { + const run = queue.then(cb) + // A settled-clean owner keeps one failed section from poisoning the queue for the next + // caller, which is why the production queue stores one. + queue = run.then( + () => {}, + () => {}, + ) + return run + } + const callOrder: string[] = [] + const records = new Map }>() + let releaseOtherView: (() => void) | undefined + const otherViewCreate = new Promise((resolve) => { + releaseOtherView = resolve + }) + const otherView = runExclusiveAcrossInstances(async () => { + callOrder.push("other view entered the section") + await otherViewCreate + records.set("pinned", { id: "other-view-id", settings: { apiKey: "other-view-secret" } }) + callOrder.push("other view created the record") + }) + const hasConfig = vi.fn((name: string) => { + callOrder.push("this call checked existence") + return Promise.resolve(records.has(name)) + }) + const getProfile = vi.fn(({ name }: { name: string }) => { + const entry = records.get(name) + return entry + ? Promise.resolve({ name, id: entry.id, ...entry.settings }) + : Promise.reject(new Error(`Profile "${name}" does not exist`)) + }) + const saveConfig = vi.fn((name: string, settings: Record) => { + // A store that keeps an existing record id on an overwrite is what the production + // manager does, and it is the reason the compensation has to compare ids. + const id = records.get(name)?.id ?? "this-call-id" + records.set(name, { id, settings }) + callOrder.push("this call saved the record") + return Promise.resolve(id) + }) + const deleteConfig = vi.fn((name: string) => { + records.delete(name) + callOrder.push("this call deleted the record") + return Promise.resolve() + }) + const setValues = vi.fn().mockResolvedValue(undefined) + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + log: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + // Releasing the parked create from the snapshot read keeps the interleaving + // deterministic: the snapshot is taken before this call enters the section, so the + // other view wakes, finishes its create, and releases the lock before our existence + // check runs - and only because our call queued behind it. + getValues: vi.fn().mockImplementation(() => { + releaseOtherView?.() + return { currentApiConfigName: "old-profile" } + }), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: vi.fn().mockResolvedValue(undefined), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: vi.fn().mockResolvedValue(undefined), + providerSettingsManager: { + runExclusiveAcrossInstances, + hasConfig, + getProfile, + saveConfig, + deleteConfig, + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + await otherView + + // The create beat our existence check, which is what joining the section means: without it + // this call reads the name as absent, overwrites the record, and rolls back by deleting it. + expect(callOrder.indexOf("other view created the record")).toBeLessThan( + callOrder.indexOf("this call checked existence"), + ) + // The other view record survives our rollback, id and secret included, and the restore + // re-applies its stored settings rather than removing the record. + expect(deleteConfig).not.toHaveBeenCalled() + expect(records.get("pinned")).toEqual({ + id: "other-view-id", + settings: expect.objectContaining({ apiKey: "other-view-secret" }), + }) + expect(saveConfig).toHaveBeenCalledTimes(2) + expect(saveConfig).toHaveBeenLastCalledWith("pinned", expect.objectContaining({ apiKey: "other-view-secret" })) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + + it("keeps a record another view rewrote between the save and the compensation", async () => { + // The reject path of the id comparison. This call creates the name, so its own rollback + // would delete it; a locked writer that replaces the record in between owns the name now, + // and deleting it here would remove that other view record together with its secret. The + // store is live-backed so the re-read inside the section sees the rewrite, which a static + // hasConfig mock cannot model. + const records = new Map }>() + const hasConfig = vi.fn((name: string) => Promise.resolve(records.has(name))) + const getProfile = vi.fn(({ name }: { name: string }) => { + const entry = records.get(name) + return entry + ? Promise.resolve({ name, id: entry.id, ...entry.settings }) + : Promise.reject(new Error(`Profile "${name}" does not exist`)) + }) + const saveConfig = vi.fn((name: string, settings: Record) => { + records.set(name, { id: "this-call-id", settings }) + return Promise.resolve("this-call-id") + }) + const deleteConfig = vi.fn((name: string) => { + records.delete(name) + return Promise.resolve() + }) + const setModeConfig = vi.fn(async () => { + // The interleaving the guard is for: another locked writer replaces the record this + // call had created, under its own id. + records.set("pinned", { id: "other-view-id", settings: { apiKey: "other-view-secret" } }) + throw new Error("mode write failed") + }) + const setValues = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + log: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + getValues: vi.fn(() => ({ currentApiConfigName: "pinned" })), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: vi.fn().mockResolvedValue(undefined), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: vi.fn().mockResolvedValue(undefined), + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + hasConfig, + getProfile, + saveConfig, + deleteConfig, + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // The existence check saw the name absent, so the rollback reaches the delete branch; the + // id its own save returned is not the id on the record any more, and the record stays. + expect(deleteConfig).not.toHaveBeenCalled() + expect(records.get("pinned")).toEqual({ + id: "other-view-id", + settings: expect.objectContaining({ apiKey: "other-view-secret" }), + }) + expect(saveConfig).toHaveBeenCalledTimes(1) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + + it("replays only the keys this call wrote and leaves keys another view changed alone", async () => { + // A rollback may restore what this call changed and nothing else, because anything the + // snapshot happens to hold otherwise belongs to a writer that has moved on since it was + // taken. The payload writes two settings; after this call snapshots the configuration and + // before its mode write fails, another view writes a shared setting and the pass-through + // taskHistory key. Neither may be written back from this call stale snapshot. + const store: Record = { + currentApiConfigName: "old-profile", + modeApiConfigs: { code: "old-code-config" }, + terminalOutputLineLimit: 1000, + taskHistory: "snapshot-tasks", + } + const written: Array> = [] + const setValues = vi.fn((values: Record) => { + written.push({ ...values }) + return Promise.resolve(undefined) + }) + const setModeConfig = vi.fn(async () => { + // Another view lands between the snapshot above and this failure. + store.terminalOutputLineLimit = 42 + store.taskHistory = "other-view-tasks" + throw new Error("mode write failed") + }) + const { saveConfig, deleteConfig, hasConfig, getProfile } = makeProfileStoreDouble() + const sharedSetValues = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + log: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + // Live-backed so the snapshot is this call view of the store at snapshot time. + getValues: vi.fn(() => ({ ...store })), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: sharedSetValues, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: vi.fn().mockResolvedValue(undefined), + providerSettingsManager: { + saveConfig, + deleteConfig, + hasConfig, + getProfile, + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + runExclusiveAcrossInstances: runManagerSectionInline, + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // Two settings writes in this call: the payload forward, then the rollback replay. + expect(written).toHaveLength(2) + expect(written[0]).toEqual(expect.objectContaining({ currentApiConfigName: "pinned" })) + // The replay carries exactly the keys this call wrote, at their pre-call values: the shared + // setting and the pass-through key another view changed are not in it at all. + expect(Object.keys(written[1]).sort()).toEqual(["currentApiConfigName", "modeApiConfigs"]) + expect(written[1]).toEqual({ currentApiConfigName: "old-profile", modeApiConfigs: { code: "old-code-config" } }) + expect(JSON.stringify(written[1])).not.toContain("terminalOutputLineLimit") + expect(JSON.stringify(written[1])).not.toContain("snapshot-tasks") + // And the other view values are still where that view put them. + expect(store.terminalOutputLineLimit).toBe(42) + expect(store.taskHistory).toBe("other-view-tasks") + expect(deleteConfig).toHaveBeenCalledWith("pinned") + expect(postStateToWebview).not.toHaveBeenCalled() + }) + it("replays the restored overlay after the configuration restore for the active profile", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("pinned-id") + const deleteConfig = vi.fn().mockResolvedValue(undefined) + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const refreshSpy = vi.fn().mockResolvedValue(undefined) + // The shared-pair restore writes the shared store directly: give it its own spy so the + // last-call assertion below still speaks about the configuration round trip. + const sharedSetValues = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const previousRecord = { + name: "pinned", + id: "pinned-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([{ name: "pinned", id: "pinned-id" }]), + // The saved profile is also the active profile: the configuration + // restore refreshes the same pinned siblings, with non-secret values. + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "pinned" }), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: sharedSetValues, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: refreshSpy, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + deleteConfig, + hasConfig: vi.fn().mockResolvedValue(true), + getProfile: vi.fn().mockResolvedValue(previousRecord), + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // The configuration restore refreshes the same pinned siblings with + // non-secret values, so the overlay replay must run after it and be the + // last refresh, ending the overlays on the restored record's settings. + expect(setValues).toHaveBeenLastCalledWith(expect.objectContaining({ currentApiConfigName: "pinned" }), { + savedProfileName: "pinned", + }) + expect(refreshSpy.mock.invocationCallOrder[0]).toBeGreaterThan(setValues.mock.invocationCallOrder[1]) + expect(refreshSpy).toHaveBeenCalledWith("pinned", expect.objectContaining({ apiKey: "old-key" })) + }) + it("preserves credentials in overlays pinned to a different previous profile", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("pinned-id") + const deleteConfig = vi.fn().mockResolvedValue(undefined) + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const refreshSpy = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const pinnedRecord = { + name: "pinned", + id: "pinned-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-old-key", + } + const oldProfileRecord = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "old-profile-key", + } + const provider = { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([{ name: "pinned", id: "pinned-id" }]), + // The mutation switched profiles: siblings pinned to the previous + // profile were refreshed from non-secret values by the restore and + // must be replayed from the previous profile's complete record. + getValues: vi.fn().mockReturnValue({ currentApiConfigName: "old-profile" }), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.anthropic }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: refreshSpy, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + deleteConfig, + hasConfig: vi.fn().mockResolvedValue(true), + getProfile: vi + .fn() + .mockImplementation(({ name }: { name: string }) => + name === "pinned" + ? Promise.resolve(pinnedRecord) + : name === "old-profile" + ? Promise.resolve(oldProfileRecord) + : Promise.reject(new Error("profile absent")), + ), + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await expect( + api.setConfiguration({ currentApiConfigName: "pinned", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // Both pinned groups end on complete records: the saved profile's + // overlays from the restored record, the previous profile's overlays from + // its own stored record - not the credential-less non-secret snapshot. + expect(refreshSpy).toHaveBeenCalledWith("pinned", expect.objectContaining({ apiKey: "pinned-old-key" })) + expect(refreshSpy).toHaveBeenCalledWith("old-profile", expect.objectContaining({ apiKey: "old-profile-key" })) + expect(postStateToWebview).not.toHaveBeenCalled() + }) + + it("names the profile the snapshot was on when the payload never carried the pin", async () => { + // Which profile the views return to has to come from the snapshot the call captured. The + // replay map only holds the keys this call wrote, so a payload that never carried + // currentApiConfigName leaves the pin out of it: defaulting the name there refreshes the + // overlays pinned to "default" with another profile settings and skips the credential + // replay for the profile that was actually active. + const store: Record = { + currentApiConfigName: "my-profile", + apiProvider: providerIdentifiers.openrouter, + } + const setValues = vi.fn().mockResolvedValue(undefined) + const profileStore = makeProfileStoreDouble({ + "my-profile": { + id: "my-id", + settings: { apiProvider: providerIdentifiers.openrouter, apiKey: "my-profile-key" }, + }, + }) + const setModeConfig = vi.fn().mockRejectedValue(new Error("mode write failed")) + const refreshSpy = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + log: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([{ name: "my-profile", id: "my-id" }]), + // Live-backed so the snapshot is this view of the store at snapshot time. + getValues: vi.fn(() => ({ ...store })), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: vi.fn().mockResolvedValue(undefined), + getProviderSettings: vi.fn().mockReturnValue({ apiProvider: providerIdentifiers.openrouter }), + setProviderSettings: vi.fn().mockResolvedValue(undefined), + }, + refreshViewLocalStateForUpdatedProfile: refreshSpy, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + hasConfig: profileStore.hasConfig, + getProfile: profileStore.getProfile, + saveConfig: profileStore.saveConfig, + deleteConfig: profileStore.deleteConfig, + setModeConfig, + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + // A payload that changes only a model setting: this call never touched the profile pin. + await expect( + api.setConfiguration({ apiModelId: "gpt-5-codex", modeApiConfigs: { code: "new-code-config" } }), + ).rejects.toThrow("mode write failed") + + // The saved profile falls back to "default" because the payload omitted the pin, but the + // profile the overlays must be considered on again is the one the snapshot names, and that + // profile overlays must be replayed from its own record. + expect(setValues).toHaveBeenLastCalledWith(expect.any(Object), { savedProfileName: "my-profile" }) + expect(refreshSpy).toHaveBeenCalledWith("my-profile", expect.objectContaining({ apiKey: "my-profile-key" })) + expect(postStateToWebview).not.toHaveBeenCalled() + }) describe("the display settings", () => { const setup = () => { const setValues = vi.fn().mockResolvedValue(undefined) const log = vi.fn() + const profileStore = makeProfileStoreDouble() const provider = { context: {}, on: vi.fn(), log, - contextProxy: { setValues }, + getValues: vi.fn().mockReturnValue({}), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + // The settings write goes through the provider, which forwards to the shared store. + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + getProviderSettings: vi.fn().mockReturnValue({}), + setValues, + }, providerSettingsManager: { - saveConfig: vi.fn().mockResolvedValue("default-id"), - setModeConfig: vi.fn(), + // The store double answers the compensation re-read with the record this call saved, + // which a static hasConfig mock cannot do: it answers "absent" on both readings. + saveConfig: profileStore.saveConfig, + deleteConfig: profileStore.deleteConfig, + hasConfig: profileStore.hasConfig, + getProfile: profileStore.getProfile, + setModeConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + runExclusiveAcrossInstances: runManagerSectionInline, }, postStateToWebview: vi.fn().mockResolvedValue(undefined), } as unknown as ClineProvider @@ -96,7 +1030,9 @@ describe("API - configuration", () => { await api.setConfiguration({ [key]: value }) - expect(setValues).toHaveBeenCalledWith({ [key]: value }) + // The settings write goes through the provider, which also carries the profile name the + // sibling refresh has to agree with. + expect(setValues).toHaveBeenCalledWith({ [key]: value }, { savedProfileName: "default" }) expect(log).not.toHaveBeenCalled() }) @@ -109,8 +1045,12 @@ describe("API - configuration", () => { "storage unavailable", ) - expect(provider.providerSettingsManager.saveConfig).not.toHaveBeenCalled() + // This unit persists the profile record before the settings - saveConfig validates first, so a + // rejected save leaves every domain untouched - so the settings write is the one that fails + // here. Nothing after it is reached, and the record the save already made is rolled back. + expect(provider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() expect(provider.postStateToWebview).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("default") }) it.each([ @@ -123,8 +1063,344 @@ describe("API - configuration", () => { // A hand-written value that the typed signature does not allow. await api.setConfiguration({ [key]: value, soundEnabled: true } as unknown as RooCodeSettings) - expect(setValues).toHaveBeenCalledWith({ soundEnabled: true }) + expect(setValues).toHaveBeenCalledWith({ soundEnabled: true }, { savedProfileName: "default" }) expect(log).toHaveBeenCalledWith(`[setConfiguration] Ignored an invalid value for ${key}`) }) }) + + it("keeps shared flat provider settings when no view-local profile is present", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "shared-profile", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + }) + // Structural double: 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(configuration.apiModelId).toBe("claude-sonnet-4-5") + }) + + it("omits credential-bearing keys that SECRET_STATE_KEYS does not name", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + apiKey: "sk-secret", + openAiHeaders: { Authorization: "Bearer header-token" }, + vertexJsonCredentials: JSON.stringify({ token: "svc" }), + vertexKeyFile: "C:/secrets/vertex.json", + zooSessionToken: "zoo-session-token", + mimoApiKey: "mimo-key", + poeApiKey: "poe-key", + qwenCodeOauthPath: "C:/secrets/qwen-oauth.json", + geminiCliOAuthPath: "C:/secrets/gemini-oauth.json", + modelTemperature: 0.5, + }, + }) + // 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration.apiKey).toBeUndefined() + expect(configuration.openAiHeaders).toBeUndefined() + expect(configuration.vertexJsonCredentials).toBeUndefined() + expect(configuration.vertexKeyFile).toBeUndefined() + expect(configuration.zooSessionToken).toBeUndefined() + expect(configuration.mimoApiKey).toBeUndefined() + expect(configuration.poeApiKey).toBeUndefined() + expect(configuration.qwenCodeOauthPath).toBeUndefined() + expect(configuration.geminiCliOAuthPath).toBeUndefined() + expect(configuration.modelTemperature).toBe(0.5) + expect(configuration.apiProvider).toBe(providerIdentifiers.openai) + }) + + it("falls back to the shared provider when a view-local profile sets apiProvider to undefined", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + apiProvider: providerIdentifiers.anthropic, + // Explicit undefined in the nested profile: the flatten spread must not + // let it clobber the retained shared fallback (getState() uses ??). + apiConfiguration: { + apiProvider: undefined, + apiModelId: "gpt-4o", + }, + }) + // 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(configuration.apiModelId).toBe("gpt-4o") + }) + + it("falls back to the shared provider when a view-local profile pins none", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + // The shared store's provider selection: a legacy/partial pinned + // profile carries no apiProvider, and getState() falls back to this. + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + anthropicBaseUrl: "https://shared.example/v1", + apiConfiguration: { + apiModelId: "gpt-4o", + }, + }) + // 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + // The shared provider survives as the fallback (matching getState()), + // while every other shared provider-setting key stays stripped. + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(configuration.apiModelId).toBe("gpt-4o") + expect(configuration.anthropicBaseUrl).toBeUndefined() + }) + + it("drops shared flat provider settings when a view-local profile is present", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + // Flat provider keys belonging to whichever profile owns the shared store. + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + anthropicBaseUrl: "https://shared.example/v1", + // A non-provider shared setting: must survive the strip. + customInstructions: "keep me", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4o", + }, + }) + // 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + // The view-local snapshot wins outright; the shared store's provider keys + // the profile never defined must not leak through. + expect(configuration.apiProvider).toBe(providerIdentifiers.openrouter) + expect(configuration.apiModelId).toBeUndefined() + expect(configuration.anthropicBaseUrl).toBeUndefined() + expect(configuration.openRouterModelId).toBe("openai/gpt-4o") + expect(configuration.customInstructions).toBe("keep me") + expect(configuration.mode).toBe("code") + expect(configuration.currentApiConfigName).toBe("view-profile") + }) + + it("strips legacy retired-provider credential fields from the flattened surface", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "retired-view-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + groqApiKey: "legacy-groq-secret", + huggingFaceSecurityToken: "legacy-hf-secret", + openRouterModelId: "openai/gpt-4o", + }, + }) + // 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + // Retired providers keep their legacy fields through passthrough; the + // credential-shaped names must not reach the public API. + expect(configuration).not.toHaveProperty("groqApiKey") + expect(configuration).not.toHaveProperty("huggingFaceSecurityToken") + expect(configuration.openRouterModelId).toBe("openai/gpt-4o") + }) + + 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") + }) + + it("refuses a caller-supplied viewStates snapshot when writing configuration", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const provider = { + context: {}, + on: vi.fn(), + log: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([]), + // The settled setConfiguration reads the pre-write snapshot through + // getConfiguration(), i.e. sidebarProvider.getValues(): the double hands it the same + // host-owned map the caller tried to smuggle in, so the test proves both legs strip + // the key - the payload the caller passed and the snapshot this call captured. + getValues: vi.fn().mockReturnValue({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openai, + viewStates: { "sidebar-1": { mode: "code", updatedAt: 1 } }, + }), + setValues, + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues, + getProviderSettings: vi.fn().mockReturnValue({}), + }, + providerSettingsManager: { + runExclusiveAcrossInstances: runManagerSectionInline, + saveConfig, + hasConfig: vi.fn().mockResolvedValue(false), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openai, + viewStates: { "sidebar-1": { mode: "code", updatedAt: 1 } }, + } as never) + + // The stale per-view snapshot is dropped before storage: neither the shared + // write nor the profile save may carry it back in. The second argument is the + // sibling-refresh hint the settled write always passes. + expect(setValues).toHaveBeenCalledWith(expect.not.objectContaining({ viewStates: expect.anything() }), { + savedProfileName: "default", + }) + expect(saveConfig).toHaveBeenCalledWith( + "default", + expect.not.objectContaining({ viewStates: expect.anything() }), + ) + }) + + it("keeps per-view viewStates out of the configuration when no profile is nested", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + apiProvider: providerIdentifiers.anthropic, + viewStates: { "sidebar-1": { mode: "architect", updatedAt: 5 } }, + }) + // Structural double: see the note on the double 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() as Record + + expect(configuration.viewStates).toBeUndefined() + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + }) + + it("keeps per-view viewStates out of the flattened configuration", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + viewStates: { "sidebar-1": { mode: "architect", updatedAt: 5 } }, + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + apiModelId: "gpt-4o", + }, + }) + // 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, makeClineProviderFactory()) + + const configuration = api.getConfiguration() as Record + + // viewStates is per-view durable state owned by the extension host: a caller + // that round-trips the configuration must not see (and later write back) a + // snapshot that can clobber newer per-view selections. + expect(configuration.viewStates).toBeUndefined() + expect(configuration.mode).toBe("code") + expect(configuration.apiModelId).toBe("gpt-4o") + }) }) 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..8648270e58 --- /dev/null +++ b/src/extension/__tests__/api-set-configuration.spec.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest" +import { makeClineProviderFactory } from "../../test-utils/provider" + +import { providerIdentifiers } from "@roo-code/types" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" +import type { OutputChannel } from "vscode" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +vi.mock("../../integrations/terminal/Terminal", () => ({ + Terminal: { + getTerminalProfile: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + closeIdleTerminals: vi.fn(), + }, +})) + +describe("API.setConfiguration", () => { + it("routes configuration through ClineProvider.setValues so view-local state stays in sync", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getProviderProfileEntries: vi.fn().mockReturnValue([ + { + name: "default", + id: "default-id", + }, + ]), + getValues: vi.fn().mockReturnValue({}), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { + getValue: vi.fn().mockReturnValue(undefined), + setValues: vi.fn().mockResolvedValue(undefined), + getProviderSettings: vi.fn().mockReturnValue({}), + }, + providerSettingsManager: { + // A single view holds no other claim on the cross-instance section: run it inline. + runExclusiveAcrossInstances: (cb: () => Promise): Promise => cb(), + saveConfig: vi.fn().mockResolvedValue("default-id"), + // The accept path records whether the profile existed before saving; without it + // the call throws and the test only passes through the catch fallback. + hasConfig: vi.fn().mockResolvedValue(true), + getProfile: vi.fn().mockResolvedValue({ + name: "default", + id: "default-id", + apiProvider: providerIdentifiers.bedrock, + }), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + 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, { savedProfileName: "default" }) + expect(provider.contextProxy.setValues).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledTimes(1) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", configuration) + expect(provider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("default") + 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..cd6335a65f --- /dev/null +++ b/src/extension/__tests__/api-task-control.spec.ts @@ -0,0 +1,700 @@ +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 + webviewFocusTracker: typeof focusTrackerDouble +} + +type TaskDouble = EventEmitter & { + taskId: string + parentTaskId?: string + approveAsk: Mock<() => void> + denyAsk: Mock<() => void> + handleWebviewAskResponse: Mock<(response: "messageResponse", text?: string, images?: string[]) => void> +} + +// Sentinel double: the API must forward the source provider's focus tracker to +// newly created tab providers, so the tests assert this exact object arrives. +const focusTrackerDouble = { kind: "focus-tracker-double" } + +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() + provider.webviewFocusTracker = focusTrackerDouble + 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, + webviewFocusTracker: focusTrackerDouble, + }) + 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, + webviewFocusTracker: focusTrackerDouble, + }) + 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). + // fileLog is private: name it through a structural type (a double assertion + // is the only way to address a private member in a spy without `any`). + const fileLogSpy = vi + .spyOn(API.prototype as unknown as { fileLog: (message: string) => Promise }, "fileLog") + .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 - per-view review fixes", () => { + let outputChannel: vscode.OutputChannel + let sidebarProvider: ProviderDouble + let api: API + + beforeEach(() => { + vi.clearAllMocks() + outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + sidebarProvider = createProvider("sidebar-task") + api = new API(outputChannel, asClineProvider(sidebarProvider), 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) + }) + + it("rejects runtime keys outside the settings schema before touching storage", async () => { + const get = vi.fn() + const update = vi.fn().mockResolvedValue(undefined) + sidebarProvider.context = { + extensionMode: vscode.ExtensionMode.Test, + globalState: { get, update }, + } + api = new API(outputChannel, asClineProvider(sidebarProvider), makeUnusedTabFactory()) + + // The generic only constrains typed callers; a crafted runtime key must + // be refused by the schema check before it reaches globalState storage. + expect(() => api.getGlobalState("bogusKey" as never)).toThrow("outside the settings schema") + expect(get).not.toHaveBeenCalled() + await expect(api.setGlobalState("bogusKey" as never, 1 as never)).rejects.toThrow( + "outside the settings schema", + ) + expect(update).not.toHaveBeenCalled() + + // A schema key still passes through. + expect(api.getGlobalState("mode")).toBeUndefined() + expect(get).toHaveBeenCalledWith("mode") + }) + + it("rejects credential-bearing keys before touching storage", async () => { + const get = vi.fn() + const update = vi.fn().mockResolvedValue(undefined) + sidebarProvider.context = { + extensionMode: vscode.ExtensionMode.Test, + globalState: { get, update }, + } + api = new API(outputChannel, asClineProvider(sidebarProvider), makeUnusedTabFactory()) + + // mimoApiKey and openAiHeaders are schema keys but carry credentials; the + // test-only API must refuse them before storage access, both directions. + expect(() => api.getGlobalState("mimoApiKey" as never)).toThrow("credential-bearing key") + expect(() => api.getGlobalState("openAiHeaders" as never)).toThrow("credential-bearing key") + expect(get).not.toHaveBeenCalled() + await expect(api.setGlobalState("mimoApiKey" as never, "sk-test" as never)).rejects.toThrow( + "credential-bearing key", + ) + expect(update).not.toHaveBeenCalled() + + // A non-credential schema key still passes through. + expect(api.getGlobalState("mode")).toBeUndefined() + expect(get).toHaveBeenCalledWith("mode") + }) + + it("rejects PII keys and malformed values before touching global-state storage", async () => { + const get = vi.fn().mockReturnValue("stored") + const update = vi.fn().mockResolvedValue(undefined) + sidebarProvider.context = { + extensionMode: vscode.ExtensionMode.Test, + globalState: { get, update }, + } + api = new API(outputChannel, asClineProvider(sidebarProvider), makeUnusedTabFactory()) + + // Task history carries user PII and stays unreachable even though it is a + // plain GlobalState key. + expect(() => api.getGlobalState("taskHistory")).toThrow("PII-bearing key") + await expect(api.setGlobalState("taskHistory", [])).rejects.toThrow("PII-bearing key") + // Values are runtime data: a malformed value is rejected before storage. + await expect(api.setGlobalState("alwaysAllowWrite", {} as never)).rejects.toThrow("invalid value for key") + // Provider-settings keys have no global-settings schema entry. + await expect(api.setGlobalState("apiModelId", "x")).rejects.toThrow("without a value schema") + expect(update).not.toHaveBeenCalled() + expect(get).not.toHaveBeenCalled() + // An allowed key with a valid value still works. + await api.setGlobalState("mode", "ask") + expect(update).toHaveBeenCalledWith("mode", "ask") + // Non-strict object schemas strip unknown fields only in the parsed + // value: the API must persist the parsed data, not the raw payload. + await api.setGlobalState("viewStates", { + "view-a": { mode: "debug", apiKey: "stray-secret" }, + } as never) + expect(update).toHaveBeenLastCalledWith("viewStates", { "view-a": { mode: "debug" } }) + }) + }) + + 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 606bb7725d..31cf1f1803 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -8,42 +8,67 @@ 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 TaskStartResponse, type CreateTaskOptions, type TaskApiConversationHistorySequence, type WebviewThemeFixture, RooCodeEventName, TaskCommandName, + isGlobalStateKey, isSecretStateKey, + isSensitiveProviderConfigKey, + globalSettingsSchema, + PROVIDER_SETTINGS_KEYS, IpcOrigin, IpcMessageType, TASK_START_FAILURE_ERROR_CODE, TASK_START_FAILURE_ERROR_MESSAGE, } from "@roo-code/types" - 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 { isNonPortableSettingsKey } from "../core/config/ContextProxy" import { withoutInvalidDisplaySettings } from "../core/config/displaySettings" import { readTaskMessages, TaskMessagesReadError } from "../core/task-persistence/taskMessages" import { getErrorCode } from "../core/task-persistence/readFileWithMissingRetry" import { tracePersistenceEvent } from "../core/task-persistence/persistenceTrace" 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 @@ -58,6 +83,7 @@ export class API extends EventEmitter implements RooCodeAPI { this.outputChannel = outputChannel this.sidebarProvider = provider + this.context = provider.context if (enableLogging) { this.log = (...args: unknown[]) => { @@ -100,7 +126,15 @@ export class API extends EventEmitter implements RooCodeAPI { const { requestId } = command.data if (requestId === undefined) { - await this.startNewTask(command.data, { focusSidebar: false }) + // IpcServer does not await async listeners: an uncaught rejection + // here would surface as an unhandled rejection in the extension host. + try { + await this.startNewTask(command.data, { focusSidebar: false }) + } catch (error) { + this.log( + `[API] StartNewTask failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } break } @@ -215,21 +249,30 @@ export class API extends EventEmitter implements RooCodeAPI { text, images, newTab, + preserveOpenTabs, }: { - configuration: RooCodeSettings + configuration?: RooCodeSettings text?: string images?: string[] newTab?: boolean + preserveOpenTabs?: boolean }, startOptions?: { focusSidebar?: 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 { // IPC callers omit focus: executeCommand cannot be cancelled and a @@ -241,6 +284,43 @@ 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" }) @@ -439,6 +519,110 @@ 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)}`, + ) + } + } + + // The mode switch above awaits, so the registration can change underneath this call: + // the task may have completed and been removed, or a replacement instance may have + // taken the id. Answering the captured entry would then report success for a task the + // registry no longer maps to that instance. + if (this.tasksById.get(taskId) !== entry) { + this.log( + `[API#selectTaskFollowupSuggestion] task ${taskId} was unregistered or replaced during the mode switch; answer not delivered`, + ) + return false + } + + entry.task.handleWebviewAskResponse("messageResponse", answer) + return true + } + public isReady() { return this.sidebarProvider.viewLaunched } @@ -465,7 +649,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, { @@ -478,6 +684,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 () => { @@ -485,8 +693,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, () => { @@ -495,6 +711,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, () => { @@ -586,6 +803,7 @@ export class API extends EventEmitter implements RooCodeAPI { provider.on(RooCodeEventName.TaskDelegationResumed, (parentTaskId, childTaskId) => { this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) }) + this.registeredProviders.add(provider) } // Logging @@ -636,34 +854,357 @@ export class API extends EventEmitter implements RooCodeAPI { // Global Settings Management public getConfiguration(): RooCodeSettings { - return Object.fromEntries( - Object.entries(this.sidebarProvider.getValues()).filter(([key]) => !isSecretStateKey(key)), + // 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() + // viewStates is per-view durable state owned by the extension host: exposing it on + // the flat configuration surface invites a caller to write a stale snapshot + // back and clobber newer per-view selections, so it never leaves here. + const { apiConfiguration, viewStates: _viewStates, ...rest } = values + // A view-local profile is a complete snapshot, not a patch over the shared + // store - getState() resolves it the same way. Drop the shared flat + // provider-settings keys before flattening, or a key the view's profile + // never defined (e.g. another profile's apiModelId or base URL) leaks in + // from whichever profile owns the shared store. The shared apiProvider is + // the one exception: getState() falls back to it when a pinned profile + // carries no provider of its own, so keep that single key as a base the + // profile's own apiProvider (when present) still overrides. + const shared = apiConfiguration + ? Object.fromEntries( + Object.entries(rest).filter( + ([key]) => key === "apiProvider" || !PROVIDER_SETTINGS_KEYS.some((k) => k === key), + ), + ) + : rest + // Secrets never leave the flattened surface: SECRET_STATE_KEYS covers the + // *ApiKey shapes, and SENSITIVE_PROVIDER_CONFIG_KEYS covers credential + // values that use other name shapes (header maps, session tokens, + // service-account JSON, credential-file paths). + // Retired providers keep their legacy fields through passthrough, and those + // names (e.g. groqApiKey, huggingFaceSecurityToken) predate the explicit + // secret lists and appear in neither. Treat credential-shaped key names as + // secret on this flattened surface so a retired profile cannot expose them. + const legacyCredentialKeyRe = /(?:ApiKey|ApiToken|SecurityToken|AccessToken|SessionToken|Credentials|Secret)$/ + const configuration = Object.fromEntries( + Object.entries({ ...shared, ...apiConfiguration }).filter( + ([key]) => + !isSecretStateKey(key) && !isSensitiveProviderConfigKey(key) && !legacyCredentialKeyRe.test(key), + ), ) + // An explicit apiProvider: undefined inside the nested profile clobbers the + // retained shared fallback through the spread above; restore it the same way + // getState() does with ?? so the provider never disappears from the result. + if (configuration.apiProvider === undefined && shared.apiProvider !== undefined) { + configuration.apiProvider = shared.apiProvider + } + return configuration } /** * Stores the settings, then the API profile and the mode profiles. The settings are written with - * `ContextProxy.setValues`, which is not a transaction (see there): if a setting cannot be stored, this rejects - * with a `SettingsWriteError` that lists the keys that were stored and the keys that were not (the cache of the - * failed keys has been read back from storage), the stored settings stay, and nothing after the settings is - * written. Invalid values of the display settings are dropped and logged first. + * `ContextProxy.setValues`, which is not a transaction (see there): if a setting cannot be stored, + * this rejects with a `SettingsWriteError` that lists the keys that were stored and the keys that + * were not (the cache of the failed keys has been read back from storage), the stored settings + * stay, and nothing after the settings is written. Invalid values of the display settings are + * dropped and logged first. + * + * The profile writes that follow the settings are wrapped by this unit. The existence check, the + * prior-record read and the save run inside the manager cross-instance critical section, while the + * prior mode-mapping read runs just before that section so a failed read aborts the call with + * nothing mutated, and the + * compensation runs in its own section after re-reading the record, so a record another view + * created is never deleted by this call rollback. On failure the call restores the prior record, + * the prior shared settings, the prior shared mode and profile pin, and the prior mapping of every + * mode the payload touched, then replays only the settings keys this call itself wrote, taken from + * the snapshot it captured; the replay is best effort - a restore that itself fails is logged + * with the failing key and swallowed, so a rollback can leave some keys restored and others + * not - and the original error is rethrown. */ public async setConfiguration(allValues: RooCodeSettings) { const values = withoutInvalidDisplaySettings(allValues, (key) => this.sidebarProvider.log(`[setConfiguration] Ignored an invalid value for ${key}`), ) - await this.sidebarProvider.contextProxy.setValues(values) - await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) + // Derive the saved profile name once so the sibling refresh and the save + // agree on the empty-string fallback. + const savedProfileName = values.currentApiConfigName || "default" + const manager = this.sidebarProvider.providerSettingsManager + // The payload may carry machine-local state this call does not own (the host-owned viewStates + // map above all): the forward write and the rollback below both filter it out, using the same + // list the export schema and the import path consult (NON_PORTABLE_SETTINGS_KEYS). + const forwardValues: RooCodeSettings = Object.fromEntries( + Object.entries(values).filter(([key]) => !isNonPortableSettingsKey(key)), + ) + // The getter map carries viewStates: a snapshot of every view selection as of now. The rollback + // below replays the snapshot through setValues, which would pin every other view back to where + // this call found them, so the host-owned key is dropped here as well. + const snapshotValues: RooCodeSettings = Object.fromEntries( + Object.entries(this.getConfiguration()).filter(([key]) => !isNonPortableSettingsKey(key)), + ) + const previousSharedSettings = this.sidebarProvider.contextProxy.getProviderSettings() + // getConfiguration() folds this view overlay into the merged copy, so its mode and profile pin + // are this view values rather than the shared ones. Snapshot the shared pair here: the rollback + // below replays the merged copy through setValues, which writes both keys to shared global + // state, and without this snapshot a failed call would leave every other unpinned view on this + // view mode and profile. + const previousSharedMode = this.sidebarProvider.contextProxy.getValue("mode") + const previousSharedProfileName = this.sidebarProvider.contextProxy.getValue("currentApiConfigName") + // Capture the prior mapping of every mode this payload touches, so a partial mode fan-out rolls + // back completely - including mappings this call creates for the first time (an undefined prior + // id restores the no-mapping semantics getModeConfigId reports) - and so that this read joins the + // fail-closed reads below: a rejection here aborts the call before the profile record is created + // or overwritten, which is the state the compensation cannot reconstruct. Held outside the + // critical section on purpose - it lengthens no cross-instance hold, and the mode writes it + // guards happen after the section anyway. + const previousModeConfigs: Array<[Mode, string | undefined]> = [] if (values.modeApiConfigs) { - await Promise.all( - Object.entries(values.modeApiConfigs).map(([mode, configId]) => - this.sidebarProvider.providerSettingsManager.setModeConfig(mode as Mode, configId), - ), + for (const mode of Object.keys(values.modeApiConfigs)) { + previousModeConfigs.push([mode as Mode, await manager.getModeConfigId(mode as Mode)]) + } + } + + // The existence check, the prior-record read and the save run as one critical section of the + // manager cross-instance lock - the same lock the profile rename, the persist path and the + // handler deletions already join. Left unserialised the sequence is: we read the name as absent, + // another view creates it through a locked writer, one of our own writes fails, and our rollback + // deletes that view record together with its secrets. Nothing inside this section joins the + // queue again: hasConfig, getProfile and saveConfig take only the per-instance lock, which is + // the inner one, so the section cannot deadlock against itself. + // Existence comes from the store rather than the cached profile list: a stale list could hide an + // existing record and let rollback delete it. A failed existence read is treated as "may exist" + // so rollback never deletes blindly, and the prior-record read still fails closed before + // anything is mutated. + const { recordExisted, previousRecord, savedId } = await manager.runExclusiveAcrossInstances(async () => { + let existed: boolean + try { + existed = await manager.hasConfig(savedProfileName) + } catch { + existed = true + } + const priorRecord = existed ? await manager.getProfile({ name: savedProfileName }) : undefined + // saveConfig validates before storing, so a rejected save leaves every domain + // untouched and the call fails with nothing to roll back. The record takes the same + // filtered payload as the shared write: a caller may hand this call a stale viewStates + // snapshot (a map of every other view's selection), and storing it in a profile record + // would pin those views to the snapshot the moment the record is re-applied. + const id = await manager.saveConfig(savedProfileName, forwardValues) + return { recordExisted: existed, previousRecord: priorRecord, savedId: id } + }) + try { + await this.sidebarProvider.setValues( + // Only the keys this call owns travel forward; see the filter comment above. + forwardValues, + { savedProfileName }, ) + if (values.modeApiConfigs) { + await Promise.all( + Object.entries(values.modeApiConfigs).map(([mode, configId]) => + manager.setModeConfig(mode as Mode, configId), + ), + ) + } + } catch (error) { + // The save landed: roll back every domain this mutation touched - the + // profile record, the sibling overlays pinned to it, the complete + // settings round trip (global values, view-local state, and sibling + // overlays), the shared provider settings, and the mode mappings. + // Each restore is independent and logged; the original failure is + // what the caller sees. + let previousSettings: ProviderSettings | undefined + if (previousRecord) { + const { name: _previousName, id: _previousId, ...rest } = previousRecord + previousSettings = rest + } + try { + await manager.runExclusiveAcrossInstances(async () => { + if (recordExisted && previousSettings) { + await manager.saveConfig(savedProfileName, previousSettings) + return + } + // This call created the name. A locked writer may have replaced the record between the + // section above and this one, so re-read before deleting: only the record our own save + // wrote is ours to remove, and the id that save returned identifies it. + if (!(await manager.hasConfig(savedProfileName))) { + return + } + const currentRecord = await manager.getProfile({ name: savedProfileName }) + if (currentRecord.id !== savedId) { + this.log( + `[setConfiguration] kept the profile record '${savedProfileName}': another view wrote ` + + `it (id '${currentRecord.id}', this call wrote '${savedId}').`, + ) + return + } + await manager.deleteConfig(savedProfileName) + }) + } catch (restoreError) { + this.log( + `[setConfiguration] failed to restore the profile record '${savedProfileName}': ` + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + // A rollback may restore only what this call wrote, taken from the snapshot it captured. + // Anything else the snapshot happens to hold belongs to a writer that has moved on since + // then, so replaying it would roll another view change back into this one. That subsumes the + // host-owned viewStates map and the pass-through taskHistory key: neither is replayed unless + // this call own payload carried it. + const writtenKeys = new Set(Object.keys(forwardValues)) + const replayValues: RooCodeSettings = Object.fromEntries( + Object.entries(snapshotValues).filter(([key]) => writtenKeys.has(key)), + ) + // Which profile the views return to is read from the snapshot, not from the replay + // filter: a payload that never wrote currentApiConfigName leaves the pin out of the + // replay, and defaulting it here would refresh the overlays pinned to "default" with + // this profile settings and skip the credential replay for the profile that was + // actually active. When the payload did write the pin the two readings are the same + // value, because the replay holds the snapshot value for every key it carries. + const previousProfileName = snapshotValues.currentApiConfigName || "default" + try { + await this.sidebarProvider.setValues(replayValues, { savedProfileName: previousProfileName }) + } catch (restoreError) { + this.log( + "[setConfiguration] failed to restore the previous configuration values: " + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + // The merged replay above wrote this view's mode and pin into shared state, which + // belongs to every unpinned view: put the shared pair back from the pre-write snapshot. + // This touches the shared store only - this view's own overlay keeps the same pair the + // replay just wrote there, so nothing about this view changes. + try { + await this.sidebarProvider.contextProxy.setValues({ + mode: previousSharedMode, + currentApiConfigName: previousSharedProfileName, + }) + } catch (restoreError) { + this.log( + "[setConfiguration] failed to restore the shared mode and profile pin: " + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + // Replay the restored record to the sibling overlays pinned to the + // saved profile AFTER the configuration restore: when the saved profile + // is also the active profile, the restore above refreshes the same + // siblings with non-secret values, and this replay must be the last + // refresh so the overlays end on the restored record's settings. An + // absent prior profile restores an empty overlay. + try { + await this.sidebarProvider.refreshViewLocalStateForUpdatedProfile( + savedProfileName, + previousSettings ?? {}, + ) + } catch (restoreError) { + this.log( + "[setConfiguration] failed to refresh the overlays pinned to '" + + savedProfileName + + "': " + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + // When the mutation switched profiles, the configuration restore above + // refreshed siblings pinned to the previous profile from non-secret + // values; replay that profile's complete stored record so those + // overlays keep their credentials. A previous profile that cannot be + // read keeps the restore's non-secret refresh. + if (previousProfileName !== savedProfileName) { + try { + const previousProfileRecord = await manager.getProfile({ name: previousProfileName }) + const { name: _ppName, id: _ppId, ...previousProfileSettings } = previousProfileRecord + await this.sidebarProvider.refreshViewLocalStateForUpdatedProfile( + previousProfileName, + previousProfileSettings, + ) + } catch (restoreError) { + this.log( + "[setConfiguration] failed to refresh the overlays pinned to '" + + previousProfileName + + "': " + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + } + try { + await this.sidebarProvider.contextProxy.setProviderSettings(previousSharedSettings) + } catch (restoreError) { + this.log( + "[setConfiguration] failed to restore the shared provider settings: " + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + try { + await Promise.all(previousModeConfigs.map(([mode, configId]) => manager.setModeConfig(mode, configId))) + } catch (restoreError) { + this.log( + "[setConfiguration] failed to restore the mode API config mappings: " + + (restoreError instanceof Error ? restoreError.message : String(restoreError)), + ) + } + throw error } await this.sidebarProvider.postStateToWebview() } + public getGlobalState(key: K): GlobalState[K] { + this.assertTestOnlyApi("getGlobalState") + // The generic only constrains typed callers; a crafted runtime key can be any + // string. Credential-bearing keys are refused first: they may not even be part + // of the current settings schema, and the test-only API must never read them. + if (isSecretStateKey(String(key)) || isSensitiveProviderConfigKey(String(key))) { + throw new Error(`getGlobalState received a credential-bearing key: ${String(key)}`) + } + // Refuse keys outside the settings schema before storage sees them, so the + // test-only API cannot read arbitrary global state. + if (!isGlobalStateKey(key)) { + throw new Error(`getGlobalState received a key outside the settings schema: ${String(key)}`) + } + // Task history carries user PII (task texts): out of bounds for this surface + // even though it is a plain GlobalState key. + if (key === "taskHistory") { + throw new Error(`getGlobalState received a PII-bearing key: ${String(key)}`) + } + return this.context.globalState.get(key) + } + + public async setGlobalState(key: K, value: GlobalState[K]): Promise { + this.assertTestOnlyApi("setGlobalState") + // Same runtime boundary as getGlobalState: an unvalidated key must not be + // persisted into global state. + if (isSecretStateKey(String(key)) || isSensitiveProviderConfigKey(String(key))) { + throw new Error(`setGlobalState received a credential-bearing key: ${String(key)}`) + } + // Same credential boundary as getGlobalState: a crafted runtime key must not + // be able to persist secrets through the test-only surface. + if (!isGlobalStateKey(key)) { + throw new Error(`setGlobalState received a key outside the settings schema: ${String(key)}`) + } + // Task history carries user PII (task texts): out of bounds for this surface + // even though it is a plain GlobalState key. + if (key === "taskHistory") { + throw new Error(`setGlobalState received a PII-bearing key: ${String(key)}`) + } + // The value is runtime data: parse it against the key's schema so malformed + // state never reaches durable storage. Keys without a global-settings schema + // entry (provider settings) are not seedable through this surface. + const valueSchema = ( + globalSettingsSchema.shape as Record< + string, + { safeParse: (value: unknown) => { success: boolean; data: unknown } } | undefined + > + )[String(key)] + if (!valueSchema) { + throw new Error(`setGlobalState received a key without a value schema: ${String(key)}`) + } + const parsedValue = valueSchema.safeParse(value) + if (!parsedValue.success) { + throw new Error(`setGlobalState received an invalid value for key: ${String(key)}`) + } + // Persist the parsed value: the object schemas in the settings map are + // non-strict, so the raw payload can carry extra credential- or PII-shaped + // fields that the schema strips; storing parsed data keeps unknown fields + // out of durable global state. + await this.context.globalState.update(key, parsedValue.data as GlobalState[K]) + } + public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) diff --git a/src/package.json b/src/package.json index dde9915cb2..214f769bd6 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%", @@ -251,22 +271,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" }, @@ -275,6 +295,40 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "commandPalette": [ + { + "command": "zoo-code.plusButtonClicked", + "when": "false" + }, + { + "command": "zoo-code.settingsButtonClicked", + "when": "false" + }, + { + "command": "zoo-code.marketplaceButtonClicked", + "when": "false" + }, + { + "command": "zoo-code.historyButtonClicked", + "when": "false" + }, + { + "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/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 770f051dc5..215cb5847c 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -632,7 +632,10 @@ export const ExtensionStateContextProvider: React.FC<{ }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 424255245a..e65e565980 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -23,6 +23,14 @@ import { createInitialExtensionState, } from "../ExtensionStateContext" import { StringCache } from "@/utils/stringCache" +import { vscode } from "@src/utils/vscode" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + getViewStateId: vi.fn(() => "view-a"), + }, +})) const TestComponent = () => { const { @@ -140,8 +148,98 @@ const InitialStateTestComponent = () => { ) } +const ViewLocalStateTestComponent = () => { + const { mode, setMode, currentApiConfigName, setCurrentApiConfigName } = useExtensionState() + + return ( +
+
{mode}
+
{currentApiConfigName}
+ + +
+ ) +} + describe("ExtensionStateContext", () => { afterEach(() => vi.restoreAllMocks()) + + 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("interns initial history and current task items and subsequent state pushes", () => { const internSpy = vi.spyOn(StringCache.prototype, "intern") const item = { id: "task-a", number: 1, ts: 1, task: "history", tokensIn: 0, tokensOut: 0, totalCost: 0 } @@ -1106,5 +1204,35 @@ describe("mergeExtensionState", () => { expect(result.clineMessages).toBe(newMessages) expect(result.clineMessagesSeq).toBe(1) }) + + it("keeps the displayed task id when a stale push names another task", () => { + const prevState: ExtensionState = { + ...baseState, + clineMessages: [makeMessage(1, "hello")], + clineMessagesSeq: 5, + currentTaskId: "task-a", + } + + const result = mergeExtensionState(prevState, { + // A push that lost the seq race cannot establish task identity either. + clineMessagesSeq: 4, + currentTaskId: "old", + }) + + expect(result.currentTaskId).toBe("task-a") + }) + + it("keeps the displayed task id when a message-free push names another task", () => { + const prevState: ExtensionState = { + ...baseState, + clineMessages: [makeMessage(1, "hello")], + clineMessagesSeq: 5, + currentTaskId: "task-a", + } + + const result = mergeExtensionState(prevState, { currentTaskId: "old" }) + + expect(result.currentTaskId).toBe("task-a") + }) }) }) diff --git a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx index 0717361f50..411858a3bb 100644 --- a/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx +++ b/webview-ui/src/i18n/__tests__/TranslationContext.locale-loading.spec.tsx @@ -47,6 +47,15 @@ describe("TranslationProvider locale loading", () => { // through the English fallback and the switch lands after the fetch. expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong") + // A locale outside the entry chunk is fetched on demand, so how long the switch + // takes is a property of the machine and the load on it, not of the code under + // test: on a contended runner the fetch can outlast any fixed wall-clock window, + // which is what made this suite flake. Awaiting the memoized promise the provider + // itself waits on - the idiom the unmount case at the end of this file uses - takes + // the clock out of the assertion. What `waitFor` has left to catch is the render + // that `languageChanged` triggers, which settles in the same task. + await loadLanguage("ko") + await waitFor(() => { expect(screen.getByTestId("error-title")).toHaveTextContent("λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€") }) @@ -67,6 +76,9 @@ describe("TranslationProvider locale loading", () => { ) }) + // Await the fetch instead of racing a fixed window (note in the first case). + await loadLanguage("ja") + await waitFor(() => { expect(screen.getByTestId("error-title")).toHaveTextContent("ε•ι‘ŒγŒη™Ίη”Ÿγ—γΎγ—γŸ") }) @@ -84,6 +96,9 @@ describe("TranslationProvider locale loading", () => { it("keeps English available after switching away and back", async () => { const { rerender } = renderProbe("de") + // German is loaded on demand as well: await it rather than time it. + await loadLanguage("de") + await waitFor(() => { expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") }) @@ -105,6 +120,9 @@ describe("TranslationProvider locale loading", () => { it("keeps the previous locale rendered while a warm switch is still loading", async () => { const { rerender } = renderProbe("de") + // The cold locale is awaited here too (see the note in the first case). + await loadLanguage("de") + await waitFor(() => { expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") }) @@ -123,6 +141,10 @@ describe("TranslationProvider locale loading", () => { // resolves would paint the English fallback here instead of German. expect(screen.getByTestId("error-title")).toHaveTextContent("Etwas ist schiefgelaufen") + // Awaiting the fetch through the promise the provider waits on keeps the assertion + // above a statement about ordering rather than about the clock. + await loadLanguage("fr") + await waitFor(() => { expect(screen.getByTestId("error-title")).toHaveTextContent("Une erreur s'est produite") }) 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..5cb3ed23c7 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,465 @@ +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("replaces a stored Object.prototype member id with a generated one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "constructor" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // The host's setViewStateId rejects every Object.prototype member name; accepting + // "constructor" here would make every launch message rejected and strand the + // pre-launch entry under the temporary id forever. + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-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("falls back to a timestamp-random id when crypto.randomUUID is inherited but not callable", () => { + // "randomUUID" in crypto is also true for an inherited property, so a presence check + // alone would call a value that is not a function and throw before any id is minted. + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: Object.create({ randomUUID: "not-a-function" }), + }) + 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() + + // Same deterministic fallback id as the sibling cases: 1700000000000.toString(36) === + // "loyw3v28" and (0.987654321).toString(36) drops its "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("generates a fresh id when the persisted id exceeds the extension length bound", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "long-refilled-view") }, + }) + // ClineProvider.setViewStateId refuses an id longer than MAX_VIEW_STATE_ID_LENGTH. Without + // the same bound here this view would keep claiming a 65-character id the extension refuses + // on every launch, and its pre-launch entry would stay stranded under the temporary id. + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "a".repeat(65) }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("long-refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "long-refilled-view" }) + }) + + it("reuses a persisted id of exactly the extension length bound", () => { + const randomUUID = vi.fn(() => "should-not-be-generated") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // The bound is inclusive: the host refuses ids longer than 64 characters, so a 64-character + // stored id is still this view id and must be reused rather than regenerated and re-keyed. + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "b".repeat(64) }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("b".repeat(64)) + expect(randomUUID).not.toHaveBeenCalled() + }) + + 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("falls back to in-memory state when the persisted vscodeState JSON is malformed", () => { + // A half-written value is not a state: a read must fall back to the in-memory state + // instead of throwing out of getViewStateId, and once a write fails the stale JSON must + // stay out of the way rather than becoming authoritative again. + const storage = createMockStorage({ vscodeState: '{"viewStateId": "half-writt' }) + storage.setItem = vi.fn(() => { + throw new Error("storage quota exceeded") + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getState()).toBeUndefined() + wrapper.setState({ viewStateId: "in-memory-view" }) + expect(wrapper.getState()).toEqual({ viewStateId: "in-memory-view" }) + expect(wrapper.getViewStateId()).toBe("in-memory-view") + // Only the first read reaches storage: after the failed write the in-memory state is + // authoritative, so the malformed value is never parsed again. + expect(storage.getItem).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..101b946987 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,63 @@ class VSCodeAPIWrapper { } } + /** + * Generates a unique identifier for this webview instance. + * + * @remarks Used only when no persisted identifier exists yet. + */ + private createViewStateId(): string { + // Presence is not enough: a crypto object can inherit a randomUUID property that is + // undefined or not a function, and calling it would throw before any id is minted. + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + // The host enforces the same bound (ClineProvider.MAX_VIEW_STATE_ID_LENGTH) when it accepts + // this id, and it enforces it on every launch, so the webview must not hand it a longer one. + private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 + + /** + * 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 + + // Keep this one an id the extension will accept. ClineProvider.setViewStateId trims and + // rewrites unsafe characters exactly as the line below does, and then rejects only two + // things: an empty result and the "__proto__" key. So "constructor" and "toString" are + // accepted by the host, and this side is deliberately stricter - it refuses every + // Object.prototype name, because writing states["constructor"] hits an inherited key that a + // later spread can drop. The host also refuses ids longer than MAX_VIEW_STATE_ID_LENGTH, + // which is applied here too: a stored id above that bound would be rejected on every + // launch, which is exactly the stranding of this view pre-launch entry this guard exists to + // avoid. Being stricter than the peer is the point - the webview must never register an id + // the host will not take. + const normalizedViewStateId = + typeof existingViewStateId === "string" ? existingViewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") : "" + + if ( + normalizedViewStateId && + normalizedViewStateId.length <= VSCodeAPIWrapper.MAX_VIEW_STATE_ID_LENGTH && + !Object.prototype.hasOwnProperty.call(Object.prototype, normalizedViewStateId) + ) { + 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 +111,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 +145,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 } }