diff --git a/apps/vscode-e2e/src/suite/restart-persistence.test.ts b/apps/vscode-e2e/src/suite/restart-persistence.test.ts index 0d508273e3..dafa4b9727 100644 --- a/apps/vscode-e2e/src/suite/restart-persistence.test.ts +++ b/apps/vscode-e2e/src/suite/restart-persistence.test.ts @@ -61,6 +61,11 @@ async function runCreate(api: RooCodeAPI): Promise { const createdTaskId: string = taskId await waitFor(() => api.hasTaskUiMessageText(createdTaskId, MARKER)) + // history_item.json is written outside the ui_messages.json flush as well: wait for + // the task to be visible in history before quitting, or the verify phase can assert + // on a task that only ever existed in memory. + await waitFor(() => api.isTaskInHistory(createdTaskId)) + const result: PhaseResult = { version: PHASE_RESULT_VERSION, phase: "create", diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index f2b2e7e8e5..a31b482c7b 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, GLOBAL_SETTINGS_KEYS, globalSettingsSchema, + viewStateSchema, } from "../global-settings.js" describe("destructive command guard global setting", () => { @@ -51,3 +52,69 @@ describe("collapseCommandOutputByDefault global setting", () => { expect(globalSettingsSchema.safeParse({ collapseCommandOutputByDefault: "true" }).success).toBe(false) }) }) + +describe("viewStates global setting", () => { + it("preserves each webview instance selection through the persisted schema", () => { + const parsed = globalSettingsSchema.parse({ + viewStates: { + "session-1": { mode: "code", currentApiConfigName: "default", updatedAt: 1700000000000 }, + "session-2": { mode: "architect" }, + }, + }) + // Every field has to survive the round trip: a selection the schema drops is + // written back as absent, so the next window opens in the default mode. + expect(parsed.viewStates).toEqual({ + "session-1": { mode: "code", currentApiConfigName: "default", updatedAt: 1700000000000 }, + "session-2": { mode: "architect" }, + }) + }) + + it("parses a single view state on its own", () => { + expect(viewStateSchema.parse({ mode: "ask", currentApiConfigName: "x", updatedAt: 5 })).toEqual({ + mode: "ask", + currentApiConfigName: "x", + updatedAt: 5, + }) + }) + + it("rejects a numeric mode and names the offending path", () => { + // The control first: the same record without the bad field must parse, so a + // failure here can only mean the schema rejected that value - not that the record + // shape is unsupported or the assertion never ran. + expect(globalSettingsSchema.safeParse({ viewStates: { a: { mode: "code" } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: { mode: 42 } } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "mode"]) + }) + + it("rejects a non-string currentApiConfigName and names the offending path", () => { + expect(globalSettingsSchema.safeParse({ viewStates: { a: { currentApiConfigName: "x" } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: { currentApiConfigName: 7 } } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "currentApiConfigName"]) + }) + + it("rejects a non-numeric updatedAt and names the offending path", () => { + expect(globalSettingsSchema.safeParse({ viewStates: { a: { updatedAt: 1 } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: { updatedAt: "1" } } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "updatedAt"]) + }) + + it("rejects a view state that is not an object", () => { + // A bare string under a session id is what a half-migrated store looks like; + // accepting it defers the crash to whoever reads the selection back. + expect(globalSettingsSchema.safeParse({ viewStates: { a: { mode: "code" } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: "code" } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a"]) + }) +}) 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/global-settings.ts b/packages/types/src/global-settings.ts index f5757a51c7..fe896f3ef9 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(), 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/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index abcd3c28a0..0ce7200370 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -338,6 +338,7 @@ describe("extension.ts", () => { context: mockContext, outputChannel, webviewFocusTracker: tracker, + forceNew: true, }) tracker?.dispose() }) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index c59933a7c1..e0084f51e7 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,9 +1,11 @@ import type { Mock } from "vitest" import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" import { ClineProvider } from "../../core/webview/ClineProvider" import { WebviewFocusTracker } from "../../core/webview/WebviewFocusTracker" import { ContextProxy } from "../../core/config/ContextProxy" import { makeExtensionContext } from "../../test-utils/vscode" +import { MdmService } from "../../services/mdm/MdmService" import { getPanel, getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" @@ -96,10 +98,6 @@ vi.mock("../../services/ripgrep/diagnostic", () => ({ registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), })) -vi.mock("../../services/diagnostics", () => ({ - createDiagnosticsReport: vi.fn().mockResolvedValue(undefined), -})) - describe("getVisibleProviderOrLog", () => { let mockOutputChannel: vscode.OutputChannel @@ -140,16 +138,14 @@ describe("getVisibleProviderOrLog", () => { describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext - let mockVisibleProvider: { - postMessageToWebview: Mock - retryWebviewRecovery: Mock<() => Promise> - evictCurrentTask?: Mock - refreshWorkspace?: Mock - } + let mockVisibleProvider: { postMessageToWebview: Mock; retryWebviewRecovery: Mock<() => Promise> } let mockProvider: { postMessageToWebview: Mock + evictCurrentTask: Mock + refreshWorkspace: Mock webviewFocusTracker: WebviewFocusTracker retryWebviewRecovery: Mock<() => Promise> + whenViewResolved: Mock<() => Promise> } let mockVisibleProviderInstance: ClineProvider let handlers: Record unknown> @@ -181,7 +177,11 @@ 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(), + // plusButtonClicked waits for the view before posting, so the double needs the gate. + whenViewResolved: vi.fn().mockResolvedValue(undefined), } ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) ;(vscode.commands.registerCommand as Mock).mockImplementation( @@ -267,59 +267,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 +447,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 +616,85 @@ 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("waits for the sidebar webview to resolve before posting the chat actions", async () => { + // The worktree flow invokes plusButtonClicked without a sidebar click, so the pane opens but + // resolveWebviewView has not handed the provider its view yet. postMessageToWebview returns + // without sending while the view is unset, so posting immediately drops both actions. + let settleGate!: () => void + const gate = new Promise((resolve) => { + settleGate = resolve + }) + const sidebarProvider = Object.assign({} as ClineProvider, { + isViewLaunched: false, + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + whenViewResolved: vi.fn().mockReturnValue(gate), + }) + 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: sidebarProvider }) - await handlers["zoo-code.plusButtonClicked"]() + const handler = commandHandlers["zoo-code.plusButtonClicked"]() - expect(evictCurrentTask).toHaveBeenCalledTimes(1) - }) + // The pane opened, but nothing may be posted until the view itself resolves. Drain the + // microtask queue so the handler reaches the gate and stops there. + await new Promise((resolve) => setImmediate(resolve)) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith(expect.stringContaining("SidebarProvider.focus")) + expect(sidebarProvider.postMessageToWebview).not.toHaveBeenCalled() - it("plusButtonClicked is a no-op when no visible provider", async () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + settleGate() + await handler - // Should not throw even with no visible provider + expect(sidebarProvider.postMessageToWebview).toHaveBeenNthCalledWith(1, { + type: "action", + action: "chatButtonClicked", + }) + expect(sidebarProvider.postMessageToWebview).toHaveBeenNthCalledWith(2, { + type: "action", + action: "focusInput", + }) + }) + it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { await handlers["zoo-code.plusButtonClicked"]() + + 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("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" }) }) }) @@ -532,6 +764,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 +777,762 @@ 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("disposes the provider and the panel when a creation step rejects", async () => { + const panel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panel) + + // Fail the LAST creation step: the provider is constructed, the panel is created and + // tracked, and the view resolved - exactly the state a later "Open in editor" would + // otherwise inherit as a dead tab. + const executeCommandMock = vscode.commands.executeCommand as Mock + const previousExecute = executeCommandMock.getMockImplementation() + executeCommandMock.mockImplementation(async (command: string) => { + if (command === "workbench.action.lockEditorGroup") { + throw new Error("lock failed") + } + return previousExecute?.(command) + }) + + try { + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("lock failed") + + // Nothing half-built may stay behind: the provider is disposed (it registers in + // ClineProvider.activeInstances with its listeners), the panel is disposed, and the + // tracked tab ref no longer points at the orphan panel. + const created = vi.mocked(ClineProvider).mock.results[0].value + expect(created.dispose).toHaveBeenCalledTimes(1) + expect(panel.dispose).toHaveBeenCalledTimes(1) + expect(getPanel()).toBeUndefined() + } finally { + // Restore the saved implementation rather than mockRestore(): this file's beforeEach + // only clears call history, and mockRestore() on a bare vi.fn() leaves executeCommand + // with no implementation at all, so every later tab creation in this file breaks. + // In a finally so a failing assertion cannot leak the override. + // The saved default can be undefined (a bare vi.fn()), which is behaviourally + // the same as a no-op implementation - mockImplementation needs a function. + executeCommandMock.mockImplementation(previousExecute ?? (async () => undefined)) + } + }) + + it("reports incomplete cleanup when the tab-creation rollback itself fails", async () => { + const panel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn().mockImplementation(() => { + throw new Error("panel gone") + }), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panel) + const executeCommandMock = vscode.commands.executeCommand as Mock + const previousExecute = executeCommandMock.getMockImplementation() + executeCommandMock.mockImplementation(async (command: string) => { + if (command === "workbench.action.lockEditorGroup") { + // Arm the failing provider cleanup here: the instance exists by now, and this is + // the last await before the rollback runs, so the ordering is deterministic. + vi.mocked(ClineProvider).mock.results[0].value.dispose = vi + .fn() + .mockRejectedValue(new Error("dispose hung")) + throw new Error("lock failed") + } + return previousExecute?.(command) + }) + + // The original failure is still what the caller sees... + try { + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("lock failed") + + // ...both cleanup steps were attempted, and the incomplete result is surfaced + // rather than swallowed behind the original error. + const created = vi.mocked(ClineProvider).mock.results[0].value + expect(created.dispose).toHaveBeenCalledTimes(1) + expect(panel.dispose).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("cleanup was incomplete (provider: dispose hung; panel: panel gone)"), + ) + } finally { + // Restore the saved implementation rather than mockRestore(): this file's beforeEach + // only clears call history, and mockRestore() on a bare vi.fn() leaves executeCommand + // with no implementation at all, so every later tab creation in this file breaks. + // In a finally so a failing assertion cannot leak the override. + // The saved default can be undefined (a bare vi.fn()), which is behaviourally + // the same as a no-op implementation - mockImplementation needs a function. + executeCommandMock.mockImplementation(previousExecute ?? (async () => undefined)) + } + }) + + 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 () => { + // Scoped to the single lookup openClineInNewTab performs: this file's beforeEach only + // clears call history, so a plain mockImplementation would keep throwing for every + // later test in this file. + ;(MdmService.getInstance as Mock).mockImplementationOnce(() => { + throw new Error("MDM service not initialized") + }) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The creation must survive the MDM lookup failure: the provider is + // constructed with an undefined MDM service and the tab panel is + // still created. + const ctor = vi.mocked(ClineProvider) + expect(ctor.mock.instances[0]).toBeDefined() + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + undefined, + ) + expect(provider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The fallback is observable in the output channel. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openClineInNewTab] MDM service unavailable, continuing without it: Error: MDM service not initialized", + ) + }) + + it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { + // The vscode mock factory declares a mutable visibleTextEditors slot that the + // readonly public API type hides, so seed it through Object.assign. + Object.assign(vscode.window, { visibleTextEditors: [] }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.lockEditorGroup") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + vscode.ViewColumn.Two, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [mockContext.extensionUri], + }, + ) + + // The panel icon points at the extension's asset files. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + iconPath?: { light: { path: string }; dark: { path: string } } + } + expect(panel.iconPath).toEqual({ + light: { path: "assets/icons/panel_light.png" }, + dark: { path: "assets/icons/panel_dark.png" }, + }) + }) + + it("treats editors without a viewColumn as column 0 when computing the target column", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixture keeps that single field. + const editorWithoutColumn = Object.assign({} as vscode.TextEditor, { viewColumn: undefined }) + Object.assign(vscode.window, { + visibleTextEditors: [editorWithoutColumn], + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // lastCol falls back to 0, so the panel lands on column 1 instead of + // opening a new editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 1, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("places the tab panel one column right of the rightmost visible editor", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixtures keep that single field. + Object.assign(vscode.window, { + visibleTextEditors: [ + Object.assign({} as vscode.TextEditor, { viewColumn: 1 }), + Object.assign({} as vscode.TextEditor, { viewColumn: 3 }), + ], + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // lastCol is 3, so the panel lands on column 4 without opening a new + // editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 4, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { + // MdmService has a private constructor, so pin a sentinel stand-in. + const mockMdm = Object.assign({} as MdmService, { name: "mock-mdm" }) + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const ctor = vi.mocked(ClineProvider) + expect(ctor).toHaveBeenCalledTimes(1) + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + mockMdm, + ) + expect(provider).toBe(ctor.mock.instances[0]) + }) + + it("posts didBecomeVisible only for visible state changes and clears the tracked tab on dispose", async () => { + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Retain the panel returned during creation and pin the tracked tab + // against it with identity (not a weak defined check), so a wrong or + // duplicated tracked panel fails before the dispose assertions. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + onDidChangeViewState: Mock + onDidDispose: Mock + } + expect(getPanel()).toBe(panel) + + const stateHandler = panel.onDidChangeViewState.mock.calls[0][0] as (event: { + webviewPanel: { visible: boolean; webview: { postMessage: (message: unknown) => void } } + }) => void + const visibleEvent = { webviewPanel: { visible: true, webview: { postMessage: vi.fn() } } } + stateHandler(visibleEvent) + expect(visibleEvent.webviewPanel.webview.postMessage).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + + const hiddenEvent = { webviewPanel: { visible: false, webview: { postMessage: vi.fn() } } } + stateHandler(hiddenEvent) + expect(hiddenEvent.webviewPanel.webview.postMessage).not.toHaveBeenCalled() + + const disposeHandler = panel.onDidDispose.mock.calls[0][0] as () => void + disposeHandler() + expect(getPanel()).toBeUndefined() + }) + + it("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("gives each overlapping forced call its own provider", async () => { + // startNewTask({ newTab: true }) forces a new tab because it has just closed the editors. + // Two of those overlapping must not join the same creation: sharing one provider would make + // both callers evict and re-create the task on the same webview and lose the first task. + const first = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + forceNew: true, + }) + const second = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + forceNew: true, + }) + + const [firstProvider, secondProvider] = await Promise.all([first, second]) + + const ctor = vi.mocked(ClineProvider) + expect(ctor.mock.instances.length).toBe(2) + expect(firstProvider).toBe(ctor.mock.instances[0]) + expect(secondProvider).toBe(ctor.mock.instances[1]) + expect(firstProvider).not.toBe(secondProvider) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + }) + + it("keeps a forced creation from evicting an in-flight ordinary creation", async () => { + // An ordinary "Open in editor" call is mid-creation when startNewTask({ newTab: true }) starts. + // The forced call must create its own provider without taking the shared slot away from the + // ordinary one: a caller that starts afterwards is asking for the panel the ordinary creation + // is building, and receiving the forced creation's promise would reject it for a failure of + // someone else's creation while its own creation still opens a panel nobody awaits. + let resolveContextProxy!: () => void + const gate = new Promise((resolve) => { + resolveContextProxy = resolve + }) + ;(ContextProxy.getInstance as Mock).mockReturnValue(gate) + + const ordinary = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + const forced = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + forceNew: true, + }) + const later = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + resolveContextProxy() + const [ordinaryProvider, forcedProvider, laterProvider] = await Promise.all([ordinary, forced, later]) + + const ctor = vi.mocked(ClineProvider) + // The ordinary creation keeps the slot, so the call that starts after the forced one still + // joins the ordinary creation instead of the forced one. + expect(ordinaryProvider).toBe(ctor.mock.instances[0]) + expect(laterProvider).toBe(ctor.mock.instances[0]) + // The forced call still gets its own provider and its own panel. + expect(forcedProvider).toBe(ctor.mock.instances[1]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + }) + + it("lets a later ordinary call join a forced creation that started with a free slot", async () => { + // The store guard is only about not evicting somebody else's creation. A forced call that + // starts while the slot is free still occupies it, so a non-forced caller that starts during + // that creation reuses its panel rather than opening a second one. + let resolveContextProxy!: () => void + const gate = new Promise((resolve) => { + resolveContextProxy = resolve + }) + ;(ContextProxy.getInstance as Mock).mockReturnValue(gate) + + const forced = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + forceNew: true, + }) + const later = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + resolveContextProxy() + const [forcedProvider, laterProvider] = await Promise.all([forced, later]) + + const ctor = vi.mocked(ClineProvider) + expect(forcedProvider).toBe(ctor.mock.instances[0]) + expect(laterProvider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + it("shares one in-flight creation when openInNewTab and popoutButtonClicked start before it resolves", async () => { + // Defer the first creation at ContextProxy.getInstance so both command + // handlers can start while the creation is still in flight. + let resolveContextProxy!: () => void + ;(ContextProxy.getInstance as Mock).mockReturnValue( + new Promise((resolve) => { + resolveContextProxy = resolve + }), + ) + + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + const sidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: sidebarProvider, + }) + + const started = [commandHandlers["zoo-code.openInNewTab"](), commandHandlers["zoo-code.popoutButtonClicked"]()] + + // While the shared creation is suspended at ContextProxy.getInstance, + // neither caller has created a panel yet. + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + + resolveContextProxy() + const [first, second] = await Promise.all(started) + + // Both command entry points await the shared in-flight creation: + // exactly one tab panel is created and both results are the same + // constructed provider. + const ctor = vi.mocked(ClineProvider) + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("creates a fresh panel for a new call once the previous creation settled and its provider disposed", async () => { + // The first open settles and tracks its panel. + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The tracked provider is disposed, so the next open cannot reuse the + // existing tab: the settled (and cleared) in-flight promise must not + // be returned, and a fresh panel is created. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + const secondProvider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const ctor = vi.mocked(ClineProvider) + const second = ctor.mock.instances[1] + expect(second).toBeDefined() + expect(secondProvider).toBe(second) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + }) + + it("clears the in-flight creation when it rejects so a later open retries", async () => { + // A creation that fails must not stay in the slot: every later "Open in editor" + // would receive the settled rejected promise and the tab could never be opened + // again without reloading the window. + const failure = new Error("context proxy unavailable") + ;(ContextProxy.getInstance as Mock).mockRejectedValueOnce(failure) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow(failure) + + // The retry starts a fresh creation instead of replaying the stored rejection. + // Capture how many providers have been constructed so the retry's own + // instance can be identified below. + const ctor = vi.mocked(ClineProvider) + const createdBeforeRetry = ctor.mock.instances.length + const retried = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + // Identity, not just definedness: the retry must return the provider it + // constructed in this call, not a leftover from the rejected one. + const expected = ctor.mock.instances[createdBeforeRetry] + expect(expected).toBeDefined() + expect(retried).toBe(expected) + expect(ContextProxy.getInstance).toHaveBeenCalledTimes(2) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("shares a rejected creation with overlapping callers and still clears the slot", async () => { + // Both callers join the same in-flight creation, so both must observe its + // rejection: dropping it for the joined caller would leave that command + // handler hanging on a promise that never yields a usable result. + let rejectCreation!: (error: Error) => void + // Only the in-flight creation is deferred; the retry after it settles gets the + // normal (immediate) context proxy. + ;(ContextProxy.getInstance as Mock).mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectCreation = reject + }), + ) + + const first = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + const second = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Attach the observers before the rejection is triggered. + const firstSettled = first.then( + () => "resolved", + (error: unknown) => error, + ) + const secondSettled = second.then( + () => "resolved", + (error: unknown) => error, + ) + + const failure = new Error("panel creation failed") + rejectCreation(failure) + + expect(await firstSettled).toBe(failure) + expect(await secondSettled).toBe(failure) + expect(ContextProxy.getInstance).toHaveBeenCalledTimes(1) + + // The slot is cleared even on the shared-rejection path: the next open runs a + // fresh creation instead of replaying the stored rejection. + const ctor = vi.mocked(ClineProvider) + const createdBeforeRetry = ctor.mock.instances.length + const third = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + // Identity, not just definedness: the next open returns the provider this + // call constructed, proving the slot was cleared rather than replayed. + const expected = ctor.mock.instances[createdBeforeRetry] + expect(expected).toBeDefined() + expect(third).toBe(expected) + expect(ContextProxy.getInstance).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", + }) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f0f4747eee..d170f4231d 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" @@ -13,6 +13,7 @@ import { handleNewTask } from "./handleTask" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic" +import { Package } from "../shared/package" import { createDiagnosticsReport } from "../services/diagnostics" /** @@ -31,8 +32,16 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl let sidebarPanel: vscode.WebviewView | undefined = undefined let tabPanel: vscode.WebviewPanel | undefined = undefined +// 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 + * Get the currently active panel. + * + * @internal Exported for focused implementation tests. * @returns WebviewPanelꈖWebviewView */ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined { @@ -40,7 +49,12 @@ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined } /** - * Set panel references + * Set panel references. + * + * The two refs are independent: each surface keeps its own ref for its whole + * lifetime, so resolving the sidebar view never wipes a live tab panel (and + * vice versa). Callers pass `undefined` only when the surface itself is + * disposed (see the `onDidDispose` wiring in `openClineInNewTab`). */ export function setPanel( newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, @@ -48,13 +62,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 +104,73 @@ 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() + + // The worktree auto-open flow (`extension.ts`) invokes this command without a sidebar click, + // so the view may not be resolved yet: focus it explicitly instead of posting into a webview + // that does not exist. On the menu path the view is already resolved and this is a no-op. + if (!provider.isViewLaunched) { + await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) + // The focus command settles when VS Code opens the pane, not when resolveWebviewView + // hands this provider its view, and postMessageToWebview returns without sending while the + // view is unset - both actions below would be dropped. Wait for the resolution that this + // command just asked VS Code to start. The wait releases on one of three things: this + // provider receives its view, the sidebar view is disposed (the pane was closed before + // resolving), or the provider is disposed. It has no clock of its own, so a view that is + // neither resolved nor torn down leaves this command pending until one of those happens. + await provider.whenViewResolved() + } + 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 +180,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 +256,15 @@ 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 if (sidebarPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -231,9 +314,122 @@ type OpenClineInNewTabOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel webviewFocusTracker: WebviewFocusTracker + // `startNewTask({ newTab: true })` asks for a tab because it has just closed the editors; the + // title-bar buttons ask for one only to reveal what is already there. Only the first must + // bypass the tracked-panel reuse, so the caller states it instead of the factory guessing. + forceNew?: boolean } -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. + // A forced call must not join somebody else's in-flight creation. forceNew means + // "this caller has just closed the editors and needs its own provider", so two + // overlapping startNewTask({ newTab: true }) calls must each get a distinct provider: + // sharing one would make both evict and re-create the task on the same webview. The + // non-forced callers - the title-bar reveal buttons - keep reusing the pending panel. + if (pendingTabPanelCreation && !options.forceNew) { + return pendingTabPanelCreation + } + + const creation = createTabPanelUnlocked(options) + // The forced call creates its own provider but only occupies the shared slot when the + // slot is free. A forced call that arrives while an ordinary creation is in flight must + // not evict it: an ordinary caller that starts afterwards is asking for the panel that + // creation is building, and giving it the forced promise would reject it for a failure of + // somebody else's creation while its own creation still opens a panel nobody awaits. + if (!pendingTabPanelCreation) { + 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. The equality check keeps this settlement from + // clearing a slot it does not own, which is the normal outcome for a + // forced call that started while an ordinary creation held the slot: that + // forced call never stored anything, so the ordinary creation stays. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined + } + } +} + +/** + * Undo a tab creation that failed part way through. A ClineProvider that was constructed + * but never resolved stays registered in ClineProvider.activeInstances with its listeners + * attached, and setPanel may already have pointed the tracked tab ref at a panel nobody + * owns - so every later "Open in editor" either reuses a dead panel or builds a + * second provider for the same view. Each step is awaited in its own try/catch so one + * failing cleanup cannot strand the other, and the tracked ref is cleared only while it + * still names this panel, so a replacement created in the meantime survives. + */ +async function disposeFailedTabCreation( + provider: ClineProvider, + panel: vscode.WebviewPanel | undefined, + outputChannel: vscode.OutputChannel, +): Promise { + const describe = (e: unknown) => (e instanceof Error ? e.message : String(e)) + const cleanupFailures: string[] = [] + try { + await provider.dispose() + } catch (cleanupError: unknown) { + cleanupFailures.push(`provider: ${describe(cleanupError)}`) + } + + if (panel) { + if (tabPanel === panel) { + setPanel(undefined, "tab") + } + try { + panel.dispose() + } catch (cleanupError: unknown) { + cleanupFailures.push(`panel: ${describe(cleanupError)}`) + } + } + + if (cleanupFailures.length > 0) { + outputChannel.appendLine( + `[openClineInNewTab] Tab creation failed and cleanup was incomplete (${cleanupFailures.join("; ")}); a leaked provider or panel may remain.`, + ) + } +} + +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async ({ + context, + outputChannel, + webviewFocusTracker, + forceNew, +}: OpenClineInNewTabOptions) => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. A caller that + // needs a genuinely new tab (startNewTask closes the editors first) passes + // `forceNew` and skips the reveal. + if (tabPanel && !forceNew) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + + // The tracked panel has no live provider (closed or disposed out from under us). + // Drop the stale reference before creating a replacement: an await below that + // rejects would otherwise leave tabPanel pointing at the dead panel, and focusInput + // would take the tab branch and post nothing even though a sidebar exists. + tabPanel = undefined + } + // (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 +440,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 } @@ -257,61 +454,80 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr webviewFocusTracker, mdmService, ) - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) + // Track the panel outside the try so the cleanup can reach it even when the creation + // failed before or while the panel was being built. + let newPanel: vscode.WebviewPanel | undefined + try { + const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - // Check if there are any visible text editors, otherwise open a new group - // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + // Check if there are any visible text editors, otherwise open a new group + // to the right. + const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } - - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + if (!hasVisibleEditors) { + await vscode.commands.executeCommand("workbench.action.newGroupRight") + } - const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - // Save as tab type panel. - setPanel(newPanel, "tab") + newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }) - // TODO: Use better svg icon with light and dark variants (see - // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). - newPanel.iconPath = { - light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), - dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), - } + // Save as tab type panel. + setPanel(newPanel, "tab") - await tabProvider.resolveWebviewView(newPanel) + // TODO: Use better svg icon with light and dark variants (see + // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). + newPanel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), + } - // Add listener for visibility changes to notify webview - newPanel.onDidChangeViewState( - (e) => { - const panel = e.webviewPanel - if (panel.visible) { - panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx - } - }, - null, // First null is for `thisArgs` - context.subscriptions, // Register listener for disposal - ) + await tabProvider.resolveWebviewView(newPanel) + + // 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 + } + }, + null, // First null is for `thisArgs` + context.subscriptions, // Register listener for disposal + ) - // Handle panel closing events. - newPanel.onDidDispose( - () => { - // A recovery may already have installed a replacement panel. - if (tabPanel === newPanel) setPanel(undefined, "tab") - }, - null, - context.subscriptions, // Also register dispose listener - ) + // Handle panel closing events. + newPanel.onDidDispose( + () => { + if (tabPanel === newPanel) { + // A recovery may already have installed a replacement panel. + setPanel(undefined, "tab") + } + }, + null, + context.subscriptions, // Also register dispose listener + ) - // Lock the editor group so clicking on files doesn't open them over the panel. - await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + // Lock the editor group so clicking on files doesn't open them over the panel. + await delay(100) + await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - return tabProvider + return tabProvider + } catch (error: unknown) { + // Nothing that half-built this tab is safe to leave behind: see + // disposeFailedTabCreation. The original failure is what the caller must see. + await disposeFailedTabCreation(tabProvider, newPanel, outputChannel) + throw error + } } diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index e53f60fb83..26a253057e 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -67,6 +67,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // Per-view selection state is machine-local: it keeps flowing through the + // normal runtime and pruning paths but must not transfer between settings. + viewStates: true, }) export class ContextProxy { diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 01695d7994..a7b740e65a 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() @@ -456,7 +469,7 @@ export class ProviderSettingsManager { name = params.name if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config with name '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config with name '${name}' not found`) } providerSettings = providerProfiles.apiConfigs[name] @@ -468,7 +481,7 @@ export class ProviderSettingsManager { ) if (!entry) { - throw new Error(`Config with ID '${id}' not found`) + throw new ProviderSettingsNotFoundError(`Config with ID '${id}' not found`) } name = entry[0] @@ -478,10 +491,31 @@ export class ProviderSettingsManager { return { name, ...providerSettings } }) } catch (error) { + // Keep the typed not-found signal: callers branch on it to treat an already + // deleted profile as an idempotent no-op instead of matching message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to get profile: ${error instanceof Error ? error.message : error}`) } } + /** + * Read the manager-owned current profile selection. Exposed for callers that + * snapshot the full activation state before a mutation group so a failure can + * compensate the manager store as well as its ContextProxy copies. + */ + public async getCurrentProfileName(): Promise { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + return providerProfiles.currentApiConfigName + }) + } catch (error) { + throw new Error(`Failed to read the active profile: ${error instanceof Error ? error.message : error}`) + } + } + /** * Activate a profile by name or ID. */ @@ -511,7 +545,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 +556,11 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + // A missing config is a caller-meaningful signal, not a failure: rethrow it + // unwrapped so callers can branch on the type instead of message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/config/__tests__/ContextProxy.globalStateRestore.spec.ts b/src/core/config/__tests__/ContextProxy.globalStateRestore.spec.ts new file mode 100644 index 0000000000..da408dcfc5 --- /dev/null +++ b/src/core/config/__tests__/ContextProxy.globalStateRestore.spec.ts @@ -0,0 +1,168 @@ +// vitest core/config/__tests__/ContextProxy.globalStateRestore.spec.ts + +import * as vscode from "vscode" + +import { makeEventEmitter, makeExtensionContext, makeUri } from "../../../test-utils/vscode" +import { ContextProxy, isPassThroughStateKey } from "../ContextProxy" + +vi.mock("vscode", () => ({ + Uri: { + file: vi.fn((path: string) => ({ path })), + }, + ExtensionMode: { + Development: 1, + Production: 2, + Test: 3, + }, +})) + +// Storage failures are correlated in the real world - a full disk or an unavailable store makes +// every write fail at once - so the interesting case is two writes in flight together, where the +// value an older write would restore is itself something storage never accepted. +describe("ContextProxy globalState cache restore", () => { + const KEY = "currentApiConfigName" + + const setup = (persisted: string, failOnValues: readonly string[] = [], deferredUpdates = false) => { + const store = new Map([[KEY, persisted]]) + let storageWorks = true + + // Deferred mode for the two overlapping-write cases below: each update parks in this list instead + // of rejecting at once, so the test - not the fixture - decides whose rejection handler runs first. + const pending: { value: unknown; resolve: () => void; reject: (error: Error) => void }[] = [] + + // Plain functions rather than vi.fn for the memento doubles: vitest's Mock wrapper + // instantiates a generic signature at its call-site type, which drops the genericity + // Memento.get and Secrets.get are declared with, and nothing here asserts on spies. + // The store read carries one widening cast to come back as T, the same shape + // makeWorkspaceConfiguration uses for its own generic get. + const mockGlobalState = { + get(key: string, defaultValue?: T): T | undefined { + return (store.get(key) as T | undefined) ?? defaultValue + }, + keys(): string[] { + return [...store.keys()] + }, + setKeysForSync(_keys: readonly string[]): void {}, + async update(key: string, value: unknown): Promise { + if (deferredUpdates) { + return new Promise((resolve, reject) => { + pending.push({ value, resolve, reject }) + }) + } + if (failOnValues.includes(value as string)) { + throw new Error("storage unavailable") + } + if (!storageWorks) { + throw new Error("storage unavailable") + } + store.set(key, value) + }, + } + const mockSecrets = { + async get(key: string): Promise { + return key === KEY ? undefined : "test-secret" + }, + async store(_key: string, _value: string): Promise {}, + async delete(_key: string): Promise {}, + onDidChange: makeEventEmitter<{ key: string }>().event, + } + // The assembled context carries one double assertion: the fixture supplies the storage and + // log URIs and a packageJSON shim that a real extension context carries as class-backed + // members, and no typed shape in the vscode module describes this fixture in full. + // AGENTS.md allows a double assertion as a last resort when a comment explains why, and + // makeExtensionContext assembles its own return value the same way. + const context = { + ...makeExtensionContext({ + globalState: mockGlobalState, + secrets: mockSecrets, + extensionUri: makeUri("/test/extension"), + extensionPath: "/test/extension", + extensionMode: vscode.ExtensionMode.Development, + }), + globalStorageUri: makeUri("/test/storage"), + logUri: makeUri("/test/logs"), + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + return { + proxy: new ContextProxy(context), + store, + setStorageWorks: (works: boolean) => (storageWorks = works), + pending, + } + } + it("only restores for keys the cache actually owns", () => { + // Guards the tests below: if this key were passed straight through to storage, the cache + // would never participate in a rejection and a restore assertion would pass for the wrong + // reason. + expect(isPassThroughStateKey(KEY)).toBe(false) + }) + + it.each(["A rejects first", "B rejects first"])( + "keeps the cache on the persisted value when two overlapping writes both reject (%s)", + async (_order: string) => { + const { proxy, store, pending } = setup("X", [], true) + await proxy.initialize() + + const writeA = Promise.resolve(proxy.updateGlobalState(KEY, "a")) + const writeB = Promise.resolve(proxy.updateGlobalState(KEY, "b")) + + // Both writes park in the memento, so nothing has rejected yet and the case really does + // choose whose rejection handler runs first. Rejecting at once, as this fixture used to, + // made the handlers run in creation order whatever the case awaited first, so the two + // cases were the same scenario and a B-before-A break could not show up. + expect(pending.map((p) => p.value)).toEqual(["a", "b"]) + const [a, b] = pending + + if (_order === "A rejects first") { + a.reject(new Error("storage unavailable")) + await expect(writeA).rejects.toThrow("storage unavailable") + b.reject(new Error("storage unavailable")) + await expect(writeB).rejects.toThrow("storage unavailable") + } else { + b.reject(new Error("storage unavailable")) + await expect(writeB).rejects.toThrow("storage unavailable") + a.reject(new Error("storage unavailable")) + await expect(writeA).rejects.toThrow("storage unavailable") + } + + // Write B owns the key: it started last, so B is the only write allowed to correct the + // cache, and it reads the value back from the store. Under A rejects first the older write + // A has already failed and its handler has already run, so it must leave the cache alone + // even though it ran before the owner. Restoring each write's own pre-write cache value + // instead - the generation-map design this guard replaced - splits the two cases: measured on + // that design, A rejects first is the case that ends with the cache advertising 'a', a value + // the store never accepted, while B rejects first happens to land back on 'X'. + expect(proxy.getGlobalState(KEY)).toBe("X") + expect(store.get(KEY)).toBe("X") + }, + ) + + it("keeps the value an older write confirmed when a newer write rejects", async () => { + // rt4474947849: write A starts, write B starts, A resolves, B rejects. Storage ends + // holding A's value, so after B's rejection the cache has to report the same thing + // rather than the value it held before A was confirmed. + const { proxy, store } = setup("X", ["b"]) + const writeA = proxy.updateGlobalState(KEY, "a") + const writeB = proxy.updateGlobalState(KEY, "b") + + await expect(Promise.resolve(writeA)).resolves.toBeUndefined() + await expect(Promise.resolve(writeB)).rejects.toThrow("storage unavailable") + + expect(proxy.getGlobalState(KEY)).toBe("a") + expect(store.get(KEY)).toBe("a") + }) + it("restores the value storage last confirmed rather than the value before the failed write", async () => { + const { proxy, store, setStorageWorks } = setup("X") + await proxy.initialize() + + await Promise.resolve(proxy.updateGlobalState(KEY, "confirmed")) + expect(store.get(KEY)).toBe("confirmed") + + setStorageWorks(false) + const failing = Promise.resolve(proxy.updateGlobalState(KEY, "rejected")) + await expect(failing).rejects.toThrow("storage unavailable") + + expect(proxy.getGlobalState(KEY)).toBe("confirmed") + expect(store.get(KEY)).toBe("confirmed") + }) +}) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 5d78e16351..e610349f0c 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -121,6 +121,43 @@ describe("ContextProxy", () => { expect(result).toBe("deepseek") }) + it("should restore the cache when a global state write rejects", async () => { + // A rejected write is corrected from the Memento, so the fixture has to model what the Memento + // holds: the value storage already carries for this key. Left as a bare vi.fn() the + // read-back has nothing to restore and the assertion below would measure the mock, not + // the code. + mockGlobalState.get.mockImplementation((key: string) => (key === "apiProvider" ? "anthropic" : undefined)) + await proxy.initialize() + await proxy.updateGlobalState("apiProvider", "anthropic") + mockGlobalState.update.mockRejectedValueOnce(new Error("write failed")) + + await expect(proxy.updateGlobalState("apiProvider", "openrouter")).rejects.toThrow("write failed") + + // The rejected write never reached storage: getGlobalState must not report it. + expect(proxy.getGlobalState("apiProvider")).toBe("anthropic") + }) + + it("should not roll the cache back over a newer write for the same key", async () => { + await proxy.updateGlobalState("apiProvider", "anthropic") + let rejectOlderWrite: (error: Error) => void = () => {} + mockGlobalState.update + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectOlderWrite = reject + }), + ) + .mockResolvedValueOnce(undefined) + + const olderWrite = proxy.updateGlobalState("apiProvider", "openrouter") + // A newer write stores the same value and succeeds while the older one is pending. + await proxy.updateGlobalState("apiProvider", "openrouter") + rejectOlderWrite(new Error("older write failed")) + await expect(olderWrite).rejects.toThrow("older write failed") + + // The older rejection must not restore the cache to the previous value. + expect(proxy.getGlobalState("apiProvider")).toBe("openrouter") + }) + it("should bypass cache for pass-through state keys", async () => { // Setup mock return value mockGlobalState.get.mockReturnValue("pass-through-value") @@ -1170,4 +1207,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..24577c9257 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 @@ -973,11 +973,30 @@ 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", ) }) + it("should not surface a storage read failure as ProviderSettingsNotFoundError", async () => { + // deleteProviderProfile() branches on the not-found type to prune a stale list entry as an + // idempotent success, so a storage outage must stay distinguishable from a missing profile: + // typed as not-found it would prune the entry while the stored record is still there. + mockSecrets.get.mockRejectedValue(new Error("Storage failed")) + + const error = await providerSettingsManager.deleteConfig("nonexistent").catch((caught: unknown) => caught) + + expect(error).not.toBeInstanceOf(ProviderSettingsNotFoundError) + expect(error).toMatchObject({ message: expect.stringContaining("Failed to delete config:") }) + // Nothing was rewritten: the outage refused before the profile list was touched. + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + it("should throw error when trying to delete last remaining config", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ @@ -1049,6 +1068,88 @@ describe("ProviderSettingsManager", () => { ) }) + it("rejects a missing profile lookup with the typed not-found error", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: { config: {}, id: "default" } }, + }), + ) + + // Callers branch on the type, not the message: deleteProviderProfile treats a missing + // profile as an expected condition to prune from the list, and wraps every OTHER + // failure as a real error. A generic wrapper here would erase that distinction. + await expect(providerSettingsManager.getProfile({ name: "nonexistent" })).rejects.toThrow( + ProviderSettingsNotFoundError, + ) + }) + + it("rejects a missing id lookup with the typed error naming that id", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { config: {}, id: "default" }, + named: { config: {}, id: "known-id" }, + }, + }), + ) + + // The id branch is the one the mode mapping and deleteProviderProfile hit: a stale + // mode -> id mapping must surface as a not-found the caller can prune, not as a + // generic failure that gets re-wrapped as unexpected. + await expect(providerSettingsManager.getProfile({ id: "missing-id" })).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) + await expect(providerSettingsManager.getProfile({ id: "missing-id" })).rejects.toThrow( + "Config with ID 'missing-id' not found", + ) + }) + it("resolves a known id lookup to the profile that owns it", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { config: {}, id: "default" }, + named: { + apiProvider: providerIdentifiers.anthropic, + id: "known-id", + }, + }, + }), + ) + + const profile = await providerSettingsManager.getProfile({ id: "known-id" }) + + // The two reject cases above would still pass if getProfile refused every id. This is + // the accept path through the same public entry point, and it pins exactly what the + // mode mapping and deleteProviderProfile consume: the id, the name the id was found + // under, and the settings themselves. + // A deliberately non-empty config: an empty one is normalized away by load(), so + // asserting on it would not prove the settings themselves come back. + // Settings, not just identity: an empty config is normalized away by load(), so the + // setting is carried in the fixture the way the sibling cases do. + expect(profile).toMatchObject({ + id: "known-id", + name: "named", + apiProvider: providerIdentifiers.anthropic, + }) + }) + + it("should not surface a storage read failure as ProviderSettingsNotFoundError", async () => { + // getProfile() is the entry point deleteProviderProfile() and the mode mapping use to treat an + // already-deleted profile as an idempotent no-op, so an outage must not arrive as not-found: + // that would read as "the profile is gone" when the record could not be read at all. + mockSecrets.get.mockRejectedValue(new Error("Storage failed")) + + const error = await providerSettingsManager + .getProfile({ name: "nonexistent" }) + .catch((caught: unknown) => caught) + + expect(error).not.toBeInstanceOf(ProviderSettingsNotFoundError) + expect(error).toMatchObject({ message: expect.stringContaining("Failed to get profile:") }) + }) + it("should throw error if secrets storage fails", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 6c66655325..c83a851135 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" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 9882039ad3..29c9497552 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -98,6 +98,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) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 87c37cf596..cc15a54016 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -26,6 +26,7 @@ import { } from "./webviewRecoveryPolicy" import { + PROVIDER_SETTINGS_KEYS, type TaskProviderLike, type TaskProviderEvents, type GlobalState, @@ -49,6 +50,7 @@ import { type CloudUserInfo, type CloudOrganizationMembership, type CreateTaskOptions, + type ProviderSettingsWithId, type TokenUsage, type ToolUsage, type ExtensionMessage, @@ -84,6 +86,10 @@ import { type TaskExecutionContext, } from "../task/providerHandoff" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" +import { ProfileDeletionActivationError } from "./profileDeletionError" + +// Re-exported so callers catching profile-deletion outcomes can import the provider and the error from one place. +export { ProfileDeletionActivationError } from "./profileDeletionError" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" @@ -133,7 +139,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 +166,21 @@ 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 + +/** + * The keys the view-local mutation helper actually applies. A wider key would update + * neither the in-memory buffer nor the persisted entry, so it must not be accepted by + * the public save path. + */ +type PerViewSavedStateKey = "mode" | "currentApiConfigName" | "apiConfiguration" + /** * 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 @@ -230,6 +251,63 @@ type GetStateOptions = { includeTaskHistory?: boolean } +/** + * In-flight profile-create claims shared by every provider instance in this + * extension host. A rollback may only delete a record whose creation it can + * prove it owns: when two views concurrently create the same new name, both + * observe `hasConfig === false`, yet the surviving record belongs to the + * create that completed. A failed create must therefore skip the deletion + * while another create for the same name is still in flight, and the last + * claimant to settle performs it. + */ +const profileCreateClaims = new Map() + +function claimProfileCreate(name: string): void { + profileCreateClaims.set(name, (profileCreateClaims.get(name) ?? 0) + 1) +} + +/** + * Names whose create claim has already landed: a failed create must not + * delete the record a successful sibling create saved, even when it settles + * last. + */ +const profileCreatesSucceeded = new Set() + +/** Records that a create for this name landed, so failed siblings spare the record. */ +function markProfileCreateSucceeded(name: string): void { + profileCreatesSucceeded.add(name) +} + +/** Drops one claim and reports whether the caller was the last claimant. */ +function releaseProfileCreate(name: string): boolean { + const remaining = (profileCreateClaims.get(name) ?? 1) - 1 + if (remaining <= 0) { + profileCreateClaims.delete(name) + return true + } + profileCreateClaims.set(name, remaining) + return false +} + +type ProfileActivationSnapshot = { + listApiConfigMeta: ProviderSettingsEntry[] | undefined + currentApiConfigName: string | undefined + viewCurrentApiConfigName: string | undefined + providerSettings: RooCodeSettings + viewApiConfiguration: ProviderSettings | undefined + viewStates: Record +} + +type ProviderManagerSnapshot = { + previousRecord: ProviderSettingsWithId | undefined + recordExisted: boolean | undefined + // True only when a read actually failed, so a caller can tell "absent" from + // "could not tell" without re-reading the store. + recordUnreadable: boolean + previousCurrentProfileName: string | undefined + claimedCreate: boolean +} + function removeTaskEventListeners(listeners: WeakMap void>>, task: Task): void { const cleanupFunctions = listeners.get(task) if (cleanupFunctions) { @@ -238,6 +316,18 @@ function removeTaskEventListeners(listeners: WeakMap void>>, t } } +/** + * Raised when a profile activation or upsert failed after durable writes landed and at least one + * compensating write failed too. The persisted profile stores may now disagree, which is not the + * ordinary "the save failed and nothing changed" outcome the caller's `undefined` would suggest. + */ +class ProfileActivationInconsistentError extends Error { + constructor(message: string) { + super(message) + this.name = "ProfileActivationInconsistentError" + } +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -248,6 +338,22 @@ 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() + + /** + * View ids whose durable entry a queued write intentionally removed + * (unpin, clear, or re-key move). Activation rollback treats these as + * tombstones and never resurrects a tracked eviction the clear has + * since deleted; a write that lands an entry lifts its tombstone. + */ + private static clearedViewStateIds = new Set() + + // Incremented when a reset clears the durable map; a queued write that started before the + // reset must not persist its entry after the clear. + private static viewStatesResetGeneration = 0 private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< string, @@ -337,51 +443,49 @@ export class ClineProvider } /** - * With `runToCompletion`, a timeout only rejects the caller: the mutation is not aborted and the - * queue advances once it settles. Use it for multi-write mutations that must not stop halfway - * (storage saved but not activated) or overlap with the next mutation. + * Serialize provider-profile mutations. A timeout only rejects the caller: the mutation is not + * aborted and keeps the queue until it settles, so multi-write mutations cannot stop halfway + * (storage saved but not activated) or overlap with the next mutation. The cancellation checks + * the callback used to receive are gone with the abort: none of these mutations is abortable. */ - private enqueueProviderProfileMutation( - fn: (signal: AbortSignal) => Promise, - options: { runToCompletion?: boolean } = {}, - ): Promise { - const controller = new AbortController() + private enqueueProviderProfileMutation(fn: () => Promise): Promise { + // Set when the caller's timeout fires while this mutation is still queued behind a + // longer-running one: the callback must not start after its caller already saw a + // timeout, or the mutation would change state while reporting failure. + let callerTimedOut = false // Run fn after either outcome so a rejected mutation never poisons the queue. - const run = this.providerProfileMutationQueue.then( - () => fn(controller.signal), - () => fn(controller.signal), - ) - const callerResult = this.withProviderProfileMutationTimeout(run, () => { - if (options.runToCompletion) { - this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") - return + const start = () => { + if (callerTimedOut) { + return Promise.reject(new Error("Provider profile mutation timed out while queued")) } - - controller.abort() - this.log("Provider profile mutation timed out; aborting in-flight mutation") + return fn() + } + const run = this.providerProfileMutationQueue.then(start, start) + const callerResult = this.withProviderProfileMutationTimeout(run, () => { + callerTimedOut = true + this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") }) - void run.then( + // Advance from the unbounded run, not the timeout-bounded caller result: a mutation that + // outlives its caller's timeout still owns the queue until every write settles. + this.providerProfileMutationQueue = run.then( () => { - if (controller.signal.aborted) { - this.log("Provider profile mutation completed after cancellation") - } - }, - (error) => { - if (controller.signal.aborted) { - this.log( - `Provider profile mutation errored after cancellation: ${ - error instanceof Error ? error.message : String(error) - }`, - ) + if (callerTimedOut) { + // The caller already saw a failure, but the mutation went through: these writes are + // live now, so the views must be told. Without this the webview keeps rendering the + // pre-mutation profile list under an error toast even though the profile was saved, + // activated and picked up by every task handler. + this.log("Provider profile mutation committed after its caller timed out; re-posting state") + void this.postStateToWebview().catch((error) => { + this.log( + `[enqueueProviderProfileMutation] re-post after a late commit failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + }) } + return undefined }, - ) - - // Advance from the timeout-bounded result. Each fn checks its AbortSignal before - // writing state, so advancing the queue on timeout cannot produce stale overwrites. - this.providerProfileMutationQueue = (options.runToCompletion ? run : callerResult).then( - () => undefined, () => undefined, ) return callerResult @@ -415,7 +519,64 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Whether this instance wrote an entry under its own session-local temporary id. + * Temporary ids restart from 0 on every extension host start, so an entry under the + * same name can belong to a different session. Only re-key entries this instance wrote. + */ + private wroteUnderTemporaryViewStateId = false + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + + /** + * View ids evicted from the durable viewStates map by this instance's writes + * since the last activation snapshot: the rollback restores exactly these + * entries, so a sibling's intentional pin clear is never resurrected. + */ + private prunedViewIdsDuringActivation: string[] = [] + + /** + * Bumped whenever the buffer is cleared (reset/import), so an in-flight load can tell + * that its captured state was invalidated while it awaited the profile lookup. + */ + private viewLocalStateGeneration = 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 + } + public isViewLaunched = false + + // Callers that must actually deliver a webview message wait on this gate. The + // SidebarProvider.focus command settles when VS Code opens the pane - well before + // resolveWebviewView hands this provider its view - and postMessageToWebview returns + // without sending while `view` is unset, so a message posted straight after that command + // can be dropped. The gate settles when the view resolves and on every path that removes + // it, so a caller can never hang on a view that is never coming. + private viewResolvedGate?: Promise + private viewResolvedGateRelease?: () => void public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.94.0-models-oauth-chat" // v3.94.0 model support, MCP OAuth, and chat reliability public readonly providerSettingsManager: ProviderSettingsManager @@ -430,6 +591,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 +629,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) => { @@ -600,142 +769,656 @@ export class ClineProvider } /** - * Override EventEmitter's on method to match TaskProviderLike interface + * 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. */ - override on( - event: K, - listener: (...args: TaskProviderEvents[K]) => void | Promise, - ): this { - return super.on(event, listener as any) + 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. 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. + return Object.assign(Object.create(null), viewStates) } /** - * Override EventEmitter's off method to match TaskProviderLike interface + * 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. */ - override off( - event: K, - listener: (...args: TaskProviderEvents[K]) => void | Promise, - ): this { - return super.off(event, listener as any) + private async savePersistedViewState(values: Partial): Promise { + const resetGeneration = ClineProvider.viewStatesResetGeneration + // 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 () => { + // A reset that ran while this write was queued cleared the durable map: persisting + // the pre-reset pin now would resurrect it on the next reload. + if (ClineProvider.viewStatesResetGeneration !== resetGeneration) { + return + } + + 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] + ClineProvider.clearedViewStateIds.add(viewStateId) + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + ClineProvider.clearedViewStateIds.delete(viewStateId) + wroteEntry = true + } + + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + + try { + await this.contextProxy.setValue("viewStates", pruned) + } 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 } /** - * Handle cloud settings updates + * 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 handleCloudSettingsUpdate = async () => { - this.log("Ignoring cloud settings update because cloud profile synchronization is disabled") + 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) + // The tombstone is recorded only after the durable delete lands: a rejected write + // leaves the entry in storage, and tombstoning an id that is still persisted would + // let a later activation rollback skip a pin that pruning had evicted. + ClineProvider.clearedViewStateIds.add(viewStateId) + } 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 } /** - * Initialize cloud profile synchronization when CloudService is ready - * This method is called externally after CloudService has been initialized + * 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. */ - public async initializeCloudProfileSyncWhenReady(): Promise { - this.log("Cloud profile synchronization is disabled in compatibility mode") - } + private async repointPersistedViewStates( + removedProfileName: string, + replacementProfileName: string, + ): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + let changed = false - // Adds a new Task instance to the registry, marking the start of a new task. - // The instance is pushed to the top of the stack (LIFO order). - // When the task is completed, the top instance is removed, reactivating the - // previous task. - async addClineToStack(task: Task) { - // Add this cline instance into the stack that represents the order of - // all the called tasks. - this.taskRegistry.push(task) - task.emit(RooCodeEventName.TaskFocused) + for (const [viewId, entry] of Object.entries(states)) { + if (entry?.currentApiConfigName !== removedProfileName) { + continue + } - // Perform special setup provider specific tasks. - await this.performPreparationTasks(task) + changed = true + const { currentApiConfigName: _removed, ...rest } = entry - // Ensure getState() resolves correctly. - const state = await this.getState() + if (rest.mode) { + states[viewId] = { ...rest, currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } else { + states[viewId] = { currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } + } - if (!state || typeof state.mode !== "string") { - throw new Error(t("common:errors.retrieve_current_mode")) - } - } + if (changed) { + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } - async performPreparationTasks(cline: Task) { - // LMStudio: We need to force model loading in order to read its context - // size; we do it now since we're starting a task with that model selected. - if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === providerIdentifiers.lmstudio) { - try { - if (!hasLoadedFullDetails(cline.apiConfiguration.lmStudioModelId!)) { - await forceFullModelDetailsLoad( - cline.apiConfiguration.lmStudioBaseUrl ?? "http://localhost:1234", - cline.apiConfiguration.lmStudioModelId!, - buildApiHandler({ ...cline.apiConfiguration, apiProvider: providerIdentifiers.lmstudio }), - ) + try { + await this.contextProxy.setValue("viewStates", pruned) + } 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 } - } catch (error) { - this.log(`Failed to load full model details for LM Studio: ${error}`) - vscode.window.showErrorMessage(error.message) } - } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write } - // Removes and destroys the top Cline instance (the current finished task), - // activating the previous one (resuming the parent task). - async removeClineFromStack() { - if (this.taskRegistry.length === 0) { - return - } + /** + * 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 { + // Entries of live views must never age out: a view that keeps its mode and profile does + // not refresh updatedAt. The cap applies to closed-view entries only. + const liveViewStateIds = new Set( + Array.from(ClineProvider.activeInstances, (instance) => instance.viewStateId).filter( + (viewStateId): viewStateId is string => Boolean(viewStateId), + ), + ) + const liveEntries = Object.entries(states).filter(([viewStateId]) => liveViewStateIds.has(viewStateId)) + const closedEntries = Object.entries(states) + .filter(([viewStateId]) => !liveViewStateIds.has(viewStateId)) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES) - // Remove the focused Cline instance from the stack. - let task = this.taskRegistry.current - if (task) { - task = this.taskRegistry.remove(task.taskId) - } + return Object.fromEntries([...liveEntries, ...closedEntries]) + } - if (task) { - task.emit(RooCodeEventName.TaskUnfocused) + /** + * 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. An entry that is already under the + * temporary id without this provider having written it is an orphan from an earlier + * session and is cleared rather than re-keyed. 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 + } - try { - // Abort the running task and set isAbandoned to true so - // all running promises will exit as well. - await task.abortTask(true) - } catch (e) { - this.log( - `[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`, - ) + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return } - // Remove event listeners before clearing the reference. - const cleanupFunctions = this.taskEventListeners.get(task) + delete states[previousViewStateId] + ClineProvider.clearedViewStateIds.add(previousViewStateId) - if (cleanupFunctions) { - cleanupFunctions.forEach((cleanup) => cleanup()) - this.taskEventListeners.delete(task) + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + ClineProvider.clearedViewStateIds.delete(nextViewStateId) } - // Make sure no reference kept, once promises end it will be - // garbage collected. - task = undefined - } + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + + try { + await this.contextProxy.setValue("viewStates", pruned) + } 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 } - private async cleanupFailedHistoryTask(task: Task, error: unknown): Promise { - if (!(error instanceof PendingActionSettlementError)) { + /** + * 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}.`) return } - if (this.taskRegistry.getById(task.taskId) !== task) { + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject every Object.prototype member name ("__proto__", "constructor", "toString", ...): + // states[normalizedViewStateId] would resolve to an inherited value instead of a + // persisted entry, so the stable entry would never be written while the pre-launch + // entry is deleted - losing the selection. + Object.prototype.hasOwnProperty.call(Object.prototype, normalizedViewStateId) + ) { return } - this.taskRegistry.remove(task.taskId) - task.emit(RooCodeEventName.TaskUnfocused) - - removeTaskEventListeners(this.taskEventListeners, task) + // 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}.`, + ) + return + } - try { - await task.dispose() - } catch (error) { + 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( - `[cleanupFailedHistoryTask] dispose() failed for ${task.taskId}.${task.instanceId}: ${error instanceof Error ? error.message : String(error)}`, + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, ) + return } - } + + const previousViewStateId = this.viewStateId + + this.viewStateId = normalizedViewStateId + + try { + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) + + await this.loadViewState() + } catch (error) { + // A persistence failure must not leave the provider holding an id that was + // never registered: restore the previous id so a later launch retries the + // registration and the load instead of the guard above early-returning for + // the failed id. + this.viewStateId = previousViewStateId + throw error + } + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + * Fields mutated while the async profile lookup is in flight are reapplied on top of the + * loaded state, field by field, so in-flight user selections are not clobbered by the load. + */ + private async loadViewState(): Promise { + // Reset/import clears the buffer without changing the view id; a load that completes + // after such a clear must not republish the state it captured before the reset. + const loadedGeneration = this.viewLocalStateGeneration + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + + // While the stable id has not been registered yet, the key is this instance's + // session-local name. Temporary names restart from 0 on every host start, so an + // entry under the same name can belong to a different session. Load only entries + // this instance authored. + if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { + return + } + try { + const persisted: PersistedViewState | undefined = 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 } + + if (persisted?.mode) { + // A persisted mode may reference a custom mode that was deleted after it was + // pinned: restore it only when the slug still resolves, so a stale slug cannot + // shadow the shared mode from getState(). + const customModes = await this.customModesManager.getCustomModes() + if (getModeBySlug(persisted.mode, customModes)) { + loadedState.mode = persisted.mode as Mode + } else { + this.log(`[loadViewState] Ignoring unknown persisted mode "${persisted.mode}"`) + } + } + + if (persisted?.currentApiConfigName) { + loadedState.currentApiConfigName = persisted.currentApiConfigName + + try { + const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ + name: persisted.currentApiConfigName, + }) + loadedState.apiConfiguration = apiConfiguration as ProviderSettings + } catch (error) { + this.log( + `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (this.viewStateId !== loadedForViewId) { + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return + } + + // Reapply 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 } + + // The lookup resolved the profile that was pinned when the load started. If the view + // pinned a different profile during the lookup window, those settings belong to a + // profile this view no longer names, and keeping them would report the new name with + // the old profile's configuration. Dropping the overlay falls back to the shared + // store, which is the same outcome as any other unresolved view-local profile. + if ( + persisted?.currentApiConfigName && + !Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName) && + !Object.is(persisted.currentApiConfigName, postLoadBuffer.currentApiConfigName) + ) { + delete mergedState.apiConfiguration + } + + 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 reset/import may have cleared the buffer while the profile lookup was in flight: + // republishing here would restore the pre-reset profile over the cleared state. + if (this.viewLocalStateGeneration !== loadedGeneration) { + this.log(`[loadViewState] Discarding state loaded before a reset for viewId ${this.viewId}`) + return + } + + this.viewLocalState = mergedState + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Saves a single view-local state value. The in-memory buffer is always updated; the + * non-secret subset (mode, currentApiConfigName) is persisted durably under the view + * id active when the change was made, re-keyed to the stable id on registration. + * + * Only the three per-view fields are accepted: the mutation helper applies exactly + * those, so any other key would leave both the buffer and the persisted store + * untouched while the method still reported a successful save. + */ + public async saveViewState( + key: K, + value: ViewLocalStateValues[K] | undefined, + ): Promise { + await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + + /** + * Override EventEmitter's on method to match TaskProviderLike interface + */ + override on( + event: K, + listener: (...args: TaskProviderEvents[K]) => void | Promise, + ): this { + return super.on(event, listener as any) + } + + /** + * Override EventEmitter's off method to match TaskProviderLike interface + */ + override off( + event: K, + listener: (...args: TaskProviderEvents[K]) => void | Promise, + ): this { + return super.off(event, listener as any) + } + + /** + * Handle cloud settings updates + */ + private handleCloudSettingsUpdate = async () => { + this.log("Ignoring cloud settings update because cloud profile synchronization is disabled") + } + + /** + * Initialize cloud profile synchronization when CloudService is ready + * This method is called externally after CloudService has been initialized + */ + public async initializeCloudProfileSyncWhenReady(): Promise { + this.log("Cloud profile synchronization is disabled in compatibility mode") + } + + // Adds a new Task instance to the registry, marking the start of a new task. + // The instance is pushed to the top of the stack (LIFO order). + // When the task is completed, the top instance is removed, reactivating the + // previous task. + async addClineToStack(task: Task) { + // Add this cline instance into the stack that represents the order of + // all the called tasks. + this.taskRegistry.push(task) + task.emit(RooCodeEventName.TaskFocused) + + // Perform special setup provider specific tasks. + await this.performPreparationTasks(task) + + // Ensure getState() resolves correctly. + const state = await this.getState() + + if (!state || typeof state.mode !== "string") { + throw new Error(t("common:errors.retrieve_current_mode")) + } + } + + async performPreparationTasks(cline: Task) { + // LMStudio: We need to force model loading in order to read its context + // size; we do it now since we're starting a task with that model selected. + if (cline.apiConfiguration && cline.apiConfiguration.apiProvider === providerIdentifiers.lmstudio) { + try { + if (!hasLoadedFullDetails(cline.apiConfiguration.lmStudioModelId!)) { + await forceFullModelDetailsLoad( + cline.apiConfiguration.lmStudioBaseUrl ?? "http://localhost:1234", + cline.apiConfiguration.lmStudioModelId!, + buildApiHandler({ ...cline.apiConfiguration, apiProvider: providerIdentifiers.lmstudio }), + ) + } + } catch (error) { + this.log(`Failed to load full model details for LM Studio: ${error}`) + vscode.window.showErrorMessage(error.message) + } + } + } + + // Removes and destroys the top Cline instance (the current finished task), + // activating the previous one (resuming the parent task). + async removeClineFromStack() { + if (this.taskRegistry.length === 0) { + return + } + + // Remove the focused Cline instance from the stack. + let task = this.taskRegistry.current + if (task) { + task = this.taskRegistry.remove(task.taskId) + } + + if (task) { + task.emit(RooCodeEventName.TaskUnfocused) + + try { + // Abort the running task and set isAbandoned to true so + // all running promises will exit as well. + await task.abortTask(true) + } catch (e) { + this.log( + `[ClineProvider#removeClineFromStack] abortTask() failed ${task.taskId}.${task.instanceId}: ${e.message}`, + ) + } + + // Remove event listeners before clearing the reference. + const cleanupFunctions = this.taskEventListeners.get(task) + + if (cleanupFunctions) { + cleanupFunctions.forEach((cleanup) => cleanup()) + this.taskEventListeners.delete(task) + } + + // Make sure no reference kept, once promises end it will be + // garbage collected. + task = undefined + } + } + + private async cleanupFailedHistoryTask(task: Task, error: unknown): Promise { + if (!(error instanceof PendingActionSettlementError)) { + return + } + + if (this.taskRegistry.getById(task.taskId) !== task) { + return + } + + this.taskRegistry.remove(task.taskId) + task.emit(RooCodeEventName.TaskUnfocused) + + removeTaskEventListeners(this.taskEventListeners, task) + + try { + await task.dispose() + } catch (error) { + this.log( + `[cleanupFailedHistoryTask] dispose() failed for ${task.taskId}.${task.instanceId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } /** * Evicts the current task from the stack and, if it was an active delegated child, @@ -951,6 +1634,13 @@ export class ClineProvider } this._disposed = true + // Unregister at the START of disposal, synchronously and before the first await below. + // The teardown awaits task eviction and several managers, and while it runs a provider that + // is already _disposed but still in activeInstances is enumerated by getAllInstances(), + // getVisibleInstance() and getInstanceForView() - so a sibling profile mutation could persist + // durable view state and post to a view that is on its way out. Unregistering here makes that + // window zero-length; no teardown step below looks this instance up in the registry. + ClineProvider.activeInstances.delete(this) this._postStateToWebviewThrottled.cancel() this.stopWebviewWatchdog() this.diagnosticsRecorder.record({ boundary: "provider", phase: "start", type: "dispose" }) @@ -986,23 +1676,39 @@ export class ClineProvider } this.clearWebviewResources() - - // Clean up cloud service event listener - if (CloudService.hasInstance()) { - CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) + this.settleViewResolvedGate() + + // Each teardown step gets its own guard: a rejecting step must not strand the steps after + // it. The registry unregistration already happened at the top of dispose(), so a step + // that throws can no longer leave this provider enumerable; the remaining steps still run, + // because _disposed is set and this provider never gets another chance to finish here. + const cleanupFailures: string[] = [] + const attemptCleanup = async (label: string, step: () => unknown) => { + try { + await step() + } catch (error: unknown) { + cleanupFailures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`) + } } - this._workspaceTracker?.dispose() - this._workspaceTracker = undefined - await this.mcpHub?.unregisterClient() + await attemptCleanup("workspace tracker", () => { + this._workspaceTracker?.dispose() + this._workspaceTracker = undefined + }) + await attemptCleanup("mcpHub", () => this.mcpHub?.unregisterClient()) this.mcpHub = undefined - await this.skillsManager?.dispose() + await attemptCleanup("skills manager", () => 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) + await attemptCleanup("marketplace manager", () => this.marketplaceManager?.cleanup()) + await attemptCleanup("custom modes manager", () => this.customModesManager?.dispose()) + await attemptCleanup("task history store", () => this.taskHistoryStore.dispose()) + + if (cleanupFailures.length > 0) { + this.log(`Disposal was incomplete (${cleanupFailures.join("; ")}); the provider was unregistered anyway.`) + } else { + this.log("Disposed all disposables") + } + this.diagnosticsRequestBroker.dispose() // Clean up any event listeners attached to this provider @@ -1023,6 +1729,16 @@ export class ClineProvider return Array.from(this.activeInstances) } + /** + * Returns the live instance whose current view is the given view or panel, + * if any. Title-bar commands on a specific surface use this to target the + * instance that owns that surface rather than the visible-instance + * heuristic (which picks whichever surface the user last focused). + */ + public static getInstanceForView(view: vscode.WebviewView | vscode.WebviewPanel): ClineProvider | undefined { + return Array.from(this.activeInstances).find((instance) => instance.view === view) + } + public static async getInstance(): Promise { let visibleProvider = ClineProvider.getVisibleInstance() @@ -1162,6 +1878,7 @@ export class ClineProvider const epoch = this.webviewRecoveryEpoch this.pendingSidebarRecovery = false this.view = webviewView + this.settleViewResolvedGate() const inTabMode = "onDidChangeViewState" in webviewView this.diagnosticsRecorder.record({ boundary: "provider", @@ -1185,8 +1902,10 @@ export class ClineProvider await this.dispose() } else { this.log("Clearing webview resources for sidebar view") + this.view = undefined this.clearWebviewResources() + this.settleViewResolvedGate() } }, null, @@ -1485,7 +2204,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, @@ -1699,6 +2421,37 @@ export class ClineProvider return task } + /** + * Settles once this provider can actually post into a webview: either the view is resolved + * (or was already) or the view and provider are gone, which is the only other way the wait + * can end without hanging the caller. Callers that must deliver a message, rather than merely + * try, await this before posting. + */ + async whenViewResolved(): Promise { + if (this.view || this._disposed) { + return + } + + if (!this.viewResolvedGate) { + this.viewResolvedGate = new Promise((resolve) => { + this.viewResolvedGateRelease = resolve + }) + } + + return this.viewResolvedGate + } + + /** + * Releases every pending whenViewResolved waiter and drops the gate so the next wait starts + * fresh: a view that resolves again after a disposal must still be gated. + */ + private settleViewResolvedGate(): void { + const release = this.viewResolvedGateRelease + this.viewResolvedGate = undefined + this.viewResolvedGateRelease = undefined + release?.() + } + public async postMessageToWebview(message: ExtensionMessage) { const startedAt = Date.now() const taskId = this.getCurrentTask()?.taskId @@ -1722,28 +2475,25 @@ 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, - }) - } catch { - this.diagnosticsRecorder.record({ - boundary: "webview-out", - phase: "failure", - type: message.type, - action: message.action, - elapsedMs: Date.now() - startedAt, - taskId, - }) - // View disposed, drop message silently + const webview = this.view?.webview + if (!webview) { + return } + + // Dispatch without awaiting the renderer ack: VS Code settles postMessage only when the + // webview page acknowledges the message, and a page reload or view dispose in flight + // orphans that promise forever. Awaiting it could wedge every caller on the task critical + // path (e.g. the trailing postStateToWebview in handleModeSwitchUnlocked gates the next + // turn after a mode switch). Message ordering is enforced by the message seq, not the ack. + // Promise.resolve() normalizes non-promise returns (e.g. test doubles) before the catch. + void Promise.resolve(webview.postMessage(omitOriginalContentFromExtensionMessage(message))).catch((error) => { + // Swallow: postMessage rejects when the webview is disposed in flight. + // Log the dropped message type so a wedged webview channel is diagnosable + // instead of silently losing state updates. + this.log( + `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -2044,21 +2794,20 @@ export class ClineProvider * current task. Pass null to apply only global mode/profile effects for a pending child. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { - return this.enqueueProviderProfileMutation((signal) => - this.handleModeSwitchUnlocked(newMode, targetTask, signal), - ) + // The switch and its nested activation own the queue until every write - including + // compensation - settles: a timeout only rejects the caller. + return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask)) } - private async handleModeSwitchUnlocked( - newMode: Mode, - targetTask: Task | null | undefined, - signal?: AbortSignal, - ): Promise { + private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise { const task = targetTask + // Captured before any write so the persistence failure below can undo the task-side + // commits instead of leaving them pointing at the rejected mode. + const previousTaskMode = task?.taskMode + const previousHistoryMode = task ? this.getTaskHistoryItem(task.taskId)?.mode : undefined if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) - task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) try { // Update the task history with the new mode first. @@ -2068,6 +2817,8 @@ export class ClineProvider await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) } + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode } catch (error) { @@ -2082,8 +2833,54 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) - + // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState + // buffer stays in sync with the durable global write: getValues() merges + // viewLocalState on top of the ContextProxy values, so an unsynced stale + // restored mode would otherwise shadow the fresh switch for consumers. + // If the durable write fails, roll the shared write back so getValues() + // cannot mix a fresh shared mode with the stale pre-switch buffer. + const previousMode = this.getValue("mode") + try { + await this.setValue("mode", newMode) + } catch (error) { + // The task-history write and the task's own mode committed before this write + // rejected. Roll them back too, or the view reverts to the previous mode while the + // task and its history item keep the rejected one and the webview shows a failed + // switch on top of a task that still runs the new mode. + if (task) { + const historyItem = this.getTaskHistoryItem(task.taskId) + if (historyItem && previousHistoryMode !== historyItem.mode) { + try { + await this.updateTaskHistory({ ...historyItem, mode: previousHistoryMode }) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to restore task history mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + if (previousTaskMode !== undefined && task.taskMode !== previousTaskMode) { + // Task has no public mode setter (see the commit above); bracket access keeps + // the rollback off the suppression list. + task["_taskMode"] = previousTaskMode + } + } + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + this.emit(RooCodeEventName.ModeChanged, newMode) // If workspace lock is on, keep the current API config — don't load mode-specific config @@ -2095,14 +2892,10 @@ export class ClineProvider return } - if (signal?.aborted) return - // 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 (signal?.aborted) return - // Update listApiConfigMeta first to ensure UI has latest data. await this.updateGlobalState("listApiConfigMeta", listApiConfig) @@ -2124,7 +2917,6 @@ export class ClineProvider await this.activateProviderProfileUnlocked( { name: profile.name }, targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, - signal, ) } else { // The task will continue with the current/default configuration. @@ -2203,15 +2995,277 @@ export class ClineProvider return !!this.getProviderProfileEntry(name) } + /** + * Prior state that the profile activation writes overwrite, captured before the + * concurrent write group so a failure can compensate the writes that landed. + */ + private _snapshotProfileActivationState(): ProfileActivationSnapshot { + // The rollback may only restore entries this activation's own writes evict. + this.prunedViewIdsDuringActivation = [] + // Capture only the provider-settings keys. getValues() returns the whole + // global state, and restoring that copy would write unrelated keys + // (viewStates, mode, taskHistory, ...) back over changes that legitimately + // landed while the activation group was in flight. + const allValues = this.contextProxy.getValues() + const providerSettings = Object.fromEntries( + PROVIDER_SETTINGS_KEYS.map((key) => [key, allValues[key]]), + ) as RooCodeSettings + return { + listApiConfigMeta: this.contextProxy.getValue("listApiConfigMeta"), + currentApiConfigName: this.contextProxy.getValue("currentApiConfigName"), + // This view's own pin: the rollback must restore it as-is instead of + // overwriting it with the shared selection. + viewCurrentApiConfigName: this.viewLocalState?.currentApiConfigName, + providerSettings, + viewApiConfiguration: this.viewLocalState?.apiConfiguration, + // The activation's pin write merges into and prunes the durable map: the + // rollback needs the exact pre-activation map to bring back any sibling the + // pruning evicted, entry metadata included. + viewStates: this.getPersistedViewStates({ fresh: true }), + } + } + + /** + * Compensate the profile activation writes after one of them failed. Every + * restore is awaited independently and logged: one failed restore must not + * strand the others. The caller rethrows the original failure. + */ + private async _restoreProfileActivationState(snapshot: ProfileActivationSnapshot): Promise { + try { + await this.updateGlobalState("listApiConfigMeta", snapshot.listApiConfigMeta) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the profile list: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + // Restore the shared selection through the ContextProxy directly: + // this.setValue would also write the shared value into this view's pin + // and its durable viewStates entry, replacing the view's own selection + // with another view's choice. + await this.contextProxy.setValue("currentApiConfigName", snapshot.currentApiConfigName) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the current profile selection: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + await this.contextProxy.setProviderSettings(snapshot.providerSettings) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the shared provider settings: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + // Restore this view's own pin and overlay together: an absent pin stays + // absent (undefined deletes the entry) instead of materializing a pin + // from the shared value. + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: snapshot.viewCurrentApiConfigName, + apiConfiguration: snapshot.viewApiConfiguration, + }) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the view-local overlay: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + // Restore the siblings the activation's pruning evicted, last, so it + // lands after the overlay restore's own pin write, with their original + // entry metadata. Only tracked evictions are restored: an entry a + // sibling deleted on purpose after the snapshot is newer than the + // snapshot too, and resurrecting it would undo the sibling's clear on + // reload. The mutation queue is per-instance, so sibling views keep + // writing to the durable map while an activation group is in flight; + // merge, never replace, to leave those newer writes alone. + const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const current = this.getPersistedViewStates({ fresh: true }) + const merged = { ...current } + for (const viewId of this.prunedViewIdsDuringActivation) { + const entry = snapshot.viewStates[viewId] + if (entry && !Object.hasOwn(merged, viewId) && !ClineProvider.clearedViewStateIds.has(viewId)) { + merged[viewId] = entry + } + } + await this.contextProxy.setValue("viewStates", merged) + }) + ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) + await restore + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the durable viewStates map: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Prior provider-manager state that the profile mutation group overwrites: the + * manager's record for the profile being saved (or its confirmed absence) and + * the manager-owned current selection. The manager store is the source of truth + * that the profile list and the shared settings are derived from, so + * compensation has to restore it and not only its ContextProxy copies. + */ + private async _snapshotProviderManagerState(name: string): Promise { + let previousRecord: ProviderSettingsWithId | undefined + let recordExisted: boolean | undefined + let recordUnreadable = false + let claimedCreate = false + // Presence is tracked separately from the record: a store that cannot answer the + // presence question at all stays best-effort (nothing was proven to be there), while + // "present, but the record itself cannot be read" is the case a caller must refuse. + let presenceEstablished: boolean | undefined + try { + // hasConfig distinguishes a confirmed absence from a read failure; the + // generic wrapper getProfile throws for a missing record cannot, because + // the manager does not type its not-found rejections. + const exists = await this.providerSettingsManager.hasConfig(name) + presenceEstablished = typeof exists === "boolean" ? exists : undefined + recordExisted = presenceEstablished + if (presenceEstablished) { + const { name: _name, ...record } = await this.providerSettingsManager.getProfile({ name }) + previousRecord = record as ProviderSettingsWithId + } else { + // Confirmed absence: claim the create so a concurrent create of the + // same name by another view can make this rollback skip the deletion + // that would destroy the other view's successful save. + claimProfileCreate(name) + claimedCreate = true + } + } catch { + // The restore must not delete what it cannot see. When hasConfig already proved + // the record was there, only the record read failed, and that is the case the + // caller must refuse instead of overwriting. + recordExisted = undefined + recordUnreadable = presenceEstablished === true + } + let previousCurrentProfileName: string | undefined + try { + previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() + } catch { + // Best-effort: the selection restore is skipped when unreadable. + } + return { previousRecord, recordExisted, recordUnreadable, previousCurrentProfileName, claimedCreate } + } + + /** + * Selection-only variant of the manager snapshot, for paths that overwrite the + * manager selection without touching any profile record. + */ + private async _snapshotProviderManagerSelection(): Promise { + let previousCurrentProfileName: string | undefined + try { + previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() + } catch { + // Best-effort: the selection restore is skipped when unreadable. + } + return { + previousRecord: undefined, + recordExisted: undefined, + recordUnreadable: false, + claimedCreate: false, + previousCurrentProfileName, + } + } + + /** + * Restore the manager record for a mutation that failed after the record write + * landed. A record the mutation created is deleted; a deletion that refuses + * (last remaining configuration) is logged and leaves the record behind. + */ + private async _restoreProviderManagerRecord( + name: string, + snapshot: ProviderManagerSnapshot, + written?: ProviderSettings, + ): Promise { + try { + if (snapshot.recordExisted === true && snapshot.previousRecord) { + if (written && !(await this._managerRecordStillHolds(name, written))) { + // A save of the same profile from another view landed after this mutation's + // write: replaying the pre-mutation record would discard that newer save. + this.log( + `[ClineProvider] skipping the provider-manager record rollback for '${name}': the stored record was superseded`, + ) + return + } + await this.providerSettingsManager.saveConfig(name, snapshot.previousRecord) + } else if (snapshot.recordExisted === false) { + // Delete only when this mutation owns the last create claim for the + // name: another view may have created the same profile concurrently + // and succeeded, and the record belongs to that save. + const lastClaimant = snapshot.claimedCreate ? releaseProfileCreate(name) : true + if (lastClaimant) { + // A successful claimant owns the record: delete only when no claimant + // succeeded, and clear the bookkeeping either way. + const anySucceeded = profileCreatesSucceeded.delete(name) + if (!anySucceeded) { + await this.providerSettingsManager.deleteConfig(name) + } + } + } + } catch (error) { + this.log( + `[ClineProvider] profile-mutation rollback failed for the provider-manager record: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Whether the stored record still holds exactly the settings this mutation wrote. A store + * that cannot answer keeps the previous behaviour (restore), and a record that is now gone + * counts as superseded. Settings are compared through a canonical key/value serialisation so + * key order cannot fake a difference. + */ + private async _managerRecordStillHolds(name: string, written: ProviderSettings): Promise { + try { + const current = await this.providerSettingsManager.getProfile({ name }) + const { + name: _currentName, + id: _currentId, + ...currentSettings + } = current as ProviderSettings & { + name?: string + id?: string + } + const canonical = (settings: Record) => + JSON.stringify( + Object.keys(settings) + .sort() + .map((key) => [key, settings[key]]), + ) + return ( + canonical(currentSettings as Record) === canonical(written as Record) + ) + } catch (error) { + if (error instanceof ProviderSettingsNotFoundError) { + return false + } + return true + } + } + + /** + * Restore the manager-owned current selection captured by a prior snapshot. + */ + private async _restoreProviderManagerSelection(snapshot: ProviderManagerSnapshot): Promise { + try { + if (snapshot.previousCurrentProfileName) { + await this.providerSettingsManager.activateProfile({ name: snapshot.previousCurrentProfileName }) + } + } catch (error) { + this.log( + `[ClineProvider] profile-mutation rollback failed for the provider-manager selection: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + async upsertProviderProfile( name: string, providerSettings: ProviderSettings, activate: boolean = true, ): Promise { try { - return await this.enqueueProviderProfileMutation( - (signal) => this.persistProviderProfile(name, providerSettings, activate, signal), - { runToCompletion: true }, + return await this.enqueueProviderProfileMutation(() => + this.persistProviderProfile(name, providerSettings, activate), ) } catch (error) { this.log( @@ -2219,6 +3273,14 @@ export class ClineProvider ) vscode.window.showErrorMessage(t("common:errors.create_api_config")) + + if (error instanceof ProfileActivationInconsistentError) { + // The rollback itself was incomplete. Returning `undefined` here would read as "the + // save failed and nothing changed" while the persisted profile stores disagree, so + // the caller gets the inconsistency instead. + throw error + } + return undefined } } @@ -2232,84 +3294,75 @@ export class ClineProvider let partialState = false try { - await this.enqueueProviderProfileMutation( - async (signal) => { - const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name }) - - // Unset provider (e.g. the seeded default profile) resolves to anthropic, matching getState(). - const storedProvider = stored.apiProvider ?? providerIdentifiers.anthropic + await this.enqueueProviderProfileMutation(async () => { + const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name }) - if (storedProvider !== expectedProvider) { - throw new Error( - `Profile "${name}" provider changed from "${expectedProvider}" to "${storedProvider}"`, - ) - } + // Unset provider (e.g. the seeded default profile) resolves to anthropic, matching getState(). + const storedProvider = stored.apiProvider ?? providerIdentifiers.anthropic - const merged: Record = { ...stored, apiProvider: storedProvider } + if (storedProvider !== expectedProvider) { + throw new Error( + `Profile "${name}" provider changed from "${expectedProvider}" to "${storedProvider}"`, + ) + } - for (const [key, value] of Object.entries(patch)) { - if (PROFILE_MODEL_ID_PATCH_KEYS.has(key)) { - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Key "${key}" must be a non-empty string in a model patch`) - } + const merged: Record = { ...stored, apiProvider: storedProvider } - merged[key] = value - } else if (PROFILE_MODEL_RESET_ONLY_KEYS.has(key)) { - if (value !== null && value !== undefined) { - throw new Error(`Key "${key}" can only be cleared in a model patch`) - } + for (const [key, value] of Object.entries(patch)) { + if (PROFILE_MODEL_ID_PATCH_KEYS.has(key)) { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Key "${key}" must be a non-empty string in a model patch`) + } - delete merged[key] - } else { - throw new Error(`Key "${key}" is not allowed in a model patch`) + merged[key] = value + } else if (PROFILE_MODEL_RESET_ONLY_KEYS.has(key)) { + if (value !== null && value !== undefined) { + throw new Error(`Key "${key}" can only be cleared in a model patch`) } + + delete merged[key] + } else { + throw new Error(`Key "${key}" is not allowed in a model patch`) } + } - if (storedProvider === providerIdentifiers.bedrock) { - if (merged.apiModelId === BEDROCK_CUSTOM_ARN_MODEL_ID) { - // The allow-list only sees apiModelId, so a patch must not select an ARN it cannot supply. - if (!merged.awsCustomArn) { - throw new Error( - `Model "${BEDROCK_CUSTOM_ARN_MODEL_ID}" requires an existing custom ARN`, - ) - } - } else { - // A retained ARN would override the allow-listed model, so drop it with its context fields. - for (const key of PROFILE_MODEL_RESET_ONLY_KEYS) { - if (key.startsWith("aws")) delete merged[key] - } + if (storedProvider === providerIdentifiers.bedrock) { + if (merged.apiModelId === BEDROCK_CUSTOM_ARN_MODEL_ID) { + // The allow-list only sees apiModelId, so a patch must not select an ARN it cannot supply. + if (!merged.awsCustomArn) { + throw new Error(`Model "${BEDROCK_CUSTOM_ARN_MODEL_ID}" requires an existing custom ARN`) + } + } else { + // A retained ARN would override the allow-listed model, so drop it with its context fields. + for (const key of PROFILE_MODEL_RESET_ONLY_KEYS) { + if (key.startsWith("aws")) delete merged[key] } } + } - const { organizationAllowList } = await this.getState() - - if (!ProfileValidator.isProfileAllowed(merged as ProviderSettings, organizationAllowList)) { - throw new OrganizationAllowListViolationError( - t("common:errors.violated_organization_allowlist"), - ) - } + const { organizationAllowList } = await this.getState() - if (signal.aborted) return + if (!ProfileValidator.isProfileAllowed(merged as ProviderSettings, organizationAllowList)) { + throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist")) + } + try { + await this.persistProviderProfile(name, merged as ProviderSettings, true) + } catch (persistError) { + // Saving and activating are separate writes. Put the previous profile back so + // storage, context settings, and the task do not disagree about the model. try { - await this.persistProviderProfile(name, merged as ProviderSettings, true, signal) - } catch (persistError) { - // Saving and activating are separate writes. Put the previous profile back so - // storage, context settings, and the task do not disagree about the model. - try { - await this.persistProviderProfile(name, stored as ProviderSettings, true, signal) - } catch (restoreError) { - partialState = true - this.log( - `Error restoring profile after failed model update: ${JSON.stringify(restoreError, Object.getOwnPropertyNames(restoreError), 2)}`, - ) - } - - throw persistError + await this.persistProviderProfile(name, stored as ProviderSettings, true) + } catch (restoreError) { + partialState = true + this.log( + `Error restoring profile after failed model update: ${JSON.stringify(restoreError, Object.getOwnPropertyNames(restoreError), 2)}`, + ) } - }, - { runToCompletion: true }, - ) + + throw persistError + } + }) } catch (error) { this.log(`Error update profile model: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) @@ -2327,64 +3380,194 @@ export class ClineProvider name: string, providerSettings: ProviderSettings, activate: boolean, - signal: AbortSignal, ): Promise { // 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. - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) - - if (signal.aborted) return id - - 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. - // 3. Sets the current mode's provider profile. - // 4. Copies the provider settings to the context. - // - // Note: 1, 2, and 4 can be done in one `ContextProxy` call: - // 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 activationResults = await Promise.allSettled([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), - this.providerSettingsManager.setModeConfig(mode, id), - this.contextProxy.setProviderSettings(providerSettings), - ]) - const failedActivation = activationResults.find( - (result): result is PromiseRejectedResult => result.status === "rejected", + // The manager record written below belongs to the same mutation group as + // the activation writes: snapshot the prior manager record and the + // manager's own selection BEFORE saveConfig so a failure anywhere in the + // group compensates the manager store too, not just its ContextProxy + // copies. The shared/per-view snapshot is taken at the same point. + const managerSnapshot = await this._snapshotProviderManagerState(name) + if (managerSnapshot.recordUnreadable) { + // The pre-save read could not establish whether the record existed, so neither + // restore branch of the compensation can decide what a landed record means. Stop + // before saveConfig instead of writing a replacement that a later failure would + // leave stored under the profile's name. + throw new Error( + `Provider profile '${name}' could not be inspected before saving; the save was not attempted`, ) + } + const preWriteSnapshot = this._snapshotProfileActivationState() + let id: string + try { + id = await this.providerSettingsManager.saveConfig(name, providerSettings) + } catch (error) { + // saveConfig assigns the record before awaiting the store write, so a + // rejection may still have landed the record: route through the + // manager-record rollback (which deletes a landed record and spends the + // create claim) instead of escaping the group with the claim leaked. + await this._restoreProviderManagerRecord(name, managerSnapshot) + throw error + } - if (failedActivation) throw failedActivation.reason + // 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 manager record and fail the + // group so raw webview data cannot reach shared writes, sibling + // overlays, or task configuration. + await this._restoreProviderManagerRecord(name, managerSnapshot) + throw new Error( + `Provider profile '${name}' could not be read back after save: ${error instanceof Error ? error.message : String(error)}`, + ) + } - if (signal.aborted) return id + let activationMode: Mode | undefined + let priorModeConfigId: string | undefined + try { + if (activate) { + const { mode } = await this.getState() + activationMode = mode + + // These promises do the following: + // 1. Adds or updates the list of provider profiles. + // 2. Sets the current provider profile. + // 3. Sets the current mode's provider profile. + // 4. Copies the provider settings to the context. + // + // Note: 1, 2, and 4 can be done in one `ContextProxy` call: + // 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. + // Sibling views 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, { requireResolvablePins: true }) - // Change the provider for the current task. - // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + try { + priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) + } catch { + // Best-effort snapshot: when the prior mapping cannot be read the + // compensation restores the mapping as absent. + } + // `allSettled` so the compensation below never races a write still in flight. + const activationResults = await Promise.allSettled([ + this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + // 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(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", + ) - // 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()) - } + 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) - await this.postStateToWebview() - return id + // Change the provider for the current task. + // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). + 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()) + + // 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 }) + } + await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) + // Rebuild only after the sibling refresh landed, as the activating branch above does: + // a rebuilt task handler is not one of the durable stores the compensation can put back, + // so rebuilding before a write that can still fail leaves the running task sending + // requests with settings the rolled-back save never committed. + // 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.pinnedProfileName === name && this.getCurrentTask()?.taskApiConfigName === name) { + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) + } + } + + await this.postStateToWebview() + // The create completed: the claim is spent and later rollbacks of + // other creates must see this one as finished - and spare the record + // it saved. + if (managerSnapshot.claimedCreate) { + markProfileCreateSucceeded(name) + if (releaseProfileCreate(name)) { + profileCreatesSucceeded.delete(name) + } + } + return id + } catch (error) { + // Compensate every write that may have landed, each restore awaited + // on its own so one failure cannot strand the rest: the manager + // record and selection first (the manager store is the source of + // truth that the profile list and the shared settings are derived + // from), then the mode mapping, then the shared and per-view state. + // The original failure is re-thrown for the caller. + await this._restoreProviderManagerRecord(name, managerSnapshot, normalizedSettings) + await this._restoreProviderManagerSelection(managerSnapshot) + if (activationMode !== undefined) { + try { + await this.providerSettingsManager.setModeConfig(activationMode, priorModeConfigId as string) + } catch (restoreError) { + this.log( + `[ClineProvider] profile-activation rollback failed for the mode mapping: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + } + await this._restoreProfileActivationState(preWriteSnapshot) + throw error + } } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { + // The same queue that serialises activation, mode switch, save and rename. This deletion + // snapshots every store it rewrites and compensates them as a group, so a mutation that + // interleaves with it gets undone by the other's rollback: an activation that started + // mid-deletion would have its selection rolled back to the profile being removed, and a + // deletion that started mid-activation would compensate a pin for the profile the user had + // just left. Nothing below enqueues again, so holding the queue here cannot deadlock. + return this.enqueueProviderProfileMutation(() => this.deleteProviderProfileUnlocked(profileToDelete)) + } + + private async deleteProviderProfileUnlocked(profileToDelete: ProviderSettingsEntry) { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName @@ -2396,20 +3579,409 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // 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) { + // Only a typed not-found means the record was already gone. Any other failure + // means the snapshot is unavailable, and deleting without a restore point would + // leave the deletion uncompensatable, so it propagates instead. + if (!(error instanceof ProviderSettingsNotFoundError)) { + throw error + } + } + const previousEntries = this.getProviderProfileEntries() + const previousSelection = globalSettings.currentApiConfigName + const previousSharedSettings = this.contextProxy.getProviderSettings() + const previousViewStates = this.getPersistedViewStates({ fresh: true }) + + // Remember what each live sibling's overlay holds before anything is rewritten, so + // the compensation can undo the re-pin: leaving a sibling on the survivor after a + // deletion that failed is a state no caller asked for. Captured outside the write + // group so the catch below can reach it. + const rePinnedSiblings = ClineProvider.getAllInstances() + .filter((instance) => instance !== this && instance.pinnedProfileName === profileToDelete.name) + .map((instance) => ({ instance, apiConfiguration: instance.viewLocalState.apiConfiguration })) + + // 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`, + ) + } - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) + // Captured outside the write group: the post-commit activation completion below the catch + // runs after the deletion stands and needs both flags, which the campaign declares inside + // the try block scope. + let deletedWasGlobal = false + let viewWasPinnedToDeleted = false + + // Captured outside the write group. When this view was pinned to the deleted profile the write group + // re-points this view's own pin and overlay onto the survivor, and the conversation's sticky profile + // name and history entry move with them; none of that is part of restoring the shared stores, so it + // is snapshotted here and put back by the compensation below when the deletion is rolled back. + const actingViewPinBefore = this.viewLocalState.currentApiConfigName + const actingViewOverlayBefore = this.viewLocalState.apiConfiguration + const actingTaskAtEntry = this.getCurrentTask() + const actingTaskPreviousName = actingTaskAtEntry?.taskApiConfigName + const actingTaskPreviousHistoryItem = actingTaskAtEntry + ? this.getTaskHistoryItem(actingTaskAtEntry.taskId) + : undefined - await this.postStateToWebview() + try { + 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) + + // Two views in this extension host share the ContextProxy while their provider queues + // and their settings-manager locks stay per instance, so a sibling view can move the + // shared selection between this method's entry snapshot and the moment the deletion + // became durable above. Decide what the shared store owes the selection as it stands + // now that the record is gone, not as it was at entry. If it names the profile this + // deletion just removed, the selection has to move to a survivor; the survivor itself stays + // the one chosen above, because picking a different one here would mean deciding the + // user's next selection for them. The remaining race - an activation that lands after + // this read and before the + // writes below - needs a lock spanning ProviderSettingsManager instances, which is + // tracked as its own unit rather than widened into this method. + const selectionAfterDelete = this.contextProxy.getValues().currentApiConfigName + deletedWasGlobal = profileToDelete.name === selectionAfterDelete + + // Capture this view's pin before any rewrite: a view pinned to the + // deleted profile while the global selection points elsewhere must still be + // reconfigured, or getState() would keep the deleted profile's settings under + // the surviving profile's name. + viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name + + // Whether anything is actually going to be re-pointed at the survivor: this view's + // own pin, the shared selection, a live sibling's pin, or a closed view's + // persisted pin. Only then does the survivor's configuration have to be known. + const rePointsToSurvivor = + deletedWasGlobal || + viewWasPinnedToDeleted || + rePinnedSiblings.length > 0 || + Object.values(previousViewStates).some((entry) => entry?.currentApiConfigName === profileToDelete.name) + + // Resolve the surviving profile's settings so this view and any other live view + // still pinned to the deleted profile can be re-pinned with a matching + // configuration. When something is about to re-point at the survivor and its + // configuration cannot be read, the failure propagates: those pins would all + // name a profile whose settings are unknown while the shared store keeps + // carrying the deleted profile's credentials under the survivor's name, and the + // catch at the bottom of this method restores the record, selection, and shared + // settings instead. Where nothing re-points, the survivor is not touched at all, + // so pruning a stale list entry stays an idempotent success even when the + // survivor cannot be read. + let survivingSettings: ProviderSettings | undefined + if (rePointsToSurvivor) { + const { name: _survivingName, ...survivingRecord } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = survivingRecord as ProviderSettings + } + + // The shared writes below (setValue's shared persistence, or the + // survivor's setProviderSettings) replace the shared store: views pinned + // to a third profile with no overlay serve the shared store under their + // own pin, so pin their profiles' settings into their overlays first, or + // their getState() would pair their pin with the survivor's settings. + // This view is covered too unless its own pin named the deleted profile + // (that case re-points the pin and the overlay together below). + if (deletedWasGlobal || viewWasPinnedToDeleted) { + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(profileToActivate, { + includeSelf: !viewWasPinnedToDeleted, + }) + } + + if (viewWasPinnedToDeleted) { + // This view's pin now dangles: re-point it. setValue also persists the + // survivor to the shared store, which covers the deleted-was-global case + // for every other view as well as this one. + await this.setValue("currentApiConfigName", profileToActivate) + } else if (deletedWasGlobal) { + // 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 || viewWasPinnedToDeleted) && survivingSettings) { + // The deleted profile was the active one (globally, or for this view), so + // the shared provider keys still carry its settings; replace them so + // getState() reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + + if (viewWasPinnedToDeleted) { + // This view's nested overlay (viewLocalState.apiConfiguration, seeded + // by loadViewState) still serves the deleted profile's configuration: + // replace it with the survivor's so the re-pointed pin serves matching + // settings. A view pinned to another profile keeps its own overlay. + await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) + } + } + + // The acting view's own conversation may be running the deleted profile's + // handler: re-pointing the pin and the shared store does not touch a live + // handler, so the running task keeps sending the deleted profile's + // configuration. Same guard the sibling re-pin above uses - rebuild only when + // the task actually runs under the deleted profile, so a conversation that + // follows a different sticky profile is neither switched nor rewritten - and + // the sticky name moves to the survivor as well, or a later reload re-resolves + // a profile that no longer exists. + if (this.getCurrentTask()?.taskApiConfigName === profileToDelete.name) { + // Strict: a task left naming the deleted profile must fail the deletion so the write + // group above is rolled back instead of reporting a successful deletion. + // It runs before the rebuild below on purpose: a rebuilt API handler is not part of that + // rollback, so a rejected sticky write must not leave this conversation on the survivor's settings. + await this.persistStickyProviderProfileToCurrentTask(profileToActivate, { strictPersistence: true }) + if (survivingSettings) { + this.updateTaskApiHandlerIfNeeded(survivingSettings, { forceRebuild: true }) + } + } + // 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) + + // Closed views keep their durable viewStates entry after disposal: a pin + // naming the deleted profile would rehydrate a missing profile name on + // the next load. Re-point those persisted pins to the survivor too. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + + 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. + let recordRestored = !previousRecord + if (previousRecord) { + try { + // Keep the original id: mode-to-profile mappings, the restored list entry and + // enhancementApiConfigId all reference the record by id. + const { name: _previousName, ...previousSettings } = previousRecord + await this.providerSettingsManager.saveConfig(profileToDelete.name, previousSettings) + recordRestored = true + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the manager record '${profileToDelete.name}': ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + } + if (recordRestored) { + // The manager record is back, so a sibling may be pinned to the restored + // profile again. Undo only the re-pins this deletion made: a sibling that + // moved its own pin since then is newer than this rollback and is left alone. + for (const { instance, apiConfiguration } of rePinnedSiblings) { + if (instance.pinnedProfileName !== profileToActivate) { + continue + } + try { + await instance._saveViewLocalStateFromMutation({ + currentApiConfigName: profileToDelete.name, + apiConfiguration, + }) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore a sibling view pin: ${ + 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)}`, + ) + } + 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)}`, + ) + } + if (recordRestored && viewWasPinnedToDeleted) { + // The shared stores are back, so this view's own re-point has to go back with them: the caller + // was told the deletion failed and the deleted profile exists again, so this view must not stay + // switched to the survivor - live and durable, because a reload reads the durable entry. Only + // undo a pin this deletion actually moved: if the user or a later activation moved this view + // onto a different profile during the failure window, that choice is newer than this snapshot. + // Same three-part rule as the sibling loop above: the record came back, this view was the one + // pinned to the deleted profile, and the pin still names what this operation wrote. + try { + if (this.viewLocalState.currentApiConfigName === profileToActivate) { + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: actingViewPinBefore, + apiConfiguration: actingViewOverlayBefore, + }) + } + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore this view's own pin: ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + // The conversation's sticky profile name and its history entry moved with the pin. The strict + // write puts both back when it is the write that fails, so this only covers a later write in + // the same group failing after the sticky write had already landed. + try { + if (actingTaskAtEntry && actingTaskAtEntry.taskApiConfigName !== actingTaskPreviousName) { + actingTaskAtEntry.setTaskApiConfigName(actingTaskPreviousName) + if (actingTaskPreviousHistoryItem) { + await this.updateTaskHistory(actingTaskPreviousHistoryItem) + } + } + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the acting task's sticky profile: ${ + 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 + } + if (current[viewId] && current[viewId].currentApiConfigName !== profileToActivate) { + // Undo only a pin this deletion actually re-pointed. A sibling that moved + // its own entry somewhere else during the failure window is newer than + // this snapshot, and rewriting it here would resurrect the deleted + // profile's 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 + } + + // The old deleteApiConfiguration path ended with activateProviderProfile, which + // updated the manager's own current-profile record and the current mode's profile + // mapping, rebuilt the current task's API handler, refreshed the current task's + // sticky profile, and emitted ProviderProfileChanged. The storage rewrites above + // cover the durable stores; when the deleted profile was the active one (globally + // or for this view), complete the activation-side effects here so the deletion + // leaves the same live state as activating the survivor. They run after the + // try/catch because the deletion has already landed: a failure here must not roll + // it back, the same placement activateProviderProfileUnlocked gives its rebuild. + if (deletedWasGlobal || viewWasPinnedToDeleted) { + let activationFailed = false + let activationError: unknown + try { + const { + id: survivingId, + name: _activatedName, + ...activatedSettings + } = await this.providerSettingsManager.activateProfile({ name: profileToActivate }) + if (survivingId) { + const { mode } = await this.getState() + await this.providerSettingsManager.setModeConfig(mode, survivingId) + } + + // The deleted profile was the one a live conversation may be running: mirror + // activateProviderProfileUnlocked's completion so the running handler serves the survivor's + // settings instead of the deleted profile's stale configuration. + this.updateTaskApiHandlerIfNeeded(activatedSettings, { forceRebuild: true }) + if (activatedSettings.apiProvider) { + this.emit(RooCodeEventName.ProviderProfileChanged, { + name: profileToActivate, + provider: activatedSettings.apiProvider, + }) + } + } catch (error: unknown) { + // The deletion is already committed: post the committed state first, then surface a + // distinct error so the caller cannot report a successful deletion as a failed one. + activationFailed = true + activationError = error + this.log( + `deleteProviderProfile: deletion committed but activation of '${profileToActivate}' failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + if (activationFailed) { + throw new ProfileDeletionActivationError(profileToActivate, activationError) + } + return + } } + /** + * Point the current task's sticky provider profile at `apiConfigName`. + * + * `strictPersistence` is what the deletion paths use: there the caller has other durable + * writes in flight and a silently-swallowed task-history failure would let the deletion + * report success while the task and its history entry still name the deleted profile. In + * strict mode the in-memory name and the history entry are put back and the failure is + * re-thrown so the caller's compensation runs. Activation keeps the tolerant contract - + * there a failed history refresh must not fail an otherwise-successful profile switch. + */ private async persistStickyProviderProfileToCurrentTask( apiConfigName: string, - options: { skipCurrentTaskRebuild?: boolean; expectedCurrentTaskId?: string } = {}, + options: { skipCurrentTaskRebuild?: boolean; expectedCurrentTaskId?: string; strictPersistence?: boolean } = {}, ): Promise { if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() @@ -2417,13 +3989,18 @@ export class ClineProvider return } + // Read before any write so the strict rollback below can put the persisted entry back. + const taskHistoryItem = this.getTaskHistoryItem(task.taskId) + + // Captured for the strict rollback below: the in-memory name and the persisted entry + // have to move back together with it. + const previousTaskApiConfigName = task.taskApiConfigName + try { // Update in-memory state immediately so sticky behavior works even before the task has // been persisted into taskHistory (it will be captured on the next save). task.setTaskApiConfigName(apiConfigName) - const taskHistoryItem = this.getTaskHistoryItem(task.taskId) - if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName }) } @@ -2434,6 +4011,98 @@ export class ClineProvider error instanceof Error ? error.message : String(error) }`, ) + + if (options.strictPersistence) { + // The deletion paths keep other durable writes in flight: a swallowed task-history + // failure would let the deletion report success while the conversation and its history + // entry still name the profile that is about to be gone. Put both back and let the + // caller compensate. + task.setTaskApiConfigName(previousTaskApiConfigName) + if (taskHistoryItem) { + try { + await this.updateTaskHistory(taskHistoryItem) + } catch (restoreError: unknown) { + this.log( + `[persistStickyProviderProfileToCurrentTask] Could not restore the task history entry for + task ${task.taskId}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + } + throw error + } + } + } + + /** + * Snapshot the settings of sibling views pinned to a different profile into + * their view-local overlays before an activation, upsert, or deletion + * 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 written profile's settings - and a task created from that view + * would run against the wrong provider, model, and credentials. Siblings + * pinned to the written profile are refreshed by + * `refreshViewLocalStateForUpdatedProfile` (or the deletion re-pin); + * unpinned siblings follow the shared slot, where name and settings move + * together. `includeSelf` covers the deletion path, where this view's own + * pin may name a third profile with no overlay. + */ + private async snapshotSiblingOverlaysBeforeSharedSettingsWrite( + writtenProfileName: string, + options?: { includeSelf?: boolean; requireResolvablePins?: boolean }, + ): Promise { + // requireResolvablePins is for the callers that are about to replace the shared + // settings: a sibling whose pinned profile cannot be read would keep reporting that + // pin while its tasks run the new profile's credentials, so the activation must fail + // before the shared write instead of proceeding without the snapshot. The deletion + // caller keeps tolerating unresolvable pins because there the pin is the profile being + // deleted and the same helper re-points such views. + const requireResolvablePins = options?.requireResolvablePins === true + const unresolvablePins: unknown[] = [] + const affected = ClineProvider.getAllInstances().filter( + (instance) => + (instance !== this || options?.includeSelf === true) && + instance.pinnedProfileName !== undefined && + instance.pinnedProfileName !== writtenProfileName && + instance.viewLocalState.apiConfiguration === undefined, + ) + + await Promise.all( + affected.map(async (instance) => { + const pinName = instance.pinnedProfileName + if (pinName === undefined) { + return + } + + try { + const { name: _siblingPinName, ...settings } = await this.providerSettingsManager.getProfile({ + name: pinName, + }) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: settings as ProviderSettings }) + await instance.postStateToWebview() + } catch (error) { + // A pin that names a profile the store no longer has is the tolerated case in + // every caller: the deletion path re-points such views and there is no correct + // snapshot to take for a profile that is gone. Any other read failure means the + // sibling's settings are unknown, and unknown settings must not be guessed from + // the shared store the caller is about to replace - that is the case the + // activation callers fail on. Same discrimination as the deletion path above. + if (requireResolvablePins && !(error instanceof ProviderSettingsNotFoundError)) { + unresolvablePins.push(error) + } else { + this.log( + `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + }), + ) + + if (unresolvablePins.length > 0) { + throw unresolvablePins[0] } } @@ -2447,9 +4116,11 @@ export class ClineProvider expectedCurrentTaskId?: string }, ) { - return this.enqueueProviderProfileMutation((signal) => - this.activateProviderProfileUnlocked(args, options, signal), - ) + // The queue stays owned until this run - including its compensation - settles: a + // group write that hangs past the mutation timeout must not let a later activation + // complete before this rollback, or the late restore would overwrite the later + // activation's selection and settings. + return this.enqueueProviderProfileMutation(() => this.activateProviderProfileUnlocked(args, options)) } private async activateProviderProfileUnlocked( @@ -2460,29 +4131,100 @@ export class ClineProvider skipCurrentTaskRebuild?: boolean expectedCurrentTaskId?: string }, - signal?: AbortSignal, ): Promise { + // The manager selection written by activateProfile belongs to the same + // mutation group as the writes below: snapshot it first so a failed group + // compensates the manager store, not just its copies. + const managerSelectionSnapshot = await this._snapshotProviderManagerSelection() 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 + // Hoisted out of the group below: the mode-binding failure further down happens after + // the selection, the profile list and the shared settings have already landed, and it has + // to roll the same activation back. + let activationSnapshot: ProfileActivationSnapshot | undefined 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), - ]) + // Sibling views 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. + activationSnapshot = this._snapshotProfileActivationState() + try { + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name, { requireResolvablePins: true }) + + // `allSettled` so the compensation below never races a write still in flight. + const activationResults = await Promise.allSettled([ + this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + // 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 }), + ]) + const failedActivation = activationResults.find( + (result): result is PromiseRejectedResult => result.status === `rejected`, + ) + + if (failedActivation) throw failedActivation.reason + } catch (error) { + // Compensate every write that may have landed before the failure, + // including the manager selection written by activateProfile. + await this._restoreProviderManagerSelection(managerSelectionSnapshot) + await this._restoreProfileActivationState(activationSnapshot) + 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. Compensated on failure like the writes above: a + // sibling refresh that throws must not leave the activation half applied. + try { + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + } catch (error) { + await this._restoreProviderManagerSelection(managerSelectionSnapshot) + await this._restoreProfileActivationState(activationSnapshot) + throw error + } } const { mode } = await this.getState() if (id && persistModeConfig) { - await this.providerSettingsManager.setModeConfig(mode, id) + // The mode mapping is one more write that can fail after the selection and the + // shared state already moved. Snapshot the prior mapping so the failure restores it + // instead of leaving the activated profile bound to the mode. + const previousModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) + try { + await this.providerSettingsManager.setModeConfig(mode, id) + } catch (error) { + if (previousModeConfigId) { + try { + await this.providerSettingsManager.setModeConfig(mode, previousModeConfigId) + } catch (restoreError) { + this.log( + `[activateProviderProfile] Failed to restore the mode mapping for '${mode}': ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + } + await this._restoreProviderManagerSelection(managerSelectionSnapshot) + if (activationSnapshot) { + // The activation is already applied at this point: selection, profile list and shared + // settings have all moved. A caller that receives an error must not end up with a + // profile that is activated but bound to no mode. + await this._restoreProfileActivationState(activationSnapshot) + } + throw error + } } // Change the provider for the current task. @@ -2511,6 +4253,342 @@ export class ClineProvider } } + /** + * Refresh the view-local apiConfiguration buffer of the other live views + * pinned to the given profile. An upsert/activation rewrites the profile's + * settings in the shared store and the store-backed manager, but a view + * whose buffer loaded the profile earlier keeps shadowing the stale + * settings in its getState() until its own next mutation. The originating + * view refreshes its buffer at the mutation site itself. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + // Direct private access: compile-time safe across sibling instances. A sibling that has + // begun disposal must not be written to - see the note in the loop below. + (instance) => instance !== this && !instance._disposed && instance.pinnedProfileName === name, + ) + + if (affected.length === 0) { + return + } + + // Snapshot every affected view BEFORE any write starts: a failure in one sibling has to + // roll back the siblings that already landed too. Without this, one sibling's rejected + // refresh leaves the other views holding the new profile's settings while the caller's + // compensation rolls the shared stores back - a split durable state across views. + const snapshots = affected.map((instance) => ({ + instance, + previousOverlay: instance.viewLocalState.apiConfiguration, + })) + + const results = await Promise.allSettled( + snapshots.map(async (snapshot) => { + // A provider that has begun disposal owns an orphaned viewStates key: viewId comes from + // the monotonic nextViewId counter, so no future view ever reads that entry again and + // prunePersistedViewStates() bounds it. Skipping work on such an instance is therefore + // safe; performing it is the defect - writing durable state and posting to a webview that + // is already being torn down. + // Re-checked before every operation, not just at enumeration: this sibling can start + // disposing after the filter above ran. + if (snapshot.instance._disposed) { + return + } + // Direct private access: compile-time safe across sibling instances. + await snapshot.instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + if (snapshot.instance._disposed) { + return + } + await snapshot.instance.postStateToWebview() + }), + ) + + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (rejected.length > 0) { + const describeFailure = (e: unknown) => (e instanceof Error ? e.message : String(e)) + const restoreFailures: string[] = [] + // Undo every affected view, not just the failing one: the buffer is filled before the + // durable write settles, so even a rejected sibling keeps the new overlay. Each restore + // is awaited on its own so one failing restore cannot skip the next one. + for (const snapshot of snapshots) { + // Compensating through a provider that has begun disposal is the orphaned-key defect + // this guard exists to avoid; its entry is never read again, so leaving it is safe. + if (snapshot.instance._disposed) { + continue + } + try { + await snapshot.instance._saveViewLocalStateFromMutation({ + apiConfiguration: snapshot.previousOverlay, + }) + } catch (rollbackError: unknown) { + snapshot.instance.log( + `[refreshViewLocalStateForUpdatedProfile] Could not restore the buffer for view ${snapshot.instance.viewId} after a failed refresh: ${describeFailure(rollbackError)}`, + ) + restoreFailures.push(`view ${snapshot.instance.viewId} buffer: ${describeFailure(rollbackError)}`) + } + } + if (restoreFailures.length > 0) { + // A sibling buffer that stayed on the new settings while the mutation is rolled back + // is a split durable state, not an ordinary failure: surface the distinct + // inconsistent-state error instead of the plain rejection. + throw new ProfileActivationInconsistentError( + `refreshViewLocalStateForUpdatedProfile: the profile change failed after sibling view buffers were rewritten and the rollback was incomplete (${restoreFailures.join("; ")}); the per-view buffers may now disagree with the shared stores. Original failure: ${describeFailure(rejected[0].reason)}`, + ) + } + throw rejected[0].reason + } + + // Task-handler rebuilds run only after every sibling write and post landed: a + // rebuilt handler cannot be rolled back, so a failing sibling must not leave + // one view's running conversation on the new settings while the rollback + // restored its buffer. 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. + for (const snapshot of snapshots) { + if (snapshot.instance._disposed) { + continue + } + if (snapshot.instance.getCurrentTask()?.taskApiConfigName === name) { + snapshot.instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } + } + } + + /** + * 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( + // Direct private access: compile-time safe across sibling instances. A sibling that has + // begun disposal is excluded from the deletion's re-pin set entirely. + (instance) => instance !== this && !instance._disposed && instance.pinnedProfileName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + // Snapshot every affected view BEFORE any write starts: a failure in one sibling + // has to roll back the siblings that already landed too, otherwise the deletion + // leaves some views pinned to a profile that no longer exists. + const snapshots = affected.map((instance) => { + // Decided before any write starts. The strict sticky persistence inside the settled loop moves + // this conversation's own profile name and its history entry, and neither is part of restoring + // the view's pin, so all of them are captured here. The eligibility flag is read before the + // write as well, because that same write re-points taskApiConfigName. + const task = instance.getCurrentTask() + return { + instance, + previousPin: instance.viewLocalState.currentApiConfigName, + previousOverlay: instance.viewLocalState.apiConfiguration, + taskAtSnapshot: task, + previousTaskApiConfigName: task?.taskApiConfigName, + previousTaskHistoryItem: task ? instance.getTaskHistoryItem(task.taskId) : undefined, + taskWasUnderDeletedProfile: task?.taskApiConfigName === deletedProfileName, + } + }) + + const results = await Promise.allSettled( + snapshots.map(async (snapshot) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + // Assigning even when undefined is what clears the overlay: without a + // resolvable replacement the re-pointed pin names a profile whose settings + // are unknown, so the deleted profile's configuration must not survive under + // the new name. + values.apiConfiguration = replacementSettings + + // A provider that has begun disposal owns an orphaned viewStates key: viewId comes from + // the monotonic nextViewId counter, so no future view ever reads that entry again and + // prunePersistedViewStates() bounds it. Skipping work on such an instance is therefore + // safe; performing it is the defect - writing durable state and posting to a webview that + // is already being torn down. + // Re-checked before every operation, not just at enumeration: the sibling can start + // disposing while earlier siblings are already being written. + if (snapshot.instance._disposed) { + return + } + // Direct private access: compile-time safe across sibling instances. + await snapshot.instance._saveViewLocalStateFromMutation(values) + if (snapshot.instance._disposed) { + return + } + // 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 (snapshot.taskWasUnderDeletedProfile) { + // Strict: the allSettled below turns a rejected task-history write into a rejected sibling, + // which rolls every affected view back and rejects the deletion, instead of leaving that + // sibling's conversation on the deleted profile while its view was already re-pointed. + // The matching handler rebuild is deliberately NOT here: a rebuilt API handler is not part of the + // rollback below, so rebuilding inside the settled loop leaves a sibling whose own write landed + // holding the replacement's handler while another sibling's rejected write rolls its pin back. + await snapshot.instance.persistStickyProviderProfileToCurrentTask(replacementName, { + strictPersistence: true, + }) + } + await snapshot.instance.postStateToWebview() + }), + ) + + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (rejected.length > 0) { + // Undo every affected view, not just the failing one: _saveViewLocalStateFromMutation + // fills the in-memory buffer before the durable write settles, so even a rejected write + // leaves the view re-pointed. Each restore is awaited on its own so one failing restore + // cannot skip the next one. + const restoreFailures: string[] = [] + for (const [index, snapshot] of snapshots.entries()) { + // A provider that has begun disposal owns an orphaned viewStates key: viewId comes from + // the monotonic nextViewId counter, so no future view ever reads that entry again and + // prunePersistedViewStates() bounds it. Re-checked here too: the sibling can start + // disposal while the re-pin is already in flight, and compensating through a provider + // that is tearing down is the defect this guard exists to avoid. + if (snapshot.instance._disposed) { + continue + } + try { + await snapshot.instance._saveViewLocalStateFromMutation({ + currentApiConfigName: snapshot.previousPin, + apiConfiguration: snapshot.previousOverlay, + }) + } catch (rollbackError: unknown) { + const message = rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + snapshot.instance.log( + `[rePinViewLocalStateForDeletedProfile] Could not restore the pin for view ${snapshot.instance.viewId} after a failed re-pin: ${message}`, + ) + // Collect, do not swallow: an incomplete compensation must reach the + // caller, or a sibling left pinned to the deleted profile is invisible. + restoreFailures.push(`view ${snapshot.instance.viewId}: ${message}`) + } + // The strict sticky persistence inside the settled loop also moved this conversation's own profile + // name and its history entry. Only the sibling whose own write FAILED is self-restored by that call, + // so a sibling whose write landed would keep naming the replacement while its pin goes back to the + // deleted profile - the same split state the pin restore above exists to prevent. Skipped when + // nothing actually moved: that covers the rejected sibling, which already put both back itself, + // and a sibling that a disposal guard returned early. + const task = snapshot.taskAtSnapshot + if ( + results[index]?.status === "fulfilled" && + task && + task.taskApiConfigName !== snapshot.previousTaskApiConfigName + ) { + try { + task.setTaskApiConfigName(snapshot.previousTaskApiConfigName) + if (snapshot.previousTaskHistoryItem) { + await snapshot.instance.updateTaskHistory(snapshot.previousTaskHistoryItem) + } + } catch (rollbackError: unknown) { + const message = rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + snapshot.instance.log( + `[rePinViewLocalStateForDeletedProfile] Could not restore the task profile for view ${snapshot.instance.viewId} after a failed re-pin: ${message}`, + ) + restoreFailures.push(`view ${snapshot.instance.viewId} task profile: ${message}`) + } + } + } + const original = rejected[0].reason + if (restoreFailures.length > 0) { + const prefix = original instanceof Error ? original.message : String(original) + throw new Error(`${prefix}; sibling restore also failed: ${restoreFailures.join("; ")}`, { + cause: original instanceof Error ? original : undefined, + }) + } + throw original + } + + // Only after every sibling's re-pin landed: switching a task is not part of the durable + // rollback above, so doing it inside the loop would leave a successful sibling's task on + // the replacement's handler and sticky name while its pin was compensated back to the + // deleted profile. 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). + for (const snapshot of snapshots) { + // The only place a task handler moves: a rebuilt handler cannot be compensated, so it waits for + // every sibling to land. The eligibility flag comes from before the writes, because the strict + // sticky persistence in the settled loop has already re-pointed taskApiConfigName for exactly + // this set - reading it here again would skip every conversation that needed the swap. Only + // conversations that actually ran under the deleted profile are re-pointed: 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 (snapshot.taskWasUnderDeletedProfile && replacementSettings) { + snapshot.instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await snapshot.instance.postStateToWebview() + } + } + + /** + * 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) + } + await instance.postStateToWebview() + }), + ) + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) @@ -2646,8 +4724,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) } } } @@ -3449,12 +5529,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. @@ -3515,127 +5601,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, } } @@ -3707,7 +5809,64 @@ export class ClineProvider } public async setValue(key: K, value: RooCodeSettings[K]) { + const previousSharedValues = { [key]: this.contextProxy.getValue(key) } as RooCodeSettings await this.contextProxy.setValue(key, value) + try { + await this._saveViewLocalStateFromMutation({ [key]: value }) + } catch (error) { + // The per-view record did not land: restore the prior shared value so the two + // persistence layers cannot diverge, then surface the original failure. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, { + [key]: value, + } as RooCodeSettings) + throw error + } + } + + /** + * Restore shared values captured before a setter wrote them, used when the paired + * per-view persistence failed so the shared store cannot move ahead of the per-view + * record. A failed rollback is logged; the original failure is what callers see. + * + * A key is restored only while the shared store still holds the value this call + * wrote: another view may have committed a newer value for that key in the window + * between the two writes, and writing the pre-mutation snapshot back would undo it. + */ + private async _restoreSharedValuesAfterFailedViewWrite( + previous: RooCodeSettings, + written: RooCodeSettings, + ): Promise { + const restore = Object.fromEntries( + Object.entries(previous).filter(([key]) => + Object.is( + this.contextProxy.getValue(key as keyof RooCodeSettings), + written[key as keyof RooCodeSettings], + ), + ), + ) as RooCodeSettings + + if (Object.keys(restore).length === 0) { + // Every key was superseded by a newer write: there is nothing left of this call to + // undo. + return + } + + try { + await this.contextProxy.setValues(restore) + + // The shared store is back on the pre-mutation values while the per-view record never landed; + // say so: the caller only sees the original per-view failure, and a silent rollback would make the + // divergence between the two stores untraceable. + this.log( + `[ClineProvider] The per-view write failed; the shared value(s) were restored: ${Object.keys( + restore, + ).join(", ")}`, + ) + } 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) { @@ -3715,13 +5874,204 @@ 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}`)), + // Main's display gate stays in front of ours: an invalid display value is dropped + // before anything reaches either persistence layer, and the drop is logged. Our mode + // gate below is a different key class and validates against the custom-modes registry. + 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. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } + } + + const previousSharedValues = Object.fromEntries( + Object.keys(sanitizedValues).map((key) => [key, this.contextProxy.getValue(key as keyof RooCodeSettings)]), + ) as RooCodeSettings + await this.contextProxy.setValues(sanitizedValues) + try { + await this._saveViewLocalStateFromMutation(sanitizedValues) + } catch (error) { + // The per-view record did not land: restore the prior shared values so the two + // persistence layers cannot diverge, then surface the original failure. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, sanitizedValues) + throw error + } + } + + /** + * Persists the view-local subset of a ContextProxy mutation, then updates the in-memory + * viewLocalState buffer. Persistence is awaited first so a failed durable write cannot + * leave the local cache ahead of the persisted state. + */ + private async _saveViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + await this._persistViewLocalStateFromMutation(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial & Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val + } + } + + if ("apiConfiguration" in values) { + const val = values.apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } + + // 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. + } + + /** + * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) + * into the registered viewStates map for this view. + */ + private async _persistViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + const persistedValues: Partial = {} + + if ("mode" in values) { + persistedValues.mode = values.mode as PersistedViewState["mode"] + } + + if ("currentApiConfigName" in values) { + persistedValues.currentApiConfigName = values.currentApiConfigName + } + + if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { + await this.savePersistedViewState(persistedValues) + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalStateGeneration += 1 + this.viewLocalState = {} + } + + /** + * Clears the per-view `mode` override of every live view that pinned `slug`. + * + * Deleting a custom mode only moves the acting view to the default mode. A sibling that + * pinned the deleted slug still holds it in its own overlay and in its own durable + * viewStates entry, so that view's getState() reports a mode that no longer exists and any + * task it creates writes that slug into its history. Only overrides that name the deleted + * slug are cleared - a sibling serving another mode keeps its own - and each affected view + * gets its own postStateToWebview, the same fan-out resetState() uses. The acting view is + * the caller's business: its reset goes through setValue, which updates its own overlay. + */ + async clearViewLocalModeForDeletedSlug(slug: string): Promise { + for (const instance of ClineProvider.getAllInstances()) { + if (instance === this) { + continue + } + + // Direct private access: compile-time safe across sibling instances. + if (instance.viewLocalState.mode !== slug) { + continue + } + + try { + await instance._saveViewLocalStateFromMutation({ mode: undefined }) + } catch (error) { + instance.log( + `[clearViewLocalModeForDeletedSlug] Failed to clear the mode override for '${slug}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + continue + } + + await instance.postStateToWebview() + } + } + + /** + * 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. + // Bump before the clear: a save queued from here on belongs after the reset and must be + // rejected (its pre-reset values are what the reset is discarding). + ClineProvider.viewStatesResetGeneration += 1 + const clearWrite = ClineProvider.persistedViewStateWriteQueue.then(async () => { + await this.contextProxy.setValue("viewStates", undefined) + }) + ClineProvider.persistedViewStateWriteQueue = clearWrite.catch(() => {}) + await clearWrite + + 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) + }`, + ) + } + } + } } // dev @@ -3737,6 +6087,11 @@ export class ClineProvider return } + // Fence in-flight profile and mode mutations before the reset clears the stores: a mutation + // that started before the reset can otherwise persist after the whole-map clear, and the clear + // would then be undone by a write the user never saw. + await this.providerProfileMutationQueue.catch(() => {}) + // Log out from cloud if authenticated if (CloudService.hasInstance()) { try { @@ -3750,9 +6105,53 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + + // Clear this view's persisted entry too, so the reset selections are not + // re-applied from the durable viewStates pin after a reload. + await this.clearPersistedViewState() + + // Other live webviews hold their own overlay and their own durable pin: a reset in + // one view would otherwise leave the others serving their pre-reset mode and profile + // in getState() until their next mutation. Direct private access: compile-time safe + // across sibling instances. + for (const instance of ClineProvider.getAllInstances()) { + if (instance === this) { + continue + } + instance._clearViewLocalState() + try { + await instance.clearPersistedViewState() + } catch (error) { + instance.log( + `[resetState] Failed to clear a sibling view's persisted state: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + // The sibling refresh post is state delivery, not part of the reset contract: a webview that + // has already gone away must not abort the steps below, which still have to reach every other + // live view. The durable clear above is already logged and retried by the next mutation. + try { + await instance.postStateToWebview() + } catch (error) { + instance.log( + `[resetState] Failed to refresh a sibling webview after the reset: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + 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" }) } @@ -4278,6 +6677,17 @@ export class ClineProvider configuration: RooCodeSettings = {}, ): Promise { if (configuration) { + // Register custom modes before setValues: setValues validates `mode` against + // getCustomModes(), so a mode supplied in the same call would otherwise be deleted + // and never persisted. The manager also overwrites the global-state copy when it + // merges .roomodes + global settings on refresh, so updateCustomMode is the write + // that makes the mode survive the merge cycle. + if (configuration.customModes?.length) { + for (const mode of configuration.customModes) { + await this.customModesManager.updateCustomMode(mode.slug, mode) + } + } + await this.setValues(configuration) if (configuration.allowedCommands) { @@ -4305,16 +6715,6 @@ export class ClineProvider if (configuration.currentApiConfigName) { await this.setProviderProfile(configuration.currentApiConfigName) } - - // Register custom modes so the CustomModesManager knows about them. - // setValues writes to global state, but the manager overwrites that - // 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) - } - } } const { diff --git a/src/core/webview/ClineProviderFactory.ts b/src/core/webview/ClineProviderFactory.ts index 4ed6aba814..39720a0135 100644 --- a/src/core/webview/ClineProviderFactory.ts +++ b/src/core/webview/ClineProviderFactory.ts @@ -16,6 +16,9 @@ export class ClineProviderFactory { context: this.context, outputChannel: this.outputChannel, webviewFocusTracker: this.webviewFocusTracker, + // The API's startNewTask closes the editors before asking for a tab, so it must not be + // handed back a panel that still has a provider: that would silence the new task. + forceNew: true, }) } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index c2aec90dcd..809e0b9de9 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -240,9 +240,15 @@ 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"), + 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,8 +857,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const first = provider.activateProviderProfile({ name: "first-profile" }) const second = provider.activateProviderProfile({ name: "second-profile" }) - await Promise.resolve() - expect(events).toEqual(["first:start"]) + // The mutation now reads the manager selection snapshot before the + // manager write, so the first call lands a few microtasks later than a + // single tick would assume; wait for it deterministically instead. + await vi.waitFor(() => expect(events).toEqual(["first:start"])) resolveFirst() await first @@ -880,7 +888,58 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) - test("timed-out mutations abort before writing state and advance the queue", async () => { + test("an activation that times out while queued behind another never starts", async () => { + vi.useFakeTimers() + let releaseFirst!: () => void + const firstActivation = new Promise((resolve) => { + releaseFirst = resolve + }) + provider["providerSettingsManager"].activateProfile = vi + .fn() + .mockImplementationOnce(async () => { + await firstActivation + return { + name: "first-profile", + id: "first-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + } + }) + .mockResolvedValueOnce({ + name: "second-profile", + id: "second-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + + try { + // The first activation outlives the second call's timeout window: the + // second caller rejects while its callback is still queued. + const first = provider.activateProviderProfile({ name: "first-profile" }) + const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(1000) + const second = provider.activateProviderProfile({ name: "second-profile" }) + const secondResult = expect(second).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await firstResult + await secondResult + + // The first activation settles and the queue advances: the second must + // not start after its caller already saw a timeout, or it would change + // the selected profile while reporting failure. + releaseFirst() + // The queue tail settles only after the second run has been evaluated. + await provider["providerProfileMutationQueue"].catch(() => undefined) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("first-profile") + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) + expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "second-profile") + } finally { + vi.useRealTimers() + } + }) + + test("timed-out activations run to completion and hold the queue", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") @@ -912,20 +971,26 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) await firstResult - // Queue advanced immediately on timeout — second enqueues now. + // The queue stays owned until the hung activation settles: the second + // activation waits, so the late rollback cannot land on top of it. const second = provider.activateProviderProfile({ name: "second-profile" }) - // activateProfile not yet called for second (it runs in the next microtask). + await vi.advanceTimersByTimeAsync(0) expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) - // Resolve the first activation's inner promise so its in-flight mock can return. - // The aborted signal prevents it from writing any state. + // The first activation was not aborted: it completes its writes before + // the queue advances to the second. resolveFirst() + await vi.advanceTimersByTimeAsync(0) await expect(second).resolves.toBeUndefined() expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(2) - // Aborted first activation wrote nothing; only second profile is set. - expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "first-profile") + expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "first-profile") expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") - expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; aborting in-flight mutation") + // The final shared selection is the second profile: no late write puts + // the first profile back on top. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("second-profile") + expect(logSpy).toHaveBeenCalledWith( + "Provider profile mutation timed out; waiting for it to finish before the next mutation", + ) } finally { vi.useRealTimers() } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9826fc206b..6781f90a3f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -22,20 +22,25 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_WRITE_DELAY_MS, providerIdentifiers, + RooCodeEventName, openAiModelInfoSaneDefaults, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" +import { EMBEDDING_MODEL_PROFILES } from "../../../shared/embeddingModels" import { setTtsEnabled } from "../../../utils/tts" import { ContextProxy } from "../../config/ContextProxy" +import { ProviderSettingsNotFoundError } from "../../config/ProviderSettingsManager" import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" +import { t } from "../../../i18n" -import { ClineProvider } from "../ClineProvider" +import { ClineProvider, ProfileDeletionActivationError } from "../ClineProvider" 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 +430,45 @@ afterAll(() => { vi.restoreAllMocks() }) +/** + * Minimal profile shape the stalled getProfile double resolves to. The + * settings fields are optional so a lookup that resolves name-only (a + * profile with no configured provider) also type-checks. + */ +type StalledProfile = { + name: string + apiProvider?: string + openRouterModelId?: string +} + +/** + * Swap the provider's ProviderSettingsManager for a double whose getProfile + * stalls until the test resolves it, so tests can mutate view state while + * loadViewState is in flight. The double only backs getProfile, the member + * loadViewState awaits; the documented @ts-ignore replaces the per-test + * suppressions the inlined copies used. + */ +function stallProviderSettingsProfile(provider: ClineProvider) { + let resolveProfile: (value: StalledProfile) => void = () => {} + const getProfileSpy = vi.fn(() => { + // Only one lookup is resolvable: resolveProfile is bound to the first + // promise's resolver, so a second getProfile would strand its promise. + // Fail loudly instead of hanging. + if (getProfileSpy.mock.calls.length > 1) { + throw new Error( + "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", + ) + } + return new Promise((resolve) => (resolveProfile = resolve)) + }) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // Return a stable wrapper around the closure binding: resolveProfile is + // reassigned to the pending promise's resolver once getProfile is called, + // so returning the variable directly would hand the test the initial no-op. + return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } +} + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -462,6 +506,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 +651,92 @@ 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("unregisters a provider whose teardown step rejects", async () => { + const failingProvider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Own view object, so the lookup below cannot resolve to the shared test provider. + failingProvider["view"] = { dispose: vi.fn(), visible: false } as never + + // Two teardown failures with different shapes: a rejected await and a synchronous throw. + failingProvider["mcpHub"] = { + unregisterClient: vi.fn().mockRejectedValue(new Error("unregister hung")), + } as never + failingProvider["marketplaceManager"] = { + cleanup: vi.fn(() => { + throw new Error("cleanup threw") + }), + } as never + + // Disposal must not surface the teardown failure as a crash - callers dispose providers + // during activation and tab rollback, where a throw would strand the rest of the cleanup. + await failingProvider.dispose() + + // _disposed is already true, so nothing here runs again: a provider left in activeInstances + // would stay counted by getVisibleInstance/getAllInstances and resolvable by + // getInstanceForView forever. + expect(ClineProvider.getAllInstances()).not.toContain(failingProvider) + expect(ClineProvider.getInstanceForView(failingProvider["view"] as vscode.WebviewView)).toBeUndefined() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining( + "Disposal was incomplete (mcpHub: unregister hung; marketplace manager: cleanup threw)", + ), + ) + }) + + it("unregisters the provider before its awaited cleanup finishes", async () => { + // The unregistration used to sit after every awaited teardown step, so a provider that was + // already _disposed stayed enumerable for the whole cleanup window - long enough for a + // sibling profile mutation to persist durable view state and post to a view that is gone. + const disposing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Own view object, so the lookup below cannot resolve to the shared test provider. + disposing["view"] = { dispose: vi.fn(), visible: false } as never + // Hold the last teardown step open so the assertions run while dispose() is still awaiting. + let releaseCleanup: () => void = () => {} + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve + }) + // taskHistoryStore is readonly, so the double assertion is the narrowest way to swap in a + // gate; bracket access alone cannot reassign a readonly field. + const store = disposing as unknown as { taskHistoryStore: { dispose: () => Promise } } + store.taskHistoryStore = { dispose: () => cleanupGate } + + const disposal = disposing.dispose() + // Drain the microtask chain that carries dispose() to its pending await. + for (let round = 0; round < 3; round++) { + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(ClineProvider.getAllInstances()).not.toContain(disposing) + expect(ClineProvider.getInstanceForView(disposing["view"] as vscode.WebviewView)).toBeUndefined() + + releaseCleanup() + await disposal + expect(ClineProvider.getAllInstances()).not.toContain(disposing) + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -1058,7 +1195,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: { @@ -1096,6 +1233,37 @@ describe("ClineProvider", () => { expect(mockWebviewView.webview.html).toContain("") expect(mockWebviewView.webview.html).toContain("Zoo Code") }) + test("whenViewResolved releases waiters only once messages would actually be delivered", async () => { + // The focus command settles when VS Code opens the pane, so the plus-button flow awaits this + // gate before posting. A waiter must not wake while postMessageToWebview would drop the + // message because the view is still unset. + const pending = provider.whenViewResolved() + let settled = false + void pending.then(() => { + settled = true + }) + + await new Promise((resolve) => setImmediate(resolve)) + expect(settled).toBe(false) + + await provider.resolveWebviewView(mockWebviewView) + await pending + expect(settled).toBe(true) + + // An already-resolved view answers immediately, and a provider that goes away must not leave + // the caller hanging on a view that is never coming. + await expect(provider.whenViewResolved()).resolves.toBeUndefined() + const orphan = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const hanging = orphan.whenViewResolved() + await orphan.dispose() + await expect(hanging).resolves.toBeUndefined() + }) describe("logWebviewHiddenDiagnostics", () => { let visibilityCallback: () => void @@ -3233,6 +3401,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 +3540,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 +3558,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 +3577,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() @@ -3452,43 +3649,6540 @@ describe("ClineProvider", () => { const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) - await postState(provider) + await postState(provider) + + expect(getAllSpy).not.toHaveBeenCalled() + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") + }) + + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { + const historyItem = { + id: "history-task", + number: 1, + ts: 1, + task: "History task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const originalGetState = provider.getState.bind(provider) + let baseStateResolved = false + const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { + const state = await originalGetState(options) + baseStateResolved = true + return state + }) + const historyReadPhases: boolean[] = [] + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { + historyReadPhases.push(baseStateResolved) + return [historyItem] + }) + + const state = await provider.getStateToPostToWebview() + + expect(getStateSpy).toHaveBeenCalledOnce() + expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) + expect(getAllSpy).toHaveBeenCalledOnce() + expect(historyReadPhases).toEqual([true]) + expect(state.taskHistory).toEqual([historyItem]) + }) + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await provider["setViewStateId"]("stable-sidebar-view") + + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "my-profile") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const testApiConfig = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("apiConfiguration", testApiConfig) + + expect(provider["viewLocalState"].apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear the currentApiConfigName override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("currentApiConfigName", "my-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + + await provider.saveViewState("currentApiConfigName", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "currentApiConfigName")).toBe(false) + + await provider.dispose() + }) + + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Conditioned on the payload, never on call order: this flow writes the viewStates + // map more than once (the registration re-key, then the mode write), so a + // mockRejectedValueOnce would pin whichever call happened to come first - and if it + // pinned nothing at all the test would still be green. rejectedKeys makes the + // injection observable: if the write below is not the one that fails, the assertion + // at the end says so instead of the test silently passing. + const rejectedKeys: string[] = [] + const setValueOriginal = provider.contextProxy.setValue.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + const states = value as Record | undefined + if (String(key) === "viewStates" && states?.["stable-sidebar-view"]?.mode === "architect") { + rejectedKeys.push(key) + throw new Error("persist failed") + } + return setValueOriginal(key, value) + }) + + 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() + expect(rejectedKeys).toContain("viewStates") + + 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() + }) + }) + + 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() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 54 }) + expect(pruned["view-5"]).toEqual({ mode: "mode-5", updatedAt: 5 }) + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("__proto__") + + // "__proto__" is rejected before assignment so a per-view entry can never be + // keyed through the Object.prototype setter: the temporary id stays active and + // nothing is persisted under the reserved name. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("rejects Object.prototype member names other than __proto__", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + for (const reserved of ["constructor", "toString", "__defineGetter__"]) { + await provider["setViewStateId"](reserved) + } + + // A prototype name would read an inherited value instead of a persisted entry and + // silently drop the pre-launch selection, so none of them may become the view id. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("does not republish captured state when the buffer is reset during the profile load", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + + let releaseCustomModes: () => void = () => {} + vi.spyOn(provider.customModesManager, "getCustomModes").mockImplementation( + () => + new Promise((resolve) => { + releaseCustomModes = () => resolve([]) + }), + ) + + const load = provider["loadViewState"]() + // A reset clears the buffer while the load is awaiting the mode/profile lookups. + provider["_clearViewLocalState"]() + releaseCustomModes() + await load + + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("restores the previous view id when the registration write fails so a later launch retries", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id through the real write path, + // so the re-key has real work to do and records this instance as its author. + 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") + const retryViewStates = mockContext.globalState.get("viewStates") as Record + expect(retryViewStates["stable-sidebar-view"]).toMatchObject({ mode: "architect" }) + + await provider.dispose() + }) + }) + + describe("view state persistence edge cases", () => { + it("should read viewStates from the ContextProxy cache when not fresh", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + expect(provider["getPersistedViewStates"]()).toEqual({ "stable-sidebar-view": { mode: "architect" } }) + await provider.dispose() + }) + + it("should treat a corrupted non-object viewStates value as an empty map", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A string in storage is corrupt: the fresh-read guard must not spread it. + await mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + }) + await save("mode", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("mode") + await provider.saveViewState("mode", "architect") + await save("currentApiConfigName", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + mode: "architect", + updatedAt: expect.any(Number), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("restores the shared value when the view-local durable write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("atomic-write-view") + await provider.setValue("currentApiConfigName", "first-profile") + await provider.setValues({ mode: "architect" }) + + // Only the durable viewStates write fails, so the failure lands exactly between the + // shared write and the view-local write of the same call. + // getMockImplementation, not bind: spyOn reuses the same mock object, so a bound copy + // would call the spy's own implementation and recurse. + const originalUpdate = vi.mocked(mockContext.globalState.update).getMockImplementation() + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementation(async (key, value) => { + if (key === "viewStates") { + throw new Error("view states write failed") + } + return originalUpdate?.(key, value) + }) + const logSpy = vi.spyOn(provider, "log") + + await expect(provider.setValue("currentApiConfigName", "second-profile")).rejects.toThrow( + "view states write failed", + ) + await expect(provider.setValues({ mode: "code" })).rejects.toThrow("view states write failed") + + // The shared store must not keep a value that getValues() cannot report: the + // view-local buffer is only updated once the durable per-view write succeeds, so an + // un-rolled shared write would leave storage on the new profile while every reader + // that merges viewLocalState over it keeps serving the old one. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("first-profile") + expect(provider.getValues().currentApiConfigName).toBe("first-profile") + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("the shared value(s) were restored")) + updateSpy.mockRestore() + 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("does not load a durable entry an earlier window left under this view id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // The temporary view id is a session counter that restarts at zero per extension + // host, so a previous window can leave its pin under exactly this key. Loading it + // would hand this view another view's mode and profile. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { + mode: "architect", + currentApiConfigName: "previous-window-profile", + updatedAt: 1, + }, + }) + + await provider["loadViewState"]() + + expect(provider["viewLocalState"].mode).toBeUndefined() + expect(provider["viewLocalState"].currentApiConfigName).toBeUndefined() + + await provider.dispose() + }) + + it("clears an orphaned pre-launch entry instead of re-keying it onto the stable id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Seeded straight into durable storage, which is where an earlier window left it. + await mockContext.globalState.update("viewStates", { + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + }) + + await provider["setViewStateId"]("stable-orphan-view") + + const orphaned = mockContext.globalState.get("viewStates") as Record + expect(orphaned["stable-orphan-view"]).toBeUndefined() + // The landed guard refuses the orphan without rewriting storage: the stale entry stays + // under the session-local key (prune ages it out) and is never adopted onto the stable id. + expect(orphaned[provider.viewId]).toMatchObject({ mode: "architect" }) + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.dispose() + }) + + it("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Author the temporary entry through the write path so this instance owns it, + // then add a stable entry that already exists in storage. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + await mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("a b/c") + expect(provider["viewStateId"]).toBe("a_b_c") + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + expect(provider["viewStateId"]).toBe("a_b_c") + logSpy.mockClear() + await provider["setViewStateId"]("a_b_c") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should reapply fields mutated while the load is in flight and keep persisted values for untouched fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutations and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // Selections made while the profile lookup is in flight must survive the load. + await provider.saveViewState("mode", "architect") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // Dirty fields win over the loaded state; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-y" }, + }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted mode authoritative when the pre-load buffer is untouched", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "code") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should not restore a persisted mode whose custom mode no longer exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // Persist a mode too: without it the load has no mode to resurrect, so the + // cleared-field assertion below would pass even if the loaded value clobbered + // the in-flight clear. + await provider.saveViewState("currentApiConfigName", "cfg-a") + await provider.saveViewState("mode", "code") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight clear and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // The user clears the mode while the load is in flight. + await provider.saveViewState("mode", undefined) + resolveProfile({ name: "cfg-a" }) + await load + + // The cleared field must stay absent rather than keeping the persisted or + // pre-load value; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the mode is mutated while the profile lookup is in flight. + await provider.saveViewState("mode", "architect") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should reapply an independently mutated profile name when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + let releaseInitialProfile: (value: StalledProfile) => void = () => {} + const getProfileSpy = vi.fn((params: { name: string }) => { + if (params.name === "cfg-a") { + return new Promise((resolve) => (releaseInitialProfile = resolve)) + } + return Promise.resolve({ + name: params.name, + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + }) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + releaseInitialProfile({ + name: "cfg-a", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await load + + // The in-flight selection wins over the loaded state. The lookup resolved cfg-a, a + // profile this view no longer names, so its settings must not be paired with cfg-b: + // the overlay stays unresolved and getState falls back to the shared store. + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-b") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.dispose() + }) + + it("should reapply an independently mutated apiConfiguration when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the apiConfiguration is mutated while the profile lookup is in flight. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should keep every persisted field authoritative when the load is untouched", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + await provider.saveViewState("mode", "code") + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // No mutation while the lookup is in flight: the persisted values must win. + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "code", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should persist known modes and reject unknown or non-string modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the slugs under test. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "refactor" ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValues({ mode: "refactor" }) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + await provider.setValues({ mode: "bogus-mode" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode must be rejected before persistence; Object.assign + // keeps this a single structural assertion, so it must not reach global + // state or the buffer. + await provider.setValues(Object.assign({} as RooCodeSettings, { mode: 42 })) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should 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("clears every live view's buffer and persisted entry when another view resets state", async () => { + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(sibling, "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(), + } + 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") + // The sibling pins a profile and buffers its settings snapshot, API + // key included - exactly what a credential-removing reset must not + // leave behind in another view. + await sibling["setViewStateId"]("stable-editor-view") + await sibling.saveViewState("mode", "code") + sibling["viewLocalState"].currentApiConfigName = "doomed-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "doomed-secret", + } + + await provider.resetState() + + expect(sibling["viewLocalState"]).toEqual({}) + const persisted = (mockContext.globalState.get("viewStates") as Record) ?? {} + expect(persisted["stable-editor-view"]).toBeUndefined() + expect(persisted["stable-sidebar-view"]).toBeUndefined() + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("should not tombstone a view id when the durable viewStates delete fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tombstone-view") + await provider.saveViewState("currentApiConfigName", "cfg-tombstone") + const viewId = provider["viewStateId"] + expect(ClineProvider["clearedViewStateIds"].has(viewId)).toBe(false) + + 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["clearPersistedViewState"](viewId)).rejects.toThrow("storage unavailable") + mockContext.globalState.update = originalUpdate + + // The entry is still in durable storage, so the id must stay restorable: profile + // activation rollback skips pruning of ids that were cleared, and tombstoning an id that + // storage never dropped is what loses the pin on the next reload. + expect(ClineProvider["clearedViewStateIds"].has(viewId)).toBe(false) + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + [viewId]: { currentApiConfigName: "cfg-tombstone" }, + }) + + await provider.dispose() + }) + + it("should roll back the shared write when setValue per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValue("mode", "code")).rejects.toThrow("storage unavailable") + // The shared store must not move ahead of the per-view record that failed. + expect(provider.getValue("mode")).toBe("architect") + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + + it("should roll back the shared writes when setValues per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + await provider.contextProxy.setValue("currentApiConfigName", "current-config") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValues({ mode: "code", currentApiConfigName: "other-config" })).rejects.toThrow( + "storage unavailable", + ) + expect(provider.getValue("mode")).toBe("architect") + expect(provider.getValue("currentApiConfigName")).toBe("current-config") + expect(provider["viewLocalState"].mode).toBeUndefined() + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + }) + + describe("provider profile mutations", () => { + it("should sync the view-local buffer when activating a profile over a loaded view state", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("currentApiConfigName", "old-profile") + + const profile: ProviderSettingsEntry = { + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openrouter, + } + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(profile), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + 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("keeps the mutation queue chained to the running mutation after the caller-facing timeout", async () => { + // The timeout exists to release the caller, not to release the queue. A mutation that is + // still writing durable state must keep later mutations out: the abort signal is advisory, + // so a fn that ignores it would otherwise interleave its writes with its successor's. + vi.useFakeTimers() + // PENDING_OPERATION_TIMEOUT_MS is a public static readonly, so TS forbids assigning it; + // the double assertion is the only way to shorten it for this test without widening the + // production API or making the constant mutable in production. + const originalTimeout = ClineProvider.PENDING_OPERATION_TIMEOUT_MS + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = 50 + const started: string[] = [] + let releaseFirst!: () => void + try { + const first = provider["enqueueProviderProfileMutation"](async () => { + started.push("first") + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + // Attach the rejection handler before the timer fires: the caller-facing timeout rejects + // inside the fake-timer tick, and a handler attached only afterwards is reported by + // Vitest as an unhandled rejection. + const firstRejected = expect(first).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(0) + + // The caller is released at the timeout while the mutation itself is still running. + await vi.advanceTimersByTimeAsync(60) + await firstRejected + + const second = provider["enqueueProviderProfileMutation"](async () => { + started.push("second") + }) + await vi.advanceTimersByTimeAsync(0) + expect(started).toEqual(["first"]) + + // Only once the running mutation settles does the queue hand over. + releaseFirst() + await expect(second).resolves.toBeUndefined() + expect(started).toEqual(["first", "second"]) + } finally { + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = + originalTimeout + vi.useRealTimers() + } + }) + + it("should report the activated profile's settings over a stale view-local buffer in getState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const freshSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ name: "new-profile", id: "new-id", ...freshSettings }), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue({ name: "new-profile", id: "new-id", ...freshSettings }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + 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 = { + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack1[name] = settings + return "fresh-id" + }), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record (none - this call creates the + // profile) and the previous per-mode mapping. + getProfile: vi.fn(async ({ name }: { name: string }) => { + if (!savedForReadBack1[name]) { + throw new ProviderSettingsNotFoundError(name) + } + return { name, id: "fresh-id", ...savedForReadBack1[name] } + }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + 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("refuses to save when the existing profile record cannot be read", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The store proves the record is there and then cannot hand it over. Neither + // restore branch of the compensation can tell a landed record from an absent + // one in that state, so the save must not start. + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue( + new Error("profile record unreadable"), + ) + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig") + const deleteSpy = vi.spyOn(provider.providerSettingsManager, "deleteConfig") + const activateSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + const id = await provider.upsertProviderProfile("unreadable-profile", { + apiProvider: providerIdentifiers.anthropic, + }) + + expect(id).toBeUndefined() + expect(saveSpy).not.toHaveBeenCalled() + expect(deleteSpy).not.toHaveBeenCalled() + expect(activateSpy).not.toHaveBeenCalled() + expect(provider.getValues().currentApiConfigName).not.toBe("unreadable-profile") + } finally { + 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 = { + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack2[name] = settings + return "fresh-id" + }), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record (none - this call creates the + // profile) and the previous per-mode mapping. + getProfile: vi.fn(async ({ name }: { name: string }) => { + if (!savedForReadBack2[name]) { + throw new ProviderSettingsNotFoundError(name) + } + return { name, id: "fresh-id", ...savedForReadBack2[name] } + }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + 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") + // The survivor has to be resolvable: this deletion re-points both this view's pin + // and the shared selection at it, which requires its settings. + await provider.providerSettingsManager.saveConfig("keeper-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }) + 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, "getCurrentProfileName").mockResolvedValue("p") + 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, + ), + ).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("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. + // The restore keeps the original id: mode mappings and enhancementApiConfigId + // reference the record by id, so a regenerated id would leave them dangling. + expect(saveSpy).toHaveBeenCalledWith( + "old-profile", + expect.objectContaining({ apiKey: "old-key", id: "old-id" }), + ) + + await provider.dispose() + }) + + it("keeps the mutation queue owned by a mode switch past its timeout", async () => { + 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) + + 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) + + switchSpy.mockRestore() + vi.useRealTimers() + await provider.dispose() + }) + + it("re-posts state when a profile mutation commits after its caller timed out", async () => { + vi.useFakeTimers() + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The mutation body only waits here, so the only postStateToWebview call this + // test can observe is the one that reports the late commit. + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log") + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + // Bracket access: enqueueProviderProfileMutation is private, and the point of the + // test is the queue's own bookkeeping rather than any one public mutation. + const callerSettled = provider["enqueueProviderProfileMutation"](async () => gate).catch(() => undefined) + + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1000) + // The caller-side timeout is deliberate: it reports failure and keeps the queue + // waiting for the run. Nothing has been committed yet, so nothing to report. + expect(postSpy).not.toHaveBeenCalled() + + release() + await callerSettled + await vi.advanceTimersByTimeAsync(0) + + // The mutation landed: the profile list, the activation and the task handlers have + // all moved, so the views must be told instead of staying on the pre-mutation state + // under the error the caller already showed. + expect(postSpy).toHaveBeenCalledTimes(1) + expect(logSpy).toHaveBeenCalledWith( + "Provider profile mutation committed after its caller timed out; re-posting state", + ) + + vi.useRealTimers() + await provider.dispose() + }) + + it("does not resurrect a pruned sibling entry that the sibling cleared mid-window", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tomb-view") + // A full 50-entry map: the activation write evicts the oldest entry (the + // sibling's), the sibling then clears it mid-window, and the tombstone + // must outrank the tracked eviction during rollback. + const seeded: Record = { + "sibling-view": { currentApiConfigName: "sibling-profile", updatedAt: 0 }, + } + for (let i = 0; i < 49; i++) { + seeded["filler-" + i] = { currentApiConfigName: "filler-profile", updatedAt: i + 1 } + } + await provider.contextProxy.setValue("viewStates", seeded) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + // After the activation write evicted the oldest entry, the sibling + // intentionally clears it; the rollback must leave it absent. + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockImplementation(async () => { + await provider["clearPersistedViewState"]("sibling-view") + throw new Error("mode write failed") + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["sibling-view"]).toBeUndefined() + expect(restored["filler-0"]).toBeDefined() + await provider.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. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ name: "keeper-profile", ...keeperSettings }), + activateProfile: vi + .fn() + .mockResolvedValue({ name: "keeper-profile", id: "keeper-id", ...keeperSettings }), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + 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 deletion + // path passed a flat ProviderSettings object (a no-op for the buffer updater). + 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 surviving profile's settings must replace the deleted profile's stale buffer. + expect(provider.getValues().apiConfiguration).toEqual(keeperSettings) + await provider.dispose() + }) + + it("rebuilds the live task state and manager selection when the active profile is deleted", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-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. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockImplementation(({ name }: { name: string }) => + Promise.resolve( + name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + : { name: "keeper-profile", id: "keeper-id", ...keeperSettings }, + ), + ), + activateProfile: vi + .fn() + .mockResolvedValue({ name: "keeper-profile", id: "keeper-id", ...keeperSettings }), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const rebuildSpy = vi + .spyOn( + provider as unknown as { + updateTaskApiHandlerIfNeeded: ( + settings: ProviderSettings, + options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean }, + ) => void + }, + "updateTaskApiHandlerIfNeeded", + ) + .mockImplementation(() => {}) + const events: Array<{ name: string; provider?: string }> = [] + const listener = (config: { name: string; provider?: string }) => { + events.push(config) + } + provider.on(RooCodeEventName.ProviderProfileChanged, listener) + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "doomed-profile") + + await provider.deleteProviderProfile(doomedProfile) + + // The manager's own current-profile record and the current mode's profile + // mapping must follow the survivor, as they did when the old path activated it. + expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "keeper-profile" }) + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith( + (await provider.getState()).mode, + "keeper-id", + ) + // The live task's API handler must be rebuilt from the survivor's settings. + expect(rebuildSpy).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "keeper-key" }), { + forceRebuild: true, + }) + // Watchers must learn that the active profile was replaced. + expect(events).toEqual([{ name: "keeper-profile", provider: providerIdentifiers.anthropic }]) + provider.off(RooCodeEventName.ProviderProfileChanged, listener) + await provider.dispose() + }) + + it("lets an activation that starts mid-deletion run after the deletion settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-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.openai, + } + const doomedSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "doomed-key" } + const keeperSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "keeper-key" } + const otherSettings = { apiProvider: providerIdentifiers.openai, apiKey: "other-key" } + // The deletion is parked inside its manager delete so the activation below starts while the + // deletion still has writes outstanding. + let releaseDelete: (() => void) | undefined + const deleteGate = new Promise((resolve) => { + releaseDelete = resolve + }) + const deleteConfig = vi.fn(() => deleteGate.then(() => undefined)) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig, + getProfile: vi + .fn() + .mockImplementation(({ name }: { name: string }) => + Promise.resolve( + name === "doomed-profile" + ? { name: "doomed-profile", id: "doomed-id", ...doomedSettings } + : name === "keeper-profile" + ? { name: "keeper-profile", id: "keeper-id", ...keeperSettings } + : { name: "other-profile", id: "other-id", ...otherSettings }, + ), + ), + activateProfile: vi + .fn() + .mockImplementation(({ name }: { name: string }) => + Promise.resolve( + name === "keeper-profile" + ? { name: "keeper-profile", id: "keeper-id", ...keeperSettings } + : { name: "other-profile", id: "other-id", ...otherSettings }, + ), + ), + setModeConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + listConfig: vi.fn().mockResolvedValue([keeperProfile, otherProfile]), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile, otherProfile]) + await provider.setValue("currentApiConfigName", "doomed-profile") + + const deletion = provider.deleteProviderProfile(doomedProfile) + // Wait until the deletion is parked on the gated delete: the activation then starts with the + // deletion half-applied. + for (let attempt = 0; attempt < 40 && deleteConfig.mock.calls.length === 0; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(deleteConfig).toHaveBeenCalledTimes(1) + + const activation = provider.activateProviderProfile({ name: "other-profile" }) + releaseDelete?.() + + await Promise.all([deletion, activation]) + + // The deletion owned the mutation queue, so the activation ran after every deletion write and + // the later selection is the one that survives. Unqueued, the deletion resumes when the gate + // opens, activates the survivor it chose at entry, and overwrites the profile the user had + // just activated: the shared selection, this view's pin and the settings buffer all move + // back onto a profile nobody picked. + expect(provider.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("other-profile") + // The activation clears this view's buffered apiConfiguration so the profile record, not a + // stale overlay, backs the new selection. Queued, that clear is the deletion's overlay write + // followed by the activation"s; unqueued the deletion writes the doomed profile's settings + // back over it afterwards and this view keeps serving a deleted profile. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] + expect(persisted?.currentApiConfigName).toBe("other-profile") + expect((provider.getValue("listApiConfigMeta") ?? []).map(({ name }) => name)).toEqual([ + "keeper-profile", + "other-profile", + ]) + await provider.dispose() + }) + + it("throws a distinct error when the replacement activation fails after the deletion commits", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + // The record restore must not resurrect the deleted profile: a + // saveConfig call here would put the committed deletion back. + const saveSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double whose activation fails. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: saveSpy, + getProfile: vi.fn().mockImplementation(({ name }: { name: string }) => + Promise.resolve( + name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + : { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ), + ), + activateProfile: vi.fn().mockRejectedValue(new Error("disk full")), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "doomed-profile") + + // The deletion has already committed when the activation fails: the caller must learn + // that from a distinct error, not from the generic "could not delete" report. + const error = await provider.deleteProviderProfile(doomedProfile).catch((caught: unknown) => caught) + expect(error).toBeInstanceOf(ProfileDeletionActivationError) + expect((error as Error).message).toBe( + "The profile was deleted, but activating replacement profile 'keeper-profile' failed.", + ) + // The committed state is posted before the error surfaces. + expect(postSpy).toHaveBeenCalled() + // The committed deletion is not rolled back by the failed activation. + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledTimes(1) + // The outer write-group restore must not resurrect the deleted record. + expect(saveSpy).not.toHaveBeenCalled() + await provider.dispose() + }) + + it("does not rebuild the live task state when the deleted profile was not active", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + activateProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const rebuildSpy = vi + .spyOn( + provider as unknown as { + updateTaskApiHandlerIfNeeded: ( + settings: ProviderSettings, + options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean }, + ) => void + }, + "updateTaskApiHandlerIfNeeded", + ) + .mockImplementation(() => {}) + const events: Array<{ name: string; provider?: string }> = [] + const listener = (config: { name: string; provider?: string }) => { + events.push(config) + } + provider.on(RooCodeEventName.ProviderProfileChanged, listener) + await provider.contextProxy.setValue("listApiConfigMeta", [keeperProfile, otherProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + + await provider.deleteProviderProfile(otherProfile) + + // The active profile was untouched: no manager re-activation, no task rebuild, + // no replacement event. + expect(provider.providerSettingsManager.activateProfile).not.toHaveBeenCalled() + expect(rebuildSpy).not.toHaveBeenCalled() + expect(events).toHaveLength(0) + provider.off(RooCodeEventName.ProviderProfileChanged, listener) + 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. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + activateProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await provider.deleteProviderProfile(oldProfile) + + // The captured pin must still trigger the reconfiguration: the shared + // provider keys and the view-local buffer both take the surviving + // profile's settings, and the nested overlay is replaced wholesale so + // no key of the deleted profile survives. + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + await provider.dispose() + }) + + it("aborts the deletion when the surviving profile's settings cannot be resolved", 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 thirdProfile: ProviderSettingsEntry = { + name: "third-profile", + id: "third-id", + apiProvider: providerIdentifiers.openrouter, + } + // Three profiles so the deletion still has a surviving profile to activate. + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, thirdProfile]) + // The global selection names a surviving profile while this view is pinned to + // the profile being deleted, so the re-point would still happen. + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // This view is pinned to the profile being deleted and still holds its + // settings in the nested overlay. + 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. + provider.providerSettingsManager = { + // The deleted profile's snapshot resolves; the survivor's settings do not, + // which is the condition this test covers. + getProfile: vi.fn().mockImplementation(async (params: { name: string }) => { + if (params.name === "old-profile") { + return { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + } + throw new Error("profile unreadable") + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("old-id"), + } + + await expect(provider.deleteProviderProfile(oldProfile)).rejects.toThrow("profile unreadable") + + // The pin was never moved onto a profile whose settings could not be read, so the + // deleted profile's configuration cannot end up served under the survivor's name: + // the view still names the profile whose settings it actually holds. + expect(provider["viewLocalState"].currentApiConfigName).toBe("old-profile") + expect(provider["viewLocalState"].apiConfiguration?.openRouterApiKey).toBe("deleted-profile-secret") + // The compensation put back the record the manager delete had already committed, + // credentials included, so the profile is still resolvable. + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith( + "old-profile", + expect.objectContaining({ + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + }), + ) + // The pruned list entry was restored as well, and no shared write paired the + // survivor's name with the deleted profile's settings. + expect(provider.contextProxy.getValue("listApiConfigMeta")?.map(({ name }) => name)).toEqual([ + "keeper-profile", + "third-profile", + "old-profile", + ]) + await provider.dispose() + }) + + it("rebuilds the acting view's task handler when the deleted profile is the one it runs", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const runningProfile: ProviderSettingsEntry = { + name: "running-profile", + id: "running-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [runningProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "running-profile") + await provider.providerSettingsManager.saveConfig("running-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "running-key", + }) + await provider.providerSettingsManager.saveConfig("keeper-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const task = { + taskId: "running-task", + taskApiConfigName: "running-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "running-key" }, + setTaskApiConfigName: vi.fn(), + updateApiConfiguration: vi.fn(), + } + // Double assertion: the handler rebuild and the sticky re-point are the only two Task + // behaviours this path touches, so a full Task is neither available nor meaningful. + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + task as unknown as ReturnType, + ) + + await provider.deleteProviderProfile(runningProfile) + + // Re-pointing the pin and the shared store does not touch a live handler: without the + // rebuild the running conversation keeps sending the deleted profile's credentials. + expect(task.updateApiConfiguration).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "keeper-key" })) + expect(task.setTaskApiConfigName).toHaveBeenCalledWith("keeper-profile") + await provider.dispose() + }) + + it("restores a sibling view's pin when the deletion fails after re-pinning it", 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 { + const deletedProfile: ProviderSettingsEntry = { + name: "sibling-profile", + id: "sibling-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [deletedProfile, keeperProfile]) + // The global selection already names a survivor: only the sibling's own pin names + // the profile being deleted. + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + sibling["viewLocalState"].currentApiConfigName = "sibling-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sibling-secret", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("sibling-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (args) => { + const name = "name" in args ? args.name : "" + + if (name === "sibling-profile") { + return { + name: "sibling-profile", + id: "sibling-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sibling-secret", + } + } + return { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + } + }) + // The re-pin of the sibling is already committed by the time this write fails. + vi.spyOn(provider, "postStateToWebview").mockRejectedValue(new Error("post state failed")) + + await expect(provider.deleteProviderProfile(deletedProfile)).rejects.toThrow("post state failed") + await ClineProvider["persistedViewStateWriteQueue"] + + // The manager record is back, so the sibling goes back to the pin it had; leaving + // it on the survivor would be a state no caller asked for. + expect(sibling.pinnedProfileName).toBe("sibling-profile") + expect(sibling["viewLocalState"].apiConfiguration?.openRouterApiKey).toBe("sibling-secret") + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("keeps a sibling's newer durable profile pin when the deletion rollback runs", 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 { + const deletedProfile: ProviderSettingsEntry = { + name: "gone-profile", + id: "gone-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [deletedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + await sibling["setViewStateId"]("stable-editor-view") + await sibling.saveViewState("currentApiConfigName", "gone-profile") + // A closed view's entry, which nobody will rewrite during the deletion. + await ClineProvider["persistedViewStateWriteQueue"] + const snapshot = provider["getPersistedViewStates"]({ fresh: true }) + await provider.contextProxy.setValue("viewStates", { + ...snapshot, + "closed-view": { currentApiConfigName: "gone-profile" }, + }) + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("gone-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (args) => { + const name = "name" in args ? args.name : "" + + if (name === "keeper-profile") { + return { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + } + } + return { + name: "gone-profile", + id: "gone-id", + apiProvider: providerIdentifiers.openrouter, + } + }) + // The sibling commits its own choice after the deletion re-pointed its entry, + // and then the deletion fails. + vi.spyOn(provider, "postStateToWebview").mockImplementation(async () => { + await sibling.saveViewState("currentApiConfigName", "third-profile") + throw new Error("post state failed") + }) + + await expect(provider.deleteProviderProfile(deletedProfile)).rejects.toThrow("post state failed") + await ClineProvider["persistedViewStateWriteQueue"] + + const states = provider["getPersistedViewStates"]({ fresh: true }) + // The sibling's newer write survives: the rollback only undoes pins this deletion + // actually wrote, instead of resurrecting a deleted profile's name. + expect(states["stable-editor-view"]?.currentApiConfigName).toBe("third-profile") + // The entry nobody touched since the snapshot is restored to the deleted profile, + // because the manager record is back. + expect(states["closed-view"]?.currentApiConfigName).toBe("gone-profile") + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + it("does not overwrite a newer save from another view when the upsert rolls back", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("p-id") + // getProfile is called three times on this path: the pre-save snapshot, the post-save + // read-back (which becomes the record this mutation wrote), and the rollback guard. + // By the guard's turn another view has saved the same profile. + vi.spyOn(provider.providerSettingsManager, "getProfile") + .mockResolvedValueOnce({ + name: "p", + id: "p-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + }) + .mockResolvedValueOnce({ + name: "p", + id: "p-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "raw-key", + }) + .mockResolvedValue({ + name: "p", + id: "p-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "other-view-key", + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("p") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + // The activation write after the record save fails, so the mutation has to compensate. + vi.spyOn(provider["contextProxy"], "setValue").mockRejectedValue(new Error("shared write failed")) + + await expect( + provider["persistProviderProfile"]( + "p", + { apiProvider: providerIdentifiers.anthropic, apiKey: "raw-key" }, + true, + ), + ).rejects.toThrow() + + // Only this mutation's own save; the rollback must not replay the prior record over the + // newer save from the other view. + expect(saveSpy).toHaveBeenCalledTimes(1) + await provider.dispose() + }) + it("does not undo a newer shared write when the per-view write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.setValue("mode", "architect") + // Another view commits a newer value for the same key while this view's per-view + // write is still in flight. + // @ts-ignore - Spy on the private per-view write to fail it after the newer write. + vi.spyOn(provider, "_saveViewLocalStateFromMutation").mockImplementation(async () => { + await provider.contextProxy.setValue("mode", "code") + throw new Error("per-view write failed") + }) + + await expect(provider.setValue("mode", "debug")).rejects.toThrow("per-view write failed") + + // The rollback only reverts a key whose shared value is still the one this call + // wrote: the newer write from the other view survives instead of being clobbered by + // the pre-mutation snapshot. + expect(provider.contextProxy.getValue("mode")).toBe("code") + await provider.dispose() + }) + + it("clears the view-local overlay of every live webview on reset", 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"].mode = "architect" + sibling["viewLocalState"].currentApiConfigName = "sibling-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sibling-secret", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "removeClineFromStack").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "resetAllConfigs").mockResolvedValue(undefined) + vi.spyOn(provider.customModesManager, "resetCustomModes").mockResolvedValue(undefined) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce(t("common:answers.yes") as never) + + await provider.resetState() + + // A reset must not leave another live webview serving its pre-reset mode and + // profile out of its own overlay. + expect(sibling["viewLocalState"]).toEqual({}) + expect(sibling.pinnedProfileName).toBeUndefined() + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + it("keeps resetting after a sibling webview refuses the refreshed state", async () => { + // Review thread 4476213612: the sibling refresh post was the one step in resetState that could + // abort the reset. Its neighbour in the same loop already logs and continues when the durable + // clear rejects (and the next mutation retries that clear), so a webview that has already gone + // away must not leave the profile, custom-mode and task-stack resets unrun for every other view. + 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"].mode = "architect" + sibling["viewLocalState"].currentApiConfigName = "sibling-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "sibling-secret", + } + const siblingPostSpy = vi + .spyOn(sibling, "postStateToWebview") + .mockRejectedValue(new Error("webview is gone")) + const providerPostSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const removeSpy = vi.spyOn(provider, "removeClineFromStack").mockResolvedValue(undefined) + const configsSpy = vi + .spyOn(provider.providerSettingsManager, "resetAllConfigs") + .mockResolvedValue(undefined) + const modesSpy = vi.spyOn(provider.customModesManager, "resetCustomModes").mockResolvedValue(undefined) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce(t("common:answers.yes") as never) + + await expect(provider.resetState()).resolves.toBeUndefined() + + // The refresh was attempted, the sibling overlay still went, and everything after the + // failed post still ran. + expect(siblingPostSpy).toHaveBeenCalled() + expect(sibling["viewLocalState"]).toEqual({}) + expect(sibling.pinnedProfileName).toBeUndefined() + expect(configsSpy).toHaveBeenCalled() + expect(modesSpy).toHaveBeenCalled() + expect(removeSpy).toHaveBeenCalled() + expect(providerPostSpy).toHaveBeenCalled() + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + it("clears only the live views whose per-view mode names the deleted slug", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const pinned = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const other = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const viewStates = () => mockContext.globalState.get("viewStates") as Record + try { + await pinned["setViewStateId"]("pinned-view") + await other["setViewStateId"]("other-view") + // Both views pin a mode in their own overlay and in their own durable entry. + await pinned["_saveViewLocalStateFromMutation"]({ mode: "deleted-mode" }) + await other["_saveViewLocalStateFromMutation"]({ mode: "another-mode" }) + vi.spyOn(pinned, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(other, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.clearViewLocalModeForDeletedSlug("deleted-mode") + + // The view that pinned the deleted slug loses the override in both layers, so its + // getState() and any task it creates fall back to the default mode. + expect(pinned["viewLocalState"].mode).toBeUndefined() + expect(viewStates()["pinned-view"]?.mode).toBeUndefined() + expect(pinned.postStateToWebview).toHaveBeenCalledTimes(1) + // A view serving another mode keeps its own override and is not disturbed. + expect(other["viewLocalState"].mode).toBe("another-mode") + expect(viewStates()["other-view"]?.mode).toBe("another-mode") + expect(other.postStateToWebview).not.toHaveBeenCalled() + // The acting view is the caller's business: its reset goes through setValue. + expect(provider.postStateToWebview).not.toHaveBeenCalled() + } finally { + await pinned.dispose() + await other.dispose() + 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("refuses the shared settings write when a sibling's pinned profile cannot be read", 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 { + const targetProfile: ProviderSettingsEntry = { + name: "target-profile", + id: "target-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [targetProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // The sibling is pinned to a profile the activation does not touch. + sibling["viewLocalState"].currentApiConfigName = "keeper-profile" + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue(targetProfile) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([targetProfile]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (args) => { + if ("name" in args && args.name === "keeper-profile") { + throw new Error("pinned profile unreadable") + } + return { name: "target-profile", id: "target-id", apiProvider: providerIdentifiers.openrouter } + }) + const sharedSettingsWrite = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await expect(provider.activateProviderProfile({ name: "target-profile" })).rejects.toThrow( + "pinned profile unreadable", + ) + + // Proceeding would replace the shared store while the sibling still names its + // own profile in getState(), so its tasks would run the activated profile's + // credentials under the wrong name. Only the compensation may have written the + // prior settings back. + expect(sharedSettingsWrite).not.toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.openrouter }), + ) + expect(sibling["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("keeps tolerating a sibling pin that names an already deleted profile", 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 { + const targetProfile: ProviderSettingsEntry = { + name: "target-profile", + id: "target-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [targetProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + sibling["viewLocalState"].currentApiConfigName = "deleted-profile" + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue(targetProfile) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([targetProfile]) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(false) + const getProfileSpy = vi + .spyOn(provider.providerSettingsManager, "getProfile") + .mockImplementation(async () => { + throw new ProviderSettingsNotFoundError("Config with name 'deleted-profile' not found") + }) + + await provider.activateProviderProfile({ name: "target-profile" }) + + // A gone profile has no correct snapshot; failing here would let one stale view + // block every later activation in the host. + expect(getProfileSpy).toHaveBeenCalledWith({ name: "deleted-profile" }) + expect(provider.getValues().currentApiConfigName).toBe("target-profile") + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("refreshes another live view's buffered settings when the profile it pins is reactivated", async () => { + // The affected view loads the profile first, so its nested overlay carries that + // profile's settings; the mutating view is a different ClineProvider instance. + const viewer = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await viewer["setViewStateId"]("shared-view") + await viewer.saveViewState("currentApiConfigName", "shared-profile") + await viewer.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-viewer-key", + }) + const viewerPostSpy = vi.spyOn(viewer, "postStateToWebview").mockResolvedValue(undefined) + + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const reactivated = { + name: "shared-profile", + id: "shared-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + } + // @ts-ignore - Replace providerSettingsManager with a test double. + writer.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(reactivated), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(reactivated), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(writer, "postStateToWebview").mockResolvedValue(undefined) + + await writer.activateProviderProfile({ name: "shared-profile" }) + + // The other view's buffer must serve the reactivated profile's settings, and its + // webview must be re-posted; otherwise that view keeps showing (and running on) + // the key it buffered before the upsert. + expect(viewer["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + const viewerState = await viewer.getState({ includeTaskHistory: false }) + expect(viewerState.apiConfiguration.apiKey).toBe("fresh-key") + expect(viewerPostSpy).toHaveBeenCalled() + await viewer.dispose() + await writer.dispose() + }) + + it("re-pins another live view and persists the replacement in its durable view state when its profile is deleted", async () => { + const viewer = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await viewer["setViewStateId"]("shared-view") + await viewer.saveViewState("currentApiConfigName", "old-profile") + await viewer.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "deleted-profile-key", + }) + const viewerPostSpy = vi.spyOn(viewer, "postStateToWebview").mockResolvedValue(undefined) + + const writer = 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 writer.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await writer.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(writer, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + writer.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }), + activateProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + + await writer.deleteProviderProfile(oldProfile) + + // The other view's in-memory buffer must be re-pinned with a matching + // configuration instead of the deleted profile's name and secret. + expect(viewer["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(viewer["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }) + // The durable entry must carry the replacement too, or a reload restores the + // deleted profile's name on top of the surviving shared state. + const durableViewStates = mockContext.globalState.get("viewStates") + expect(durableViewStates?.["shared-view"]?.currentApiConfigName).toBe("keeper-profile") + expect(viewerPostSpy).toHaveBeenCalled() + await viewer.dispose() + await writer.dispose() + }) + + it("restores the deleted profile's settings when the profile-list write fails after the store commit", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const doomedSettings = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "doomed-secret", + } + const saveConfigSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue(doomedSettings), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: saveConfigSpy, + } + // The settings store commit succeeds; the profile-list write then fails. + const setValueSpy = vi + .spyOn(provider.contextProxy, "setValue") + .mockRejectedValue(new Error("storage write failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("storage write failed") + setValueSpy.mockRestore() + + // The settings were already removed, so the compensation must put them back with + // their original id: the durable list still names this profile, and without the + // settings a later selection or load could not recover them. + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", apiKey: "doomed-secret" }), + ) + await provider.dispose() + }) + + it("aborts the deletion before the settings store is touched when the pre-delete read fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const deleteConfigSpy = vi.fn().mockResolvedValue(undefined) + const saveConfigSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + // A transient read failure, not the typed not-found: the settings may still exist, + // so a later failure could not be compensated and the deletion must not start. + getProfile: vi.fn().mockRejectedValue(new Error("secret storage unavailable")), + deleteConfig: deleteConfigSpy, + saveConfig: saveConfigSpy, + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("secret storage unavailable") + + // Nothing destructive happened, so the same call can succeed once storage recovers. + // Continuing here would have removed the settings with no snapshot to restore, while + // the durable profile list still named the profile. + expect(deleteConfigSpy).not.toHaveBeenCalled() + expect(saveConfigSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + await provider.dispose() + }) + + it("restores the profile list, shared selection, and view pin when a later write fails after the list write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + // This view is pinned to the doomed profile and has its settings loaded, so the + // deletion also rewrites this view's pin and nested overlay. + provider["viewLocalState"].currentApiConfigName = "doomed-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const saveConfigSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(({ name }: { name: string }) => + name === "doomed-profile" + ? Promise.resolve({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + : Promise.resolve({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + }), + ), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: saveConfigSpy, + } + + // The settings commit and the profile-list write both landed; this later shared write + // is what fails, so every store already changed has to be put back. + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockRejectedValue(new Error("provider settings write failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow( + "provider settings write failed", + ) + setProviderSettingsSpy.mockRestore() + + // The settings are back under their original id... + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", openRouterApiKey: "doomed-secret" }), + ) + // ...and so are the profile list, the shared selection, and this view's own pin: + // restoring only the settings would leave the persisted metadata pointing at the + // surviving profile while the deleted profile's settings exist again. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual( + expect.arrayContaining([doomedProfile, keeperProfile]), + ) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toHaveLength(2) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("doomed-profile") + // The landed compensation restores the manager record, the profile list, the shared selection, + // the OTHER views' pins and this view's own pin and overlay: a deletion the caller was told + // failed must not leave this view switched to the survivor, in memory or on disk. + // (Before the round that added that restore the code produced the opposite - buffer and durable + // entry stayed on the survivor - and these lines documented that behaviour rather than promising + // it; the review round on head 48198939b7 turned the description into the promise.) + expect(provider["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + // The durable entry is what a reload reads, so it goes back with the live buffer. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + expect(persisted.currentApiConfigName).toBe("doomed-profile") + await provider.dispose() + }) + + it("serializes a deletion with a concurrent upsert so the rollback cannot restore a stale profile list", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // One log across both mutations: the assertions below compare when the upsert reached its + // save against when the deletion settled. + const events: string[] = [] + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockImplementation((name: string) => { + // Filtered by profile name: the deletion's own restore calls this with the deleted + // profile, so a bare index into this mock says nothing about which mutation wrote. + events.push("save:" + name) + return Promise.resolve("doomed-id") + }), + listConfig: vi.fn().mockResolvedValue(["doomed-profile", "keeper-profile", "new-profile"]), + } + + // The deletion parks in this write, so the concurrent upsert gets its chance + // while the deletion holds the profile stores half-updated. + let parked = false + let releaseFailingWrite!: () => void + const failingWrite = new Promise((resolve) => { + releaseFailingWrite = resolve + }) + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockImplementation(async () => { + parked = true + await failingWrite + throw new Error("provider settings write failed") + }) + const deletion = provider.deleteProviderProfile(doomedProfile) + + while (!parked) { + await new Promise((resolve) => setImmediate(resolve)) + } + // The upsert starts while the deletion is mid-flight. With the queue it waits for the + // deletion to settle; unqueued it runs straight through the deletion half-applied. + const upsert = provider.upsertProviderProfile( + "new-profile", + { apiProvider: providerIdentifiers.anthropic }, + false, + ) + releaseFailingWrite() + + await expect(deletion).rejects.toThrow("provider settings write failed") + await upsert + setProviderSettingsSpy.mockRestore() + + // The deletion's restore call is the marker: it can only happen after the parked write + // settled and the deletion entered its catch. An upsert that reached its save afterwards + // never overlapped the deletion; unserialized it saves while the deletion is still + // half-applied, which is what the ordering below rejects. + expect(JSON.stringify(provider.contextProxy.getValue("listApiConfigMeta"))).toContain("new-profile") + const upsertSave = events.indexOf("save:new-profile") + const deletionRestore = events.indexOf("save:doomed-profile") + expect(upsertSave).toBeGreaterThan(-1) + expect(deletionRestore).toBeGreaterThan(-1) + expect(upsertSave).toBeGreaterThan(deletionRestore) + await provider.dispose() + }) + + it("refreshes another live view pinned to a profile that was just upserted", async () => { + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("sibling-refresh-view") + // The sibling is pinned to the profile with its settings loaded into the buffer. + await sibling.saveViewState("currentApiConfigName", "shared-profile") + await sibling.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + const siblingPostSpy = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const sharedProfile: ProviderSettingsEntry = { + name: "shared-profile", + id: "shared-id", + apiProvider: providerIdentifiers.anthropic, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + updater.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("shared-id"), + listConfig: vi.fn().mockResolvedValue([sharedProfile]), + setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue({ ...sharedProfile, apiKey: "fresh-key" }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(updater, "postStateToWebview").mockResolvedValue(undefined) + await updater.contextProxy.setValue("listApiConfigMeta", [sharedProfile]) + + await updater.upsertProviderProfile("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + + // The other live view must not keep serving the pre-update settings: its buffer + // overlay is what getState() merges over the shared store, so a stale overlay here + // means the sibling keeps sending the old key after the profile was edited. + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + expect(siblingPostSpy).toHaveBeenCalled() + await updater.dispose() + await sibling.dispose() + }) + + it("restores an affected sibling view's own pin when the deletion re-pin write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A context of its own for the sibling: same backing store, but this view's durable + // viewStates write is the one that fails. Failing ContextProxy#setValue instead would + // skip updateGlobalState entirely, so the phantom cache entry the rollback repairs + // would never exist and the test would prove nothing. + const siblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + // Reject exactly the re-pin write - the map that would name the survivor for this + // view - so the setup writes and the rollback write still land. + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["sibling-pin-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const sibling = new ClineProvider( + siblingContext, + mockOutputChannel, + "editor", + new ContextProxy(siblingContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("sibling-pin-view") + await sibling.saveViewState("currentApiConfigName", "doomed-profile") + await sibling.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling pin write failed") + + // The sibling keeps its own pin and overlay: a rolled-back deletion must not leave it + // pinned to the surviving profile, or a reload would re-pin that view to a profile the + // user never chose for it. + expect(sibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + // The ContextProxy cache is where the phantom lives: updateGlobalState fills it before + // awaiting the durable write, so a failed re-pin leaves the cache naming the survivor + // unless the rollback writes the previous pin back. + const cached = sibling["getPersistedViewStates"]()["sibling-pin-view"] ?? {} + expect(cached.currentApiConfigName).toBe("doomed-profile") + const persisted = sibling["getPersistedViewStates"]({ fresh: true })["sibling-pin-view"] ?? {} + expect(persisted.currentApiConfigName).toBe("doomed-profile") + // The deleting view rolled its own stores back too. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual( + expect.arrayContaining([doomedProfile, keeperProfile]), + ) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toHaveLength(2) + await provider.dispose() + await sibling.dispose() + }) + + it("rolls back the sibling views whose re-pin write succeeded when another sibling fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // healthySibling + // This sibling's writes all land: it is the one that has to be undone when the OTHER + // sibling's re-pin fails. + const healthySibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await healthySibling["setViewStateId"]("healthy-sibling-view") + await healthySibling.saveViewState("currentApiConfigName", "doomed-profile") + // failingSibling + const failingSiblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["failing-sibling-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const failingSibling = new ClineProvider( + failingSiblingContext, + mockOutputChannel, + "editor", + new ContextProxy(failingSiblingContext), + new WebviewFocusTracker(), + ) + await failingSibling["setViewStateId"]("failing-sibling-view") + await failingSibling.saveViewState("currentApiConfigName", "doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling pin write failed") + + // The sibling whose write LANDED must be put back too: the deletion as a whole did not + // happen, so leaving this view pinned to the survivor would name a profile that is + // still in the list but was never chosen for this view. + expect(healthySibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + const cachedHealthy = healthySibling["getPersistedViewStates"]()["healthy-sibling-view"] ?? {} + expect(cachedHealthy.currentApiConfigName).toBe("doomed-profile") + expect(failingSibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual( + expect.arrayContaining([doomedProfile, keeperProfile]), + ) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toHaveLength(2) + await provider.dispose() + await healthySibling.dispose() + await failingSibling.dispose() + }) + + it("does not rebuild the healthy sibling's task handler when another sibling's re-pin fails", async () => { + // A rebuilt API handler is the one part of a re-pin that no compensation can undo. The sibling whose + // durable pin write lands must keep its conversation on the deleted profile until every sibling has + // landed: rebuilding inside the settled loop leaves this task on the survivor's settings while its + // own pin is rolled back to the profile the deletion failed to remove. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const healthySibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await healthySibling["setViewStateId"]("healthy-handler-view") + await healthySibling.saveViewState("currentApiConfigName", "doomed-profile") + const healthyTask = { + taskId: "healthy-handler-task", + taskApiConfigName: "doomed-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "deepseek/deepseek-coder", + }, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn((name: string) => { + healthyTask.taskApiConfigName = name + }), + } + // Structural double: the re-pin only touches the sticky name and the API handler, so a minimal + // task is enough and keeps the assertion on the handler instead of on a Task internals list. + vi.spyOn(healthySibling, "getCurrentTask").mockReturnValue( + healthyTask as unknown as ReturnType, + ) + const failingSiblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["failing-handler-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const failingSibling = new ClineProvider( + failingSiblingContext, + mockOutputChannel, + "editor", + new ContextProxy(failingSiblingContext), + new WebviewFocusTracker(), + ) + await failingSibling["setViewStateId"]("failing-handler-view") + await failingSibling.saveViewState("currentApiConfigName", "doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(healthySibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(failingSibling, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + // Name-aware so the compensation restores the deleted profile's own record. + getProfile: vi.fn(async ({ name }: { name: string }) => + name === "doomed-profile" + ? { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter } + : { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling pin write failed") + + // The sibling that landed has to go back to the profile its conversation still runs under, + // handler included: a survivor-built handler cannot be compensated out of a running task. + expect(healthyTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(healthyTask.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "deepseek/deepseek-coder", + }) + expect(healthyTask.setTaskApiConfigName.mock.calls.at(-1)?.[0]).toBe("doomed-profile") + expect(healthySibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + await ClineProvider["persistedViewStateWriteQueue"] + await provider.dispose() + await healthySibling.dispose() + await failingSibling.dispose() + }) + it("rejects the deletion when a sibling's task history cannot follow the re-pin", async () => { + // The sibling conversation is part of the state the deletion rewrites: if its history entry + // and sticky name keep naming the deleted profile, the next request from that conversation + // still sends the deleted profile's configuration while the deletion reports success. The + // history write therefore has to fail the deletion so the compensation runs. + 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 { + await sibling["setViewStateId"]("history-fail-sibling-view") + await sibling.saveViewState("currentApiConfigName", "doomed-profile") + const siblingTask = { + taskId: "history-fail-task", + taskApiConfigName: "doomed-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn((name: string) => { + siblingTask.taskApiConfigName = name + }), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + // The re-pin only writes history when an entry exists, so seed one under the profile + // the deletion removes. + sibling.taskHistoryStore["cache"].set(siblingTask.taskId, { + id: siblingTask.taskId, + task: "sticky sibling", + ts: Date.now(), + number: 1, + mode: "code", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + apiConfigName: "doomed-profile", + }) + vi.spyOn(sibling, "updateTaskHistory").mockRejectedValue(new Error("task history write failed")) + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + const saveConfig = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + // Name-aware so the compensation restores the deleted profile's OWN record, not the + // survivor's: the assertion below has to be able to tell those two apart. + getProfile: vi.fn(async ({ name }: { name: string }) => + name === "doomed-profile" + ? { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter } + : { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig, + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("task history write failed") + + // Propagating the failure is what makes the rollback observable: the committed manager + // delete is put back, and the sibling goes back to the pin its task still runs under. + expect(saveConfig).toHaveBeenCalledWith("doomed-profile", expect.objectContaining({ id: "doomed-id" })) + expect(sibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(siblingTask.setTaskApiConfigName.mock.calls.at(-1)?.[0]).toBe("doomed-profile") + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("restores the acting view's own pin and overlay when its own strict sticky write fails", async () => { + // Crosses two findings on purpose. The acting view moves its own pin and overlay onto the survivor + // before the strict task-history write runs, and a rebuilt API handler cannot be rolled back: so the + // sticky write has to come first, and the compensation has to put this view's own pin and overlay + // back as well as the shared stores it already restores. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await provider["setViewStateId"]("acting-crossover-view") + await provider.saveViewState("currentApiConfigName", "doomed-profile") + const actingTask = { + taskId: "acting-crossover-task", + taskApiConfigName: "doomed-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "deepseek/deepseek-coder", + }, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn((name: string) => { + actingTask.taskApiConfigName = name + }), + } + // Structural double: the deletion path only touches the sticky name and the API handler here, + // so a minimal task keeps the assertion on what the write group actually mutates. + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + actingTask as unknown as ReturnType, + ) + provider.taskHistoryStore["cache"].set(actingTask.taskId, { + id: actingTask.taskId, + task: "crossover acting", + ts: Date.now(), + number: 1, + mode: "code", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + apiConfigName: "doomed-profile", + }) + vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("acting history write failed")) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn(async ({ name }: { name: string }) => + name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + } + : { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow( + "acting history write failed", + ) + + // The handler rebuild must not have run: it is the irreversible half of the write group, and the + // deletion is being undone. + expect(actingTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(actingTask.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "deepseek/deepseek-coder", + }) + // This view's own pin and overlay went onto the survivor inside the same write group, and the + // compensation restores the record, the list, the shared selection and the OTHER views' pins - + // so it has to restore this view's own too, live and durable, or a failed deletion still leaves + // the user looking at the survivor also after a reload. + expect(provider["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + await ClineProvider["persistedViewStateWriteQueue"] + const persisted = provider["getPersistedViewStates"]({ fresh: true })[provider["viewStateId"]] ?? {} + expect(persisted.currentApiConfigName).toBe("doomed-profile") + expect(actingTask.setTaskApiConfigName.mock.calls.at(-1)?.[0]).toBe("doomed-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + expect(ClineProvider.getAllInstances()).toContain(provider) + } finally { + await provider.dispose() + } + }) + it("rejects the deletion when the acting task's history entry cannot be re-pointed", async () => { + // Same contract on the deleting view itself: the write group rebuilds the conversation and + // moves its sticky profile name; if the durable half of that fails, the deletion has to + // fail with it instead of leaving the running task on the profile that is being deleted. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await provider["setViewStateId"]("acting-history-view") + await provider.saveViewState("currentApiConfigName", "doomed-profile") + const actingTask = { + taskId: "acting-history-task", + taskApiConfigName: "doomed-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn((name: string) => { + actingTask.taskApiConfigName = name + }), + } + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + actingTask as unknown as ReturnType, + ) + provider.taskHistoryStore["cache"].set(actingTask.taskId, { + id: actingTask.taskId, + task: "sticky acting", + ts: Date.now(), + number: 1, + mode: "code", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + apiConfigName: "doomed-profile", + }) + vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("acting history write failed")) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "doomed-profile") + const saveConfig = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + // Name-aware so the compensation restores the deleted profile's OWN record, not the + // survivor's: the assertion below has to be able to tell those two apart. + getProfile: vi.fn(async ({ name }: { name: string }) => + name === "doomed-profile" + ? { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter } + : { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig, + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow( + "acting history write failed", + ) + + expect(saveConfig).toHaveBeenCalledWith("doomed-profile", expect.objectContaining({ id: "doomed-id" })) + expect(actingTask.setTaskApiConfigName.mock.calls.at(-1)?.[0]).toBe("doomed-profile") + // The rejected write never landed, so the entry still names the deleted profile; the + // rollback attempt below also runs through the rejected mock and is logged, not thrown. + expect(provider["getTaskHistoryItem"](actingTask.taskId)).toMatchObject({ + apiConfigName: "doomed-profile", + }) + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await provider.dispose() + } + }) + + it("surfaces sibling restore failures when the compensating write also fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // healthySibling: its forward re-pin lands, but its compensating restore + // write is rejected, so the deletion must report the incomplete + // compensation instead of only the original re-pin failure. + let sawKeeperWrite = false + const healthySiblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + const states = value as Record | undefined + if (key === "viewStates") { + if (states?.["healthy-sibling-view"]?.currentApiConfigName === "keeper-profile") { + sawKeeperWrite = true + } else if ( + sawKeeperWrite && + states?.["healthy-sibling-view"]?.currentApiConfigName === "doomed-profile" + ) { + return Promise.reject(new Error("sibling restore failed")) + } + } + return mockContext.globalState.update(key, value) + }, + }, + } + const healthySibling = new ClineProvider( + healthySiblingContext, + mockOutputChannel, + "editor", + new ContextProxy(healthySiblingContext), + new WebviewFocusTracker(), + ) + await healthySibling["setViewStateId"]("healthy-sibling-view") + await healthySibling.saveViewState("currentApiConfigName", "doomed-profile") + const failingSiblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["failing-sibling-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const failingSibling = new ClineProvider( + failingSiblingContext, + mockOutputChannel, + "editor", + new ContextProxy(failingSiblingContext), + new WebviewFocusTracker(), + ) + await failingSibling["setViewStateId"]("failing-sibling-view") + await failingSibling.saveViewState("currentApiConfigName", "doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + const error = await provider.deleteProviderProfile(doomedProfile).then( + () => undefined, + (error: unknown) => error as Error, + ) + // The surfaced error names both the original failure and the restore that + // could not undo it, so an incomplete compensation is never invisible. + expect(error?.message).toContain("sibling pin write failed") + expect(error?.message).toContain("sibling restore also failed") + expect(error?.message).toContain("sibling restore failed") + await provider.dispose() + await healthySibling.dispose() + await failingSibling.dispose() + }) + + it("refreshes a live sibling but leaves one that has started disposing", async () => { + // dispose() unregisters at its start, so a disposed sibling is normally not enumerated at all; + // this pins the second line of defence the row asks for. The live sibling is the positive + // control: it proves the refresh reaches siblings at all, so the disposing assertion cannot + // pass by accident. + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const live = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const disposing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await live["setViewStateId"]("live-sibling-view") + await live.saveViewState("currentApiConfigName", "shared-profile") + await live.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + await disposing["setViewStateId"]("disposing-refresh-view") + await disposing.saveViewState("currentApiConfigName", "shared-profile") + await disposing.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + const livePost = vi.spyOn(live, "postStateToWebview").mockResolvedValue(undefined) + const disposingPost = vi.spyOn(disposing, "postStateToWebview").mockResolvedValue(undefined) + disposing["_disposed"] = true + expect(live["pinnedProfileName"]).toBe("shared-profile") + expect(disposing["pinnedProfileName"]).toBe("shared-profile") + + await updater["refreshViewLocalStateForUpdatedProfile"]("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + + // Positive control: the live sibling's overlay is replaced and it is posted to. + expect(live["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + expect(livePost).toHaveBeenCalledTimes(1) + // The disposing sibling keeps its own overlay and receives no post. + expect(disposing["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + expect(disposingPost).not.toHaveBeenCalled() + + // Reset the seeded flag so dispose() can unregister this instance again. + disposing["_disposed"] = false + await updater.dispose() + await live.dispose() + await disposing.dispose() + }) + + it("stops before posting to a sibling that starts disposing mid-refresh", async () => { + // Reachable window: the affected set is enumerated before any await, so a sibling can begin + // disposal after the filter ran and before its post. The re-check between the persistence and + // the post is what keeps the post off a webview that is already gone. + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("midflight-refresh-view") + await sibling.saveViewState("currentApiConfigName", "shared-profile") + const siblingPostSpy = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + // Double assertion: the method is private and vi.spyOn needs a property holder; house idiom in + // this spec (see the teardown spies elsewhere in this file). + const teardown = sibling as unknown as { + _saveViewLocalStateFromMutation: (values: never) => Promise + } + const persist = vi.spyOn(teardown, "_saveViewLocalStateFromMutation").mockImplementation(async () => { + // Disposal starts while this sibling's own write is in flight. + sibling["_disposed"] = true + }) + + await updater["refreshViewLocalStateForUpdatedProfile"]("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + }) + + expect(persist).toHaveBeenCalledTimes(1) + expect(siblingPostSpy).not.toHaveBeenCalled() + sibling["_disposed"] = false + await updater.dispose() + await sibling.dispose() + }) + + it("defers task-handler rebuilds until every sibling refresh has landed", async () => { + // A rebuilt task handler cannot be rolled back: when a sibling refresh + // fails, the buffers roll back but a running conversation would keep the + // rebuilt (new-settings) handler. The rebuild must therefore wait until + // every sibling write and post has succeeded. + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const healthy = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const failing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await healthy["setViewStateId"]("defer-healthy-view") + await healthy.saveViewState("currentApiConfigName", "shared-profile") + await failing["setViewStateId"]("defer-failing-view") + await failing.saveViewState("currentApiConfigName", "shared-profile") + // Double assertion: the method is private and vi.spyOn needs a property + // holder; house idiom in this spec (see the rebuild spies elsewhere). + const rebuildSpy = vi + .spyOn( + healthy as unknown as { + updateTaskApiHandlerIfNeeded: ( + settings: ProviderSettings, + options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean }, + ) => void + }, + "updateTaskApiHandlerIfNeeded", + ) + .mockImplementation(() => {}) + vi.spyOn(healthy, "getCurrentTask").mockReturnValue({ + taskApiConfigName: "shared-profile", + } as unknown as ReturnType) + vi.spyOn(healthy, "postStateToWebview").mockResolvedValue(undefined) + const failingPost = vi + .spyOn(failing, "postStateToWebview") + .mockRejectedValue(new Error("sibling post down")) + + await expect( + updater["refreshViewLocalStateForUpdatedProfile"]("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }), + ).rejects.toThrow("sibling post down") + + // The healthy sibling's buffer rolled back, and its running task kept its + // original handler: the rebuild never ran for the failed refresh. + expect(rebuildSpy).not.toHaveBeenCalled() + + // Positive control: once every sibling lands, the rebuild runs. + failingPost.mockResolvedValue(undefined) + await updater["refreshViewLocalStateForUpdatedProfile"]("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + expect(rebuildSpy).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "fresh-key" }), { + forceRebuild: true, + }) + + await updater.dispose() + await healthy.dispose() + await failing.dispose() + }) + + it("re-pins a live sibling but leaves one that has started disposing", async () => { + // Same second line of defence on the deletion path, with the live sibling as the positive + // control: it proves the deletion really reaches the sibling re-pin set, so the disposing + // assertion is a guard and not an accident of setup. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const live = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const disposing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await live["setViewStateId"]("live-repin-view") + await live.saveViewState("currentApiConfigName", "doomed-profile") + await disposing["setViewStateId"]("disposing-repin-view") + await disposing.saveViewState("currentApiConfigName", "doomed-profile") + const livePost = vi.spyOn(live, "postStateToWebview").mockResolvedValue(undefined) + const disposingPost = vi.spyOn(disposing, "postStateToWebview").mockResolvedValue(undefined) + disposing["_disposed"] = true + expect(live["pinnedProfileName"]).toBe("doomed-profile") + expect(disposing["pinnedProfileName"]).toBe("doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await provider.deleteProviderProfile(doomedProfile) + + // Positive control: the live sibling is re-pinned onto the survivor and posted to. + expect(live["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(livePost).toHaveBeenCalled() + // The disposing sibling is left on its own pin with no post. + expect(disposing["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(disposingPost).not.toHaveBeenCalled() + + disposing["_disposed"] = false + await provider.dispose() + await live.dispose() + await disposing.dispose() + }) + + it("restores the shared provider settings when a step after the rewrite fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-key", + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + getProfile: vi.fn().mockImplementation(async ({ name }) => + name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-key", + } + : { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }, + ), + } + // The failure lands after the shared provider keys were replaced with the survivor's: + // the rollback has to put those keys back too, or the restored selection would be + // paired with another profile's provider and credentials. + provider["rePinViewLocalStateForDeletedProfile"] = vi + .fn() + .mockRejectedValue(new Error("sibling re-pin failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling re-pin failed") + + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("doomed-profile") + expect(provider.contextProxy.getProviderSettings()).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-key", + }) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual( + expect.arrayContaining([doomedProfile, keeperProfile]), + ) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toHaveLength(2) + await provider.dispose() + }) + + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) + // The global selection and this view's pin both name surviving profiles: + // the deletion must not reconfigure this view's settings, and the + // pinned view's nested overlay must survive byte-for-byte. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // No reconfiguration: the guard must stay false when neither the global + // selection nor the view pin names the deleted profile, so the view + // keeps its own pin and its nested overlay while the shared store + // reports the global selection. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + }) + await provider.dispose() + }) + }) + describe("profile activation and upsert compensate every durable write", () => { + it("restores the shared selection and the acting view pin separately when an activating upsert is rejected", async () => { + // A sibling activation leaves the shared selection on one profile while this view stays + // pinned to another. Restoring the shared name with setValue moves the pin with it, so + // the view ends up reporting the shared profile next to its own settings - getState + // merges the view-local state over the shared state. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-split-rollback") + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "profile-a", id: "id-a" }, + { name: "profile-b", id: "id-b" }, + ]) + + const manager = provider.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-a") + vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-b", id: "id-b" }]) + + // The shared provider settings fan out to several storage keys (secrets included) + // and the first apiKey write rejects, after the profile record, the profile list, the + // mode mapping and the shared name had all landed: the compensation runs with the + // selection already durable. + let settingsWrites = 0 + vi.mocked(mockContext.secrets.store).mockImplementation(async (key: string) => { + if (key === "apiKey") { + settingsWrites += 1 + if (settingsWrites === 1) { + throw new Error("settings persist failed") + } + } + return Promise.resolve() + }) + + await expect( + provider.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).resolves.toBeUndefined() + + // The shared selection returns to the shared pre-operation value and the view keeps + // the pin it had, rather than inheriting the shared one. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + await provider.dispose() + }) + + it("restores sibling view buffers already refreshed when one sibling refresh fails during an activating upsert", async () => { + const actor = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await actor["setViewStateId"]("tab-fanout-actor") + vi.spyOn(actor, "log").mockImplementation(() => {}) + const siblingOk = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await siblingOk["setViewStateId"]("tab-fanout-ok") + const siblingFails = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await siblingFails["setViewStateId"]("tab-fanout-fail") + + // Pin both siblings to the profile the actor is about to save, each with its own + // loaded overlay, so a missing per-view restore is observable per view. + const okOverlay = { apiProvider: providerIdentifiers.anthropic, apiKey: "old-ok" } + const failOverlay = { apiProvider: providerIdentifiers.anthropic, apiKey: "old-fail" } + await siblingOk.setValue("currentApiConfigName", "profile-b") + await siblingFails.setValue("currentApiConfigName", "profile-b") + siblingOk["viewLocalState"].apiConfiguration = okOverlay + siblingFails["viewLocalState"].apiConfiguration = failOverlay + + const previousEntries = [{ name: "profile-b", id: "id-b" }] + await actor.contextProxy.setValue("listApiConfigMeta", previousEntries) + + const manager = actor.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-b") + vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-b", id: "id-b" }]) + vi.spyOn(actor, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(siblingOk, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(siblingFails, "postStateToWebview").mockRejectedValue(new Error("sibling post failed")) + + await expect( + actor.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).resolves.toBeUndefined() + + // Both sibling buffers are back on their pre-refresh overlays: a refresh that landed + // for one sibling cannot survive the rollback triggered by another's failure. + expect(siblingOk["viewLocalState"].apiConfiguration).toEqual(okOverlay) + expect(siblingFails["viewLocalState"].apiConfiguration).toEqual(failOverlay) + // The actor's own stores are rolled back by the mutation's compensation. + expect(actor.contextProxy.getValue("listApiConfigMeta")).toEqual(previousEntries) + await actor.dispose() + await siblingOk.dispose() + await siblingFails.dispose() + }) + + it("surfaces an explicit inconsistent-state error when a sibling buffer cannot be restored", async () => { + const actor = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await actor["setViewStateId"]("tab-fanout-inconsistent-actor") + vi.spyOn(actor, "log").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("tab-fanout-inconsistent-sibling") + vi.spyOn(sibling, "log").mockImplementation(() => {}) + await sibling.setValue("currentApiConfigName", "profile-b") + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-sib", + } + + await actor.contextProxy.setValue("listApiConfigMeta", [{ name: "profile-b", id: "id-b" }]) + + const manager = actor.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-b") + vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-b", id: "id-b" }]) + vi.spyOn(actor, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(sibling, "postStateToWebview").mockRejectedValue(new Error("sibling post failed")) + + // The forward refresh lands; the compensating restore of the same buffer fails. + // Double assertion: _saveViewLocalStateFromMutation is private and has no typed + // handle for vi.spyOn. + const siblingInternals = sibling as unknown as { + _saveViewLocalStateFromMutation: (values: Record) => Promise + } + let bufferWrites = 0 + vi.spyOn(siblingInternals, "_saveViewLocalStateFromMutation").mockImplementation(async () => { + bufferWrites += 1 + if (bufferWrites > 1) { + throw new Error("sibling buffer restore failed") + } + }) + + await expect( + actor.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).rejects.toThrow( + /rollback was incomplete[\s\S]*sibling buffer restore failed[\s\S]*Original failure: sibling post failed/, + ) + // Exactly the forward write and one restore attempt - the rollback must not retry blindly. + expect(bufferWrites).toBe(2) + await actor.dispose() + await sibling.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 savedForReadBack2: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack2[name] = settings + return "activated-id" + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + // The activation snapshot reads the prior record (not-found is the + // creation case) and the mode mapping: without these the snapshot + // throws a TypeError that upsert swallows, and the activation path + // this test names never runs. + getProfile: vi.fn(async ({ name }: { name: string }) => { + if (!savedForReadBack2[name]) { + throw new ProviderSettingsNotFoundError(name) + } + return { name, id: "activated-id", ...savedForReadBack2[name] } + }), + getModeConfigId: 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 savedForReadBack3: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack3[name] = settings + return "profile-b-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "profile-b-id", + ...savedForReadBack3[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("rolls back the profile activation writes when one concurrent write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the prior shared selection so the rollback restores a real state. + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, + ]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + const activateProfileSpy = vi + .spyOn(provider.providerSettingsManager, "activateProfile") + .mockResolvedValue({ name: "old-profile", apiProvider: providerIdentifiers.anthropic }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id (the + // user sees the error toast); the compensation must still have run. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + // Every write that landed is compensated: the mode mapping returns to its + // prior id, and the selection, list, and shared settings are restored. + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("code", "old-mode-id") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + expect((provider.contextProxy.getValue("listApiConfigMeta") ?? []).map((entry) => entry.name)).toEqual([ + "old-profile", + ]) + expect(setProviderSettingsSpy).toHaveBeenCalledTimes(2) + // The manager store is compensated too: the record written by saveConfig + // returns to its prior content and the manager selection returns to the + // prior profile. + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("new-profile", { + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + expect(activateProfileSpy).toHaveBeenCalledWith({ name: "old-profile" }) + await provider.dispose() + }) + it("rolls back the direct activation writes when one concurrent write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the prior shared selection so the rollback restores a real state. + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.anthropic }, + ]) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openai, + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, + ]), + getCurrentProfileName: vi.fn().mockResolvedValue("old-profile"), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider.contextProxy, "setProviderSettings").mockRejectedValue( + new Error("shared settings write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect(provider.activateProviderProfile({ name: "new-profile" })).rejects.toThrow( + "shared settings write failed", + ) + + // The compensation waits for every group write to settle before restoring: + // the selection and the profile list return to the prior state even though + // the shared settings write rejected mid-group. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + const restoredEntries = (provider.contextProxy.getValue("listApiConfigMeta") ?? []).map( + (entry) => entry.name, + ) + expect(restoredEntries).toEqual(["old-profile"]) + await provider.dispose() + }) + it("re-points closed views' persisted pins onto the surviving profile after a deletion", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-delete-view") + // A closed view's durable pin and this view's own pin both name the doomed + // profile; the deletion must move them to the survivor. + await provider.contextProxy.setValue("viewStates", { + "closed-view": { currentApiConfigName: "doomed-profile", updatedAt: 1 }, + "stable-delete-view": { mode: "code", currentApiConfigName: "doomed-profile", updatedAt: 2 }, + }) + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.anthropic }, + { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.openrouter }, + ]) + await provider.saveViewState("currentApiConfigName", "doomed-profile") + // The deleted profile is the shared selection: the survivor resolution needs it. + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("keeper-id") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteProviderProfile({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.anthropic, + }) + + const states = provider.contextProxy.getValue("viewStates") as Record> + expect(states["closed-view"].currentApiConfigName).toBe("keeper-profile") + 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) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + // 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("deletes the newly created manager record when a later activation write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The profile is new: hasConfig confirms absence even though getProfile + // reports it through an untyped wrapper error. + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue( + new Error("Config with name 'new-profile' not found"), + ) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, + ]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + const deleteConfigSpy = vi + .spyOn(provider.providerSettingsManager, "deleteConfig") + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + // Compensation must delete the record the mutation created, not skip the + // restore because the lookup threw an untyped not-found. + expect(deleteConfigSpy).toHaveBeenCalledWith("new-profile") + await provider.dispose() + }) + + it("does not delete a newly created record while another create for the same name is in flight", 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(), + ) + for (const instance of [providerA, providerB]) { + vi.spyOn(instance.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(instance.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + } + const deleteSpyA = vi.spyOn(providerA.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + const deleteSpyB = vi.spyOn(providerB.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + // Both views confirm the name is absent before saving it: each create is + // claimed so a rollback can tell whose deletion is safe. + const snapshotA = await providerA["_snapshotProviderManagerState"]("dup-profile") + const snapshotB = await providerB["_snapshotProviderManagerState"]("dup-profile") + expect(snapshotA.claimedCreate).toBe(true) + expect(snapshotB.claimedCreate).toBe(true) + + // A's activation fails while B's create for the same name is still in + // flight: the surviving record may belong to B, so A must not delete it. + await providerA["_restoreProviderManagerRecord"]("dup-profile", snapshotA) + expect(deleteSpyA).not.toHaveBeenCalled() + + // B fails too: the last claimant to settle owns the deletion. + await providerB["_restoreProviderManagerRecord"]("dup-profile", snapshotB) + expect(deleteSpyB).toHaveBeenCalledWith("dup-profile") + + await providerA.dispose() + await providerB.dispose() + }) + + it("keeps the record when a sibling create succeeded before this create failed", 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(), + ) + for (const instance of [providerA, providerB]) { + vi.spyOn(instance.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(instance.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + vi.spyOn(instance.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(instance.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(instance.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(instance.providerSettingsManager, "saveConfig").mockResolvedValue("b-id") + vi.spyOn(instance.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "dup-profile", + id: "b-id", + apiProvider: providerIdentifiers.openai, + }) + vi.spyOn(instance.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "dup-profile", + id: "b-id", + apiProvider: providerIdentifiers.openai, + }) + vi.spyOn(instance, "postStateToWebview").mockResolvedValue(undefined) + } + const deleteSpyA = vi.spyOn(providerA.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + // A confirms the name is absent and claims the create. + const snapshotA = await providerA["_snapshotProviderManagerState"]("dup-profile") + expect(snapshotA.claimedCreate).toBe(true) + + // B creates the same profile successfully: its claim is spent and the + // record belongs to B's save. + await expect( + providerB.upsertProviderProfile("dup-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBe("b-id") + + // A's activation group fails after B already landed: deleting would + // discard the profile B saved. + await providerA["_restoreProviderManagerRecord"]("dup-profile", snapshotA) + expect(deleteSpyA).not.toHaveBeenCalled() + + // The bookkeeping is cleared once the last claimant settles: a later + // failed create for the same name owns its deletion again. + const snapshotA2 = await providerA["_snapshotProviderManagerState"]("dup-profile") + await providerA["_restoreProviderManagerRecord"]("dup-profile", snapshotA2) + expect(deleteSpyA).toHaveBeenCalledWith("dup-profile") + + await providerA.dispose() + await providerB.dispose() + }) + + it("spends the create claim when the profile save itself fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + const deleteSpy = vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "saveConfig") + .mockRejectedValueOnce(new Error("store write failed")) + .mockResolvedValue("profile-id") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The first create fails inside saveConfig: its rollback must spend the claim + // even though the record write itself is what failed. + await provider.upsertProviderProfile("leaky-profile", { apiProvider: providerIdentifiers.openai }, false) + + // A second create of the same name now fails during activation instead. + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + await provider.upsertProviderProfile("leaky-profile", { apiProvider: providerIdentifiers.openai }, true) + + // A claim leaked by the first create would make this rollback defer to a + // create that never happened and strand the landed record: the deletion + // must land. + expect(deleteSpy).toHaveBeenCalledWith("leaky-profile") + await provider.dispose() + }) + + it("restores the mode mapping when the mode binding fails after an activation", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + // The activation destructures { name, id, ...settings } from the manager: the id is what + // the mode binding is written with, so the double has to carry one. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.anthropic, + }) + const setModeConfigSpy = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockImplementation(async (mode: string, id: string) => { + if (id !== "old-mode-id") { + throw new Error("mode write failed") + } + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect(provider.activateProviderProfile({ name: "new-profile" })).rejects.toThrow("mode write failed") + + // The activation had already moved the selection and the shared settings before the + // mode binding failed, so the mapping has to go back to what it held: otherwise the + // mode stays bound to a profile whose activation the caller was told failed. + expect(setModeConfigSpy).toHaveBeenCalledWith("code", "old-mode-id") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + expect((provider.contextProxy.getValue("listApiConfigMeta") ?? []).map((entry) => entry.name)).toEqual([ + "old-profile", + ]) + await provider.dispose() + }) + + it("restores the activation state when a sibling overlay refresh fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // Double assertion: the refresh is private, and the failure being injected is a + // sibling's storage write, which no public seam can produce on demand. + const proto = ClineProvider.prototype as unknown as { + refreshViewLocalStateForUpdatedProfile: (...args: unknown[]) => Promise + } + const refreshSpy = vi + .spyOn(proto, "refreshViewLocalStateForUpdatedProfile") + .mockRejectedValue(new Error("sibling refresh failed")) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await expect(provider.activateProviderProfile({ name: "new-profile" })).rejects.toThrow( + "sibling refresh failed", + ) + + // The refresh runs after the selection, the profile list and the shared provider + // settings have all moved, so a failure there has to roll them back like any other + // write in the group: the caller saw an error, and the state must match that. + expect(refreshSpy).toHaveBeenCalledTimes(1) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + expect((provider.contextProxy.getValue("listApiConfigMeta") ?? []).map((entry) => entry.name)).toEqual([ + "old-profile", + ]) + expect(setProviderSettingsSpy).toHaveBeenCalledTimes(2) + refreshSpy.mockRestore() + await provider.dispose() + }) + + it("keeps this view's own profile pin when an activation rollback restores the shared selection", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The view pins profile-a while another view's choice sits in the shared + // selection. + await provider.saveViewState("currentApiConfigName", "profile-a") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + + const snapshot = provider["_snapshotProfileActivationState"]() + // The activation moved the shared selection; a later write failed. + await provider.setValue("currentApiConfigName", "profile-c") + await provider["_restoreProfileActivationState"](snapshot) + + // The shared selection returns to its prior value... + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + // ...but this view's own pin is restored to its own choice, not the + // shared one: the rollback must not replace the pin with profile-b. + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + const viewStates = mockContext.globalState.get("viewStates") as Record> + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-a") + await provider.dispose() + }) + + it("does not rewrite unrelated global keys when an activation rollback restores provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const snapshot = provider["_snapshotProfileActivationState"]() + // An unrelated key legitimately changes while the activation group is in + // flight. + await provider.contextProxy.setValue("customInstructions", "written during activation") + await provider["_restoreProfileActivationState"](snapshot) + // The rollback restores provider settings only; it must not replay the + // whole global-state copy over keys that moved in the meantime. + expect(provider.contextProxy.getValue("customInstructions")).toBe("written during activation") + 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("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("restores the complete durable viewStates map when an activation rollback follows a pruning pin write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-activation-view") + // A full map: the activation's own pin write prunes the oldest entry. + const seeded: Record = {} + for (let i = 1; i <= 50; i++) { + seeded["view-" + i] = { mode: "code", updatedAt: i } + } + await provider.contextProxy.setValue("viewStates", seeded) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + // The rollback restores the exact pre-activation map: the sibling the + // pruning evicted is back with its original metadata. + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["view-1"]).toEqual({ mode: "code", updatedAt: 1 }) + await provider.dispose() + }) + it("keeps sibling viewStates writes that land during an activation rollback", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-merge-view") + await provider.contextProxy.setValue("viewStates", { "old-view": { mode: "code", updatedAt: 1 } }) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // A sibling instance persists its own entry while the activation group is + // in flight; the rollback must not replay the stale snapshot over it. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + const current = (provider.contextProxy.getValue("viewStates") ?? {}) as Record + await provider.contextProxy.setValue("viewStates", { + ...current, + "sibling-view": { mode: "architect", updatedAt: 99 }, + }) + return [] + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + // The rollback restores what the activation pruned without erasing the + // sibling's newer entry. + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["sibling-view"]).toEqual({ mode: "architect", updatedAt: 99 }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + await provider.dispose() + }) + + it("does not resurrect a sibling pin cleared after the activation snapshot", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-clear-view") + await provider.contextProxy.setValue("viewStates", { + "sibling-view": { currentApiConfigName: "sibling-profile", updatedAt: 5 }, + "old-view": { mode: "code", updatedAt: 1 }, + }) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // Mid-group, after the rollback snapshot, the sibling intentionally clears + // its only persisted selection: the deletion is newer than the snapshot and + // must survive the rollback. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + const current = (provider.contextProxy.getValue("viewStates") ?? {}) as Record< + string, + { mode?: string; currentApiConfigName?: string; updatedAt?: number } + > + delete current["sibling-view"] + await provider.contextProxy.setValue("viewStates", { ...current }) + return [] + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + const restored = provider.contextProxy.getValue("viewStates") as Record + // The rollback must not treat the sibling's clear as activation pruning. + expect(restored["sibling-view"]).toBeUndefined() + expect(restored["old-view"]).toEqual({ mode: "code", updatedAt: 1 }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + 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("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 = { + 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), + getModeConfigId: 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 explicit view-local override survives. + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.bedrock) + expect(provider.contextProxy.getValue("awsBedrockEndpoint")).toBe("http://127.0.0.1:4567") + + await provider.dispose() + }) + }) + + describe("persisted view state", () => { + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" }) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should drop an unknown mode from setValues while keeping valid modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab panel/with.dots and spaces") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + const firstSave = provider.saveViewState("mode", "architect") + await firstWriteStarted + // The switch waits on the same persisted-write queue as the pending save: start it, + // release the save, then await both, or the test deadlocks on the queue. + const switchToViewB = provider["setViewStateId"]("view-b") + releaseFirstWrite() + await firstSave + await switchToViewB + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("view-b") + + await provider.dispose() + }) + + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + }) + + it("should read viewStates fresh from storage so out-of-proxy writes are not clobbered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + await provider.saveViewState("mode", "architect") + + // Simulate a concurrent writer (another view's provider) updating the shared + // map directly in storage, bypassing this proxy's cache. + const stored = (await mockContext.globalState.get>("viewStates")) ?? {} + await mockContext.globalState.update("viewStates", { + ...stored, + "view-b": { mode: "debug", updatedAt: 1 }, + }) + + await provider.saveViewState("mode", "code") + + // The serialized write must have merged on top of the fresh storage value, not + // on top of this proxy's stale cache. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "code" }, + "view-b": { mode: "debug" }, + }) + + await provider.dispose() + }) + + it("should re-key durable viewStates entries from the temporary pre-launch view id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A change made before the stable id is registered persists under the + // temporary id so it is not lost; registration re-keys it to the stable id. + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + [provider.viewId]: { mode: "architect" }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "debugger") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop the temporary viewStates entry when a stable entry already exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A stable entry already exists (e.g. persisted by an earlier registration); + // it must win over this instance's temporary entry. + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + // Author the temporary entry through the real write path so this instance + // owns it: unowned entries are never re-keyed. + await provider.saveViewState("mode", "architect") + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("debugger") + + await provider.dispose() + }) + + it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // The temporary entry must be authored by this instance through the real write + // path, or the load would ignore it as an orphan; the stable entry is the + // post-registration one and is seeded through the proxy cache. + await provider["savePersistedViewState"]({ + mode: "architect", + currentApiConfigName: "ghost-profile", + }) + await provider.contextProxy.setValue("viewStates", { + ...(provider.contextProxy.getValue("viewStates") as Record), + "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, + }) + + // Author the temporary entry through the real write path so this instance owns it: an + // unowned temporary entry makes loadViewState bail before the profile lookup, so the + // stale check below would never be reached. + await provider.saveViewState("currentApiConfigName", "ghost-profile") + + // 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 + }) + const ghostGetProfile = 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"]() + + // The lookup has to be in flight before the stable id is registered; asserting only + // after the call completes cannot prove the stale check fires mid-load. + await vi.waitFor(() => expect(ghostGetProfile).toHaveBeenCalledTimes(1)) + + // Register the stable id without awaiting its load: the re-key drops the + // temporary entry (the stable one already exists) and the registration's own + // load settles on the stable entry immediately. + const register = provider["setViewStateId"]("stable-sidebar-view") + await register + + releaseGhost() + await staleLoad + + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) + + await provider.dispose() + }) + }) + + describe("getState default values", () => { + it("should fall back to defaults for unset state values", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const state = await provider.getState() + + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.alwaysAllowReadOnly).toBe(false) + expect(state.alwaysAllowReadOnlyOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWrite).toBe(false) + expect(state.alwaysAllowWriteOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWriteProtected).toBe(false) + expect(state.alwaysAllowExecute).toBe(false) + expect(state.alwaysAllowMcp).toBe(false) + expect(state.alwaysAllowModeSwitch).toBe(false) + expect(state.alwaysAllowSubtasks).toBe(false) + expect(state.alwaysAllowFollowupQuestions).toBe(false) + expect(state.followupAutoApproveTimeoutMs).toBe(60000) + expect(state.diagnosticsEnabled).toBe(true) + expect(state.soundEnabled).toBe(false) + expect(state.ttsEnabled).toBe(false) + expect(state.ttsSpeed).toBe(1) + expect(state.enableCheckpoints).toBe(true) + expect(state.checkpointTimeout).toBe(DEFAULT_CHECKPOINT_TIMEOUT_SECONDS) + expect(state.terminalPowershellCounter).toBe(false) + expect(state.terminalZshClearEolMark).toBe(true) + expect(state.terminalZshOhMy).toBe(false) + expect(state.terminalZshP10k).toBe(false) + expect(state.terminalZdotdir).toBe(false) + expect(state.mcpEnabled).toBe(true) + expect(state.listApiConfigMeta).toEqual([]) + expect(state.pinnedApiConfigs).toEqual({}) + expect(state.modeApiConfigs).toEqual({}) + expect(state.customSupportPrompts).toEqual({}) + expect(state.experiments).toEqual(experimentDefault) + expect(state.autoApprovalEnabled).toBe(false) + expect(state.maxOpenTabsContext).toBe(20) + expect(state.maxWorkspaceFiles).toBe(200) + expect(state.telemetrySetting).toBe("unset") + expect(state.enableSubfolderRules).toBe(false) + expect(state.maxImageFileSize).toBe(5) + expect(state.maxTotalImageSize).toBe(20) + expect(state.historyPreviewCollapsed).toBe(false) + expect(state.reasoningBlockCollapsed).toBe(true) + expect(state.enterBehavior).toBe("send") + expect(state.codebaseIndexModels).toEqual(EMBEDDING_MODEL_PROFILES) + expect(state.codebaseIndexConfig).toEqual({ + codebaseIndexEnabled: false, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "", + }) + expect(state.profileThresholds).toEqual({}) + expect(state.includeDiagnosticMessages).toBe(true) + expect(state.maxDiagnosticMessages).toBe(50) + expect(state.includeTaskHistoryInEnhance).toBe(true) + expect(state.includeCurrentTime).toBe(true) + expect(state.includeCurrentCost).toBe(true) + expect(state.maxGitStatusFiles).toBe(0) + expect(state.language).toBe("en") + + await provider.dispose() + }) + + it("should report a non-retired apiProvider from state instead of the anthropic fallback", async () => { + const contextProxy = new ContextProxy(mockContext) + await contextProxy.setValues({ apiProvider: providerIdentifiers.openrouter }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + + const state = await provider.getState() + + 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(), + ) + + 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(), + ) - expect(getAllSpy).not.toHaveBeenCalled() - expect(postMessageSpy).toHaveBeenCalledOnce() - expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") - }) + // 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) - test("getStateToPostToWebview computes task history once after its base state resolves", async () => { - const historyItem = { - id: "history-task", - number: 1, - ts: 1, - task: "History task", - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - } - const originalGetState = provider.getState.bind(provider) - let baseStateResolved = false - const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { - const state = await originalGetState(options) - baseStateResolved = true - return state - }) - const historyReadPhases: boolean[] = [] - const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { - historyReadPhases.push(baseStateResolved) - return [historyItem] - }) + const state = await provider.getState() - const state = await provider.getStateToPostToWebview() + expect(state.codebaseIndexModels).toBe(EMBEDDING_MODEL_PROFILES) - expect(getStateSpy).toHaveBeenCalledOnce() - expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) - expect(getAllSpy).toHaveBeenCalledOnce() - expect(historyReadPhases).toEqual([true]) - expect(state.taskHistory).toEqual([historyItem]) + await provider.dispose() + }) }) describe("postStateToWebviewThrottled", () => { @@ -3821,6 +10515,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) @@ -4624,6 +11359,8 @@ describe("ClineProvider", () => { activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(profile), getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any @@ -4651,6 +11388,8 @@ describe("ClineProvider", () => { activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(profile), getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any @@ -4823,14 +11562,24 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack4: Record> = {} ;(provider as any).providerSettingsManager = { 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) => { + savedForReadBack4[name] = settings + return "test-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "test-id", + ...savedForReadBack4[name], + })), setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any // Update API configuration @@ -5229,6 +11978,54 @@ describe("ClineProvider", () => { }) }) + it("restores the task mode and its history entry when the shared mode write fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + + class TaskDouble { + // Mirrors Task: taskMode is a getter over the private field the provider writes, + // which has no public setter. + _taskMode = "code" + taskId = "mode-fail-task" + emit = vi.fn() + get taskMode() { + return this._taskMode + } + } + const task = new TaskDouble() + + provider.taskHistoryStore["cache"].set(task.taskId, { + id: task.taskId, + task: "mode switch", + ts: Date.now(), + number: 1, + mode: "code", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) + const updateHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(async (item) => { + provider.taskHistoryStore["cache"].set(item.id, item) + return [] + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "setValue").mockRejectedValue(new Error("mode write unavailable")) + + // The double mirrors only the two Task members this path touches, so it cannot + // satisfy the full structural type. + await expect( + provider.handleModeSwitch("architect", task as unknown as Parameters[1]), + ).rejects.toThrow("mode write unavailable") + + // The history write and the task's own mode committed before the shared write + // rejected. Leaving them at the rejected mode shows a failed switch on top of a + // task that still runs the new mode. + expect(task.taskMode).toBe("code") + expect(provider.taskHistoryStore["cache"].get(task.taskId)).toMatchObject({ mode: "code" }) + expect(updateHistorySpy).toHaveBeenCalledTimes(2) + expect(updateHistorySpy.mock.calls[1]?.[0]).toMatchObject({ id: task.taskId, mode: "code" }) + await provider.dispose() + }) + describe("createTaskWithHistoryItem mode validation", () => { test("validates and falls back to default mode when restored mode no longer exists", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -5286,8 +12083,13 @@ 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. Reject any write to the shared "mode" key, + // whatever value it carries - a write of "ask" would satisfy a value-specific check. + expect(provider["viewLocalState"].mode).toBe("code") + expect( + vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode"), + ).toHaveLength(0) expect(logSpy).toHaveBeenCalledWith( "Mode 'non-existent-mode' from history no longer exists. Falling back to default mode 'code'.", ) @@ -5359,8 +12161,11 @@ 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 write to the shared "mode" key at all. + expect(provider["viewLocalState"].mode).toBe("custom-mode") + expect( + vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode"), + ).toHaveLength(0) expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("no longer exists")) // Verify history item mode was not changed @@ -5407,8 +12212,11 @@ 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 write to the shared "mode" key at all. + expect(provider["viewLocalState"].mode).toBe("architect") + expect( + vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode"), + ).toHaveLength(0) // Verify history item mode was not changed expect(historyItem.mode).toBe("architect") @@ -5597,14 +12405,24 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack5: Record> = {} ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack5[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack5[name], + })), listConfig: vi .fn() .mockResolvedValue([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any const testApiConfig = { @@ -5642,14 +12460,24 @@ describe("ClineProvider", () => { ;(buildApiHandler as any).mockImplementationOnce(() => { throw new Error("API handler error") }) + const savedForReadBack6: Record> = {} ;(provider as any).providerSettingsManager = { 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([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any // Setup Task instance with auto-mock from the top of the file @@ -5685,9 +12513,18 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack7: Record> = {} ;(provider as any).providerSettingsManager = { 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([ @@ -6473,6 +13310,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 +13333,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 +13379,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 @@ -8104,12 +14956,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, ) }) @@ -8206,4 +15061,489 @@ 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 savedForReadBack8: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack8[name] = settings + return "saved-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "saved-id", + ...savedForReadBack8[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("leaves the acting task handler alone when a rejected save never reached the sibling refresh", async () => { + // A rebuilt task handler is not a durable store: compensateProfileActivation can put the + // profile record, the list, the selection and this view's overlay back, but it cannot un-build + // a handler. So the rebuild has to run after the last write that can still fail, exactly as + // the activating branch does it - otherwise a save that fails in the sibling refresh leaves a + // running task sending requests with settings the rolled-back save never committed. + // 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 { + await sibling["setViewStateId"]("save-refresh-failing-view") + await sibling.saveViewState("currentApiConfigName", "saved-profile") + vi.spyOn(sibling, "postStateToWebview").mockRejectedValue(new Error("sibling post down")) + + // This view is pinned to the saved profile and its own task runs under it, so the + // rebuild guard below is satisfied and only ordering can stop it. + provider["viewLocalState"].currentApiConfigName = "saved-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The acting view holds a task that runs under the saved profile: without it the + // rebuild guard is never satisfied and the test would pass for the wrong reason. + vi.spyOn(provider, "getCurrentTask").mockReturnValue({ + taskApiConfigName: "saved-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "old-key" }, + updateApiConfiguration: vi.fn(), + } as unknown as ReturnType) + + // Double assertion: the method is private and vi.spyOn needs a property holder; + // house idiom in this spec (see the rebuild spies elsewhere). + const rebuildSpy = vi + .spyOn( + provider as unknown as { + updateTaskApiHandlerIfNeeded: ( + settings: ProviderSettings, + options: { forceRebuild?: boolean }, + ) => void + }, + "updateTaskApiHandlerIfNeeded", + ) + .mockImplementation(() => {}) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ + name: "saved-profile", + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }), + } + + // `upsertProviderProfile` reports a failed save as `undefined` - only an incomplete + // rollback rethrows - so the rejected save is asserted through the return value. + const outcome = await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + expect(outcome).toBeUndefined() + + expect(rebuildSpy).not.toHaveBeenCalled() + // The overlay the save wrote before the refresh is back to the pre-save copy. + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + }) + + // Positive control: once every sibling lands, the rebuild runs. The fix is the + // ordering, not removing the rebuild. + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + const secondOutcome = await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "newest-key" }, + false, + ) + + expect(secondOutcome).toBe("saved-id") + expect(rebuildSpy).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "new-key" }), { + forceRebuild: true, + }) + + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + } + }) + + it("restores only its own profile-list entry when a rejected save compensates", async () => { + // The rollback replays the profile list, not the whole settings snapshot. Replaying the + // captured list would also erase a write another view landed while this save awaited. + // 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 keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const concurrentProfile: ProviderSettingsEntry = { + name: "concurrent-profile", + id: "concurrent-id", + apiProvider: providerIdentifiers.openrouter, + } + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await provider.contextProxy.setValue("listApiConfigMeta", [keeperProfile]) + await sibling["setViewStateId"]("list-rollback-failing-view") + await sibling.saveViewState("currentApiConfigName", "saved-profile") + // A sibling view writes the shared list after this save wrote it - a sibling profile + // refresh path does exactly that - and then rejects, so this save is compensated. + vi.spyOn(sibling, "postStateToWebview").mockImplementation(async () => { + await writer.contextProxy.setValue("listApiConfigMeta", [ + keeperProfile, + { + name: "saved-profile", + id: "saved-id", + apiProvider: providerIdentifiers.anthropic, + }, + concurrentProfile, + ]) + throw new Error("sibling post down") + }) + vi.spyOn(writer, "postStateToWebview").mockResolvedValue(undefined) + + // The stored list holds only what this save produced; the concurrent entry never + // passes through this call, which is what makes it another view's write. + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([ + keeperProfile, + { + name: "saved-profile", + id: "saved-id", + apiProvider: providerIdentifiers.anthropic, + }, + ]), + setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ + name: "saved-profile", + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }), + } + + // `upsertProviderProfile` reports a failed save as `undefined` - only an incomplete + // rollback rethrows - so the rejected save is asserted through the return value. + const outcome = await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + expect(outcome).toBeUndefined() + const entries = provider.contextProxy.getValue("listApiConfigMeta") as ProviderSettingsEntry[] + // Only this call's entry comes back: the activation compensation restores the profile list from + // the pre-write snapshot rather than merging, so a sibling's concurrent upsert that landed after + // the snapshot is not resurrected here (the deletion path merges; this path does not). + expect(entries.map((entry) => entry.name)).toEqual(["keeper-profile"]) + + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await writer.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 savedForReadBack9: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + 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, + ) + + // 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 savedForReadBack10: Record> = {} + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + 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, + ) + + 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() + }) + + it("keeps a pinned view's served settings aligned with its pin when another view activates a different 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 { + // This view is pinned to profile A with no overlay: its served + // settings come from the shared store, which the sibling's activation + // is about to overwrite with profile B's settings. + provider["viewLocalState"].currentApiConfigName = "pinned-a" + provider["viewLocalState"].apiConfiguration = undefined + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + const profileA = { apiProvider: providerIdentifiers.anthropic, apiKey: "a-key" } + const managerDouble = { + activateProfile: vi.fn().mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.bedrock, + apiKey: "b-key", + }), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + getProfile: vi + .fn() + .mockImplementation(async ({ name }: { name: string }) => + name === "pinned-a" + ? { name: "pinned-a", ...profileA } + : { name, apiProvider: providerIdentifiers.bedrock }, + ), + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = managerDouble + // @ts-ignore - The sibling resolves the pin through its own manager. + sibling.providerSettingsManager = managerDouble + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + await sibling.activateProviderProfile({ name: "profile-b" }) + + // The activation snapshotted profile A's settings into this view's + // overlay before replacing the shared store, so getState() still + // serves the pinned profile's configuration. + const state = await provider.getState() + expect(state.currentApiConfigName).toBe("pinned-a") + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.apiConfiguration.apiKey).toBe("a-key") + } finally { + await sibling.dispose() + } + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 47fcfd11f0..09ab8916fb 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(), } }, }, @@ -466,6 +468,120 @@ 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("leaves no mode pin behind when a view without a pin fails the same way", async () => { + // Only the TaskModeSwitched emit may throw: addClineToStack emits other events + // during setup, and a listener failure is what the rollback covers. Kept as an + // untyped Mock so the object literal still overlaps Partial. + const emit = vi.fn() + emit.mockImplementation((event: string) => { + if (event === "taskModeSwitched") { + throw new Error("listener failed") + } + }) + + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit, + 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").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + await provider.contextProxy.setValue("mode", "code") + // No saveViewState: this view has no pin, so the forward write creates one. + expect(provider["viewLocalState"].mode).toBeUndefined() + + await expect(provider["handleModeSwitchUnlocked"]("ask", mockTask)).rejects.toThrow("listener failed") + + expect(provider.getValue("mode")).toBe("code") + // The pin the failed switch created must be cleared again rather than left + // holding the restored shared mode. + expect(provider["viewLocalState"].mode).toBeUndefined() + expect(provider.getValues().mode).toBe("code") + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + // Either no entry was written for this view or the entry carries no mode key; what + // must not happen is a persisted pin holding the restored shared mode. + expect(persisted.mode).toBeUndefined() + }) + + it("proceeds normally when no cancellation state exists", 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) + + expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + }) }) describe("createTaskWithHistoryItem", () => { @@ -486,14 +602,15 @@ describe("ClineProvider - Sticky Mode", () => { mode: "architect", // Saved mode } - // Mock updateGlobalState to track mode updates - const updateGlobalStateSpy = vi.spyOn(provider as any, "updateGlobalState").mockResolvedValue(undefined) + // Register a stable view id so the durable per-view write is persisted + 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 () => { @@ -770,7 +887,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") @@ -969,72 +1088,6 @@ describe("ClineProvider - Sticky Mode", () => { expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "invalid-mode") }) - it("should handle errors during mode switch gracefully", async () => { - await provider.resolveWebviewView(mockWebviewView) - - // Create a mock task that throws on emit only for specific events - let emitCallCount = 0 - const mockTask = { - taskId: "test-task-id", - _taskMode: "code", - emit: vi.fn().mockImplementation((event) => { - emitCallCount++ - // Only throw on the second emit call (taskModeSwitched event) - // The first call is for TaskFocused in addClineToStack - if (emitCallCount === 2 && event === "taskModeSwitched") { - throw new Error("Emit failed") - } - }), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } - - // Add task to provider stack - await provider.addClineToStack(mockTask as any) - - // Seed the authoritative file-backed task history. - await seedTaskHistory([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) - - // Mock updateTaskHistory - vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { - return Promise.resolve([]) - }) - - // Mock console.error to suppress error output - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - - // Clear previous mock calls to isolate this test - vi.mocked(mockContext.globalState.update).mockClear() - - // The handleModeSwitch method doesn't catch errors from emit, so it will throw - // The error is thrown before the task's mode is updated - await expect(provider.handleModeSwitch("architect")).rejects.toThrow("Emit failed") - - // Since the error is thrown before updating the task's _taskMode, - // neither the task mode nor global state are updated - const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - expect(modeCalls.length).toBe(0) - - // The task's mode should NOT have been updated since the error occurred first - expect(mockTask._taskMode).toBe("code") - - consoleErrorSpy.mockRestore() - }) - it("should handle updateTaskHistory failures", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1275,4 +1328,148 @@ 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()) + 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") + }) + + 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()) + 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() + }) + + it("does not mark the temporary id as authored when the durable write fails", async () => { + // Authorship must be recorded only after the write lands: a failed persistence + // that still marked the id owned would let a later load adopt an entry that was + // never actually stored. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string, _value: unknown) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(fresh.saveViewState("mode", "code")).rejects.toThrow("storage unavailable") + expect(fresh["wroteUnderTemporaryViewStateId"]).toBe(false) + + mockContext.globalState.update = originalUpdate + }) + + 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..cc60613f5d 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1110,4 +1110,330 @@ 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 deletion alone must have repointed the context at the surviving profile. Assert that + // before driving any further public path: a later mode switch that happens to repair the + // selection would otherwise mask a deletion that left currentApiConfigName on the profile + // it just removed. + const afterDeletion = provider.contextProxy.getValues() + expect(afterDeletion.currentApiConfigName).toBe("default") + expect(afterDeletion.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + + // Drive the public path the comment above describes instead of asserting only the + // mapping: switching to the mode that was pinned to the deleted profile must fall + // through to the surviving profile, not re-activate the deleted one. + await provider.handleModeSwitch("ask") + const afterSwitch = provider.contextProxy.getValues() + expect(afterSwitch.currentApiConfigName).toBe("default") + expect(afterSwitch.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") + + // The refusal must leave the stored record alone. The message, the selection, and the + // list all stay the same when a regression deletes the record first and then throws + // this exact error, so the store has to be read directly. + await expect(provider.providerSettingsManager.getProfile({ name: "default" })).resolves.toBeDefined() + expect((await provider.providerSettingsManager.listConfig()).map(({ name }) => name)).toEqual(["default"]) + + // Nothing was repointed or pruned. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "ghost-profile"]) + }) + + it("re-points the selection when a sibling view activates the deleted profile mid-deletion", async () => { + await provider.resolveWebviewView(mockWebviewView) + + 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.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + // A second view activates the profile this deletion is about to remove while the deletion is + // in flight. Both views share the extension host's ContextProxy, so from the moment the + // record is gone the shared selection genuinely names the deleted profile - the deletion has + // to notice that instead of trusting what it read at entry. + const manager = provider.providerSettingsManager + const deleteStoredConfig = manager.deleteConfig.bind(manager) + manager.deleteConfig = async (name: string) => { + await provider.contextProxy.setValue("currentApiConfigName", "subtask-child-profile") + return deleteStoredConfig(name) + } + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + // The shared provider keys still carried the deleted profile's settings when the selection + // moved onto it, so they must be replaced with the profile the selection now names. + expect(provider.contextProxy.getProviderSettings().openRouterModelId).toBe("openai/gpt-4.1") + }) + + it("follows a selection a sibling view moved onto another survivor mid-deletion", async () => { + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const otherId = await provider.providerSettingsManager.saveConfig("other-survivor", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4o", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "other-survivor", id: otherId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.resolveWebviewView(mockWebviewView) + + // This deletion selects the first survivor at entry, because that is what the shared + // selection names then. A sibling view moves the selection onto a different survivor while + // the record delete is in flight; that choice is the user's, and the deletion must follow it + // instead of pushing the selection back onto its own pick. + const manager = provider.providerSettingsManager + const deleteStoredConfig = manager.deleteConfig.bind(manager) + manager.deleteConfig = async (name: string) => { + await provider.contextProxy.setValue("currentApiConfigName", "other-survivor") + return deleteStoredConfig(name) + } + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("other-survivor") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "other-survivor"]) + }) + + it("leaves a sibling view's shared settings on the survivor it activated mid-deletion", async () => { + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const otherId = await provider.providerSettingsManager.saveConfig("other-survivor", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4o", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "other-survivor", id: otherId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + await provider.resolveWebviewView(mockWebviewView) + + // The cross-view half of this deletion is only observable from another view: the deletion names + // the survivor in the shared store, but whether the survivor's settings actually land in a second + // view's provider keys is a property of that view. Production builds the tab view on the same + // ContextProxy as the sidebar view (activate/registerCommands.ts:449 passes the sidebar's + // contextProxy; ClineProvider.ts:3634 documents that the two views share it while their queues + // and settings-manager locks stay per instance), so the sibling here shares provider.contextProxy + // and keeps its own settings-manager, which is exactly the shape that lets it move the selection + // mid-deletion. + // resolved webview. + const siblingView = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://sibling-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } as unknown as vscode.WebviewView + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + provider.contextProxy, + new WebviewFocusTracker(), + ) + await new Promise((resolve) => setTimeout(resolve, 10)) + sibling.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + await sibling.resolveWebviewView(siblingView) + + const manager = provider.providerSettingsManager + const deleteStoredConfig = manager.deleteConfig.bind(manager) + manager.deleteConfig = async (name: string) => { + // A sibling view moves the shared selection onto another survivor and activates it there while + // the record delete is in flight; the deletion must follow that choice instead of pushing its + // own entry-time pick back onto the sibling's provider keys. + await provider.contextProxy.setValue("currentApiConfigName", "other-survivor") + await sibling.activateProviderProfile({ name: "other-survivor" }) + return deleteStoredConfig(name) + } + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + expect(sibling.contextProxy.getValues().currentApiConfigName).toBe("other-survivor") + expect(sibling.contextProxy.getProviderSettings().openRouterModelId).toBe("openai/gpt-4o") + // The same shared store is what the acting view reads: if the deletion pushed its own entry-time + // survivor's settings over the sibling's activation, this is where it shows up. + expect(provider.contextProxy.getProviderSettings().openRouterModelId).toBe("openai/gpt-4o") + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProviderFactory.spec.ts b/src/core/webview/__tests__/ClineProviderFactory.spec.ts index 6545835b2b..0fe37c4d34 100644 --- a/src/core/webview/__tests__/ClineProviderFactory.spec.ts +++ b/src/core/webview/__tests__/ClineProviderFactory.spec.ts @@ -41,6 +41,7 @@ describe("ClineProviderFactory", () => { context, outputChannel, webviewFocusTracker: tracker, + forceNew: true, }) }) @@ -57,11 +58,13 @@ describe("ClineProviderFactory", () => { context, outputChannel, webviewFocusTracker: tracker, + forceNew: true, }) expect(openClineInNewTab).toHaveBeenNthCalledWith(2, { context: secondContext, outputChannel, webviewFocusTracker: secondTracker, + forceNew: true, }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index bf50f08786..ce153ee14e 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1,6 +1,7 @@ // npx vitest core/webview/__tests__/webviewMessageHandler.spec.ts import type { Mock } from "vitest" +import { defaultModeSlug } from "../../../shared/modes" // Mock dependencies - must come before imports vi.mock("../../../api/providers/fetchers/modelCache") @@ -83,6 +84,7 @@ import * as copilotProvider from "../../../api/providers/github-copilot" import { webviewMessageHandler } from "../webviewMessageHandler" import { registerCopilotModelBroadcast } from "../../../services/language-models/copilotModelBroadcast" +import { ProfileDeletionActivationError } from "../profileDeletionError" import type { ClineProvider } from "../ClineProvider" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" @@ -110,6 +112,7 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + saveViewState: vi.fn(), customModesManager: { getCustomModes: vi.fn(), deleteCustomMode: vi.fn(), @@ -118,6 +121,10 @@ const mockClineProvider = { extensionPath: "/mock/extension/path", globalStorageUri: { fsPath: "/mock/global/storage" }, }, + // The per-view write path; deleteCustomMode routes its mode reset through it. + setValue: vi.fn(), + // deleteCustomMode also clears the same slug out of every other live view's per-view state. + clearViewLocalModeForDeletedSlug: vi.fn().mockResolvedValue(undefined), contextProxy: { context: { extensionPath: "/mock/extension/path", @@ -129,6 +136,14 @@ const mockClineProvider = { log: vi.fn(), postStateToWebview: vi.fn(), resolveWebviewThemeFixtureProbe: vi.fn(), + // deleteApiConfiguration is routed through deleteProviderProfile, which needs the + // manager only to resolve the entry the caller named. + providerSettingsManager: { + listConfig: vi.fn(), + deleteConfig: vi.fn(), + }, + deleteProviderProfile: vi.fn().mockResolvedValue(undefined), + activateProviderProfile: vi.fn().mockResolvedValue(undefined), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), @@ -777,6 +792,7 @@ import { changeLanguage, t } from "../../../i18n" vi.mock("vscode", () => { const showInformationMessage = vi.fn() const showErrorMessage = vi.fn() + const showWarningMessage = vi.fn() const openTextDocument = vi.fn().mockResolvedValue({}) const showTextDocument = vi.fn().mockResolvedValue(undefined) @@ -784,6 +800,7 @@ vi.mock("vscode", () => { window: { showInformationMessage, showErrorMessage, + showWarningMessage, showTextDocument, }, workspace: { @@ -864,6 +881,188 @@ import { resolveImageMentions } from "../../mentions/resolveImageMentions" import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" +import type { ProviderSettingsManager } from "../../config/ProviderSettingsManager" + +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 }) => Promise + getMcpHub: () => unknown + getStateToPostToWebview: () => Promise<{ telemetrySetting: string }> + } + const double = mockClineProvider as LaunchProviderFixture + + beforeEach(() => { + vi.clearAllMocks() + 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() + // The re-pin changes this view's selection, so the merged state must be posted after the + // list refresh: the webview otherwise keeps rendering the profile that no longer resolves. + const postMessageOrders = vi.mocked(mockClineProvider.postMessageToWebview).mock.invocationCallOrder + const listConfigIndex = vi + .mocked(mockClineProvider.postMessageToWebview) + .mock.calls.findIndex(([message]) => (message as { type?: string } | undefined)?.type === "listApiConfig") + const statePostOrders = vi.mocked(mockClineProvider.postStateToWebview).mock.invocationCallOrder + // The repair's post must be the LAST state post, after the list refresh. + expect(statePostOrders.length).toBeGreaterThan(0) + expect(statePostOrders[statePostOrders.length - 1]).toBeGreaterThan(postMessageOrders[listConfigIndex]) + }) + + 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 () => { + // Earlier tests in this suite already ran the full webviewDidLaunch flow on the + // shared module-level double, so isViewLaunched is already true. Reset it, or the + // assertion below would pass even if the failed registration aborted launch. + mockClineProvider.isViewLaunched = false + 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(() => { @@ -1684,6 +1883,47 @@ describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { }) }) +describe("webviewMessageHandler - deleteApiConfiguration", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.providerSettingsManager.listConfig).mockResolvedValue([]) + vi.mocked(mockClineProvider.deleteProviderProfile).mockResolvedValue(undefined) + }) + + it("routes the deletion through deleteProviderProfile instead of the manager", async () => { + const profileToDelete = { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.openrouter } + vi.mocked(mockClineProvider.providerSettingsManager.listConfig).mockResolvedValue([profileToDelete]) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "old-profile" }) + + // ClineProvider.deleteProviderProfile is where the profile list, every live view's pin + // and overlay, the closed views' persisted pins, the last-profile guard, and the + // compensation for a half-finished deletion live. Deleting the manager record here and + // activating a survivor afterwards left every one of those surfaces naming a profile + // that no longer exists, and never wrote the survivor's settings to the shared store. + expect(mockClineProvider.deleteProviderProfile).toHaveBeenCalledWith(profileToDelete) + expect(mockClineProvider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("reports the failure when the routed deletion rejects", async () => { + const profileToDelete = { name: "only-profile", id: "only-id", apiProvider: providerIdentifiers.zooGateway } + vi.mocked(mockClineProvider.providerSettingsManager.listConfig).mockResolvedValue([profileToDelete]) + vi.mocked(mockClineProvider.deleteProviderProfile).mockRejectedValue( + new Error("You cannot delete the last profile"), + ) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "only-profile" }) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("You cannot delete the last profile"), + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(t("common:errors.delete_api_config")) + }) +}) + describe("webviewMessageHandler - deleteCustomMode", () => { beforeEach(() => { vi.clearAllMocks() @@ -1714,6 +1954,32 @@ describe("webviewMessageHandler - deleteCustomMode", () => { expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith(slug) expect(fs.rm).toHaveBeenCalledWith(rulesFolderPath, { recursive: true, force: true }) + // The reset back to the default mode has to go through the provider's per-view write + // path. The deprecated ContextProxy helper it replaces skips the view-local buffer, so a + // view that pinned the deleted mode keeps it in getState() and in any task it starts. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("mode", defaultModeSlug) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + }) + + it("clears the deleted mode out of every other live view's per-view state", async () => { + const slug = "test-sibling-mode" + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([ + { name: "Test Sibling Mode", slug, roleDefinition: "Test Role", groups: [] } as ModeConfig, + ]) + vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false) + vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.setValue).mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug }) + + // The acting view moves to the default through setValue above. That write does not reach + // a sibling that pinned the slug: its getState() would keep reporting a deleted mode and a + // task created there would store it, so the handler fans the clear out after its own write. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("mode", defaultModeSlug) + expect(mockClineProvider.clearViewLocalModeForDeletedSlug).toHaveBeenCalledWith(slug) + expect( + vi.mocked(mockClineProvider.clearViewLocalModeForDeletedSlug).mock.invocationCallOrder[0], + ).toBeGreaterThan(vi.mocked(mockClineProvider.setValue).mock.invocationCallOrder[0]) }) it("should delete a global mode and its rules folder", async () => { @@ -1906,6 +2172,188 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - host-owned keys in an updateSettings payload", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("refuses to apply profile- and provider-owned keys supplied by the webview", async () => { + // The webview is untrusted input. `apiConfiguration` is not even a RooCodeSettings key, + // so the only way it reaches the handler is a payload the compile-time type cannot + // express - which is what a crafted webview sends. Passing it through a variable rather + // than an inline literal models that at runtime. + const craftedPayload = { + enableCheckpoints: true, + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "attacker-key", + }, + listApiConfigMeta: [{ name: "evil-profile", id: "evil-id", apiProvider: providerIdentifiers.openrouter }], + viewStates: { + "evil-view": { mode: "act", currentApiConfigName: "evil-profile", updatedAt: 1 }, + }, + currentApiConfigName: "evil-profile", + } + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: craftedPayload, + }) + + // The ordinary setting still takes its normal route through provider.setValue, which + // The ordinary setting still takes its normal route through ContextProxy.setValue; + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("enableCheckpoints", true) + // The profile- and provider-owned keys reach neither sink: not the shared store, and + // not this view's local buffer (which getState() prefers over the shared values). + for (const key of ["apiConfiguration", "listApiConfigMeta", "viewStates", "currentApiConfigName"]) { + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith(key, expect.anything()) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith(key, expect.anything()) + } + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignoring host-owned setting 'apiConfiguration'"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignoring host-owned setting 'currentApiConfigName'"), + ) + }) + + it("refuses flat provider-profile keys supplied by the webview", async () => { + // RooCodeSettings is GlobalSettings & ProviderSettings, so these keys are expressible in + // the payload type - a crafted webview message does not have to smuggle them past it. They + // are the keys ProviderSettingsManager validates before a configuration may become active. + const craftedPayload = { + enableCheckpoints: true, + apiProvider: providerIdentifiers.openai, + openAiApiKey: "attacker-key", + openAiBaseUrl: "https://attacker.example", + openAiModelId: "attacker-model", + } + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: craftedPayload, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("enableCheckpoints", true) + + for (const key of ["apiProvider", "openAiApiKey", "openAiBaseUrl", "openAiModelId"]) { + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith(key, expect.anything()) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith(key, expect.anything()) + } + + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignoring provider-profile setting 'apiProvider'"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignoring provider-profile setting 'openAiApiKey'"), + ) + }) + + it("still applies a settings key that is a provider field and a global setting", async () => { + // "rateLimitSeconds" belongs to both schemas. The guard is about profile ownership, not a + // blanket block on the settings payload, so an ordinary global key must still be written. + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { rateLimitSeconds: 30 }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("rateLimitSeconds", 30) + expect(mockClineProvider.log).not.toHaveBeenCalledWith( + expect.stringContaining("Ignoring provider-profile setting 'rateLimitSeconds'"), + ) + }) +}) + +describe("webviewMessageHandler - profile deletion routes through the provider mutation path", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // providerSettingsManager is readonly on the class and this suite reassigns it, so the + // fixture type is the writable view of the same object - the same pattern the + // webviewDidLaunch suite above uses. + type Writable = { -readonly [K in keyof T]: T[K] } + type DeletionProviderFixture = { providerSettingsManager: Partial> } + const writable = mockClineProvider as DeletionProviderFixture + + // This suite reassigns both providerSettingsManager and deleteProviderProfile, the way + // the rename and launch suites do for their own members, so it has to put them back: + // otherwise the doomed-profile list below leaks into any later test that reads either + // member without setting its own value. + const originalProviderSettingsManager = writable.providerSettingsManager + const originalDeleteProviderProfile = mockClineProvider.deleteProviderProfile + + afterEach(() => { + writable.providerSettingsManager = originalProviderSettingsManager + mockClineProvider.deleteProviderProfile = originalDeleteProviderProfile + }) + + it("deletes through ClineProvider so the queue, compensation and sibling re-pin still run", async () => { + writable.providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter }, + { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ]), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + mockClineProvider.deleteProviderProfile = vi.fn().mockResolvedValue(undefined) + // The handler compares the dialog answer against the TRANSLATED string, not the key: + // t comes from src/i18n, so the stub has to return the same value t produces here. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "doomed-profile" }) + + expect(mockClineProvider.deleteProviderProfile).toHaveBeenCalledWith({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + }) + expect(mockClineProvider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("reports the delete failure without falling back to a direct manager delete", async () => { + writable.providerSettingsManager = { + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter }, + ]), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + mockClineProvider.deleteProviderProfile = vi + .fn() + .mockRejectedValue(new Error("You cannot delete the last profile")) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "doomed-profile" }) + + expect(mockClineProvider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.delete_api_config") + }) + + it("warns that the profile was deleted when only the replacement activation fails", async () => { + writable.providerSettingsManager = { + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter }, + ]), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + mockClineProvider.deleteProviderProfile = vi + .fn() + .mockRejectedValue(new ProfileDeletionActivationError("keeper-profile", new Error("disk full"))) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "doomed-profile" }) + + // The deletion landed: the user must not be told the delete failed. + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + "The profile was deleted, but activating replacement profile 'keeper-profile' failed.", + ) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalledWith("common:errors.delete_api_config") + }) +}) + describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { beforeEach(() => { vi.clearAllMocks() @@ -2567,6 +3015,518 @@ describe("zooCodeSignOut", () => { }) }) +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 + } + 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() + }) + + it("re-points views pinned to the old name before the old name is deleted", async () => { + const order: string[] = [] + provider.providerSettingsManager = { + 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("restores the pre-rename profile when the activation after a rename fails", async () => { + provider.providerSettingsManager = { + 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), + } + provider.repointViewLocalStateAfterProfileRename = vi.fn().mockResolvedValue(undefined) + provider.activateProviderProfile = vi.fn().mockRejectedValue(new Error("activation failed")) + vi.mocked(mockClineProvider.setValue).mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // The rename committed (new record stored, old name deleted) and the failed activation's + // own rollback already put the shared selection, the shared settings, this view's pin and + // this view's overlay back on old-profile. Writing newName to the shared selection there + // would pair that name with settings belonging to another profile, so the handler restores + // the record the rename deleted, pulls the views back to it with that record, and drops the + // record this rename authored. Every pair stays a pair. + expect(mockClineProvider.providerSettingsManager.saveConfig).toHaveBeenCalledWith( + "old-profile", + expect.objectContaining({ id: "profile-id" }), + ) + expect(provider.repointViewLocalStateAfterProfileRename).toHaveBeenLastCalledWith( + "new-profile", + "old-profile", + expect.objectContaining({ id: "profile-id" }), + ) + expect(mockClineProvider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + // The rename legitimately deletes old-profile before the activation runs, so the ordering is + // the assertion that matters: restoring the record before that delete would let the delete + // undo the restore and leave the selection naming a missing profile again. + const deleteOldCall = vi + .mocked(mockClineProvider.providerSettingsManager.deleteConfig) + .mock.calls.findIndex(([name]) => name === "old-profile") + const restoreOldCall = vi + .mocked(mockClineProvider.providerSettingsManager.saveConfig) + .mock.calls.findIndex(([name]) => name === "old-profile") + expect(restoreOldCall).toBeGreaterThan(deleteOldCall) + // The fallback must not move the shared selection on its own, and it must not resurrect a + // delete of the name it just restored. + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith("currentApiConfigName", expect.anything()) + }) + + it("warns the user when a rename's activation rollback cannot restore the pre-rename state", async () => { + // Review row DOSK-bwM6z6Vop: the two rename compensations that give up show the user the + // rename_api_config warning; this one - the activation failure whose own rollback also fails - + // only logged, so the inconsistent state was visible to nobody but the log file. The existing + // key is reused across the locale bundles; no new string is invented for it. + provider.providerSettingsManager = { + 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), + } + // The forward re-point moves the views old-profile -> new-profile; the rollback calls it the + // other way round. Rejecting only the reverse direction is what makes the rollback itself fail. + const repoint = vi.fn(async (from: string) => { + if (from === "new-profile") { + throw new Error("rollback failed") + } + }) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockRejectedValue(new Error("activation failed")) + vi.mocked(mockClineProvider.setValue).mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + expect(repoint).toHaveBeenCalledWith( + "new-profile", + "old-profile", + expect.objectContaining({ id: "profile-id" }), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Rename activation failed and the pre-rename state could not be restored"), + ) + // The log is not something the user can act on: the rename committed, its activation failed and + // the rollback failed too, so both names can stay stored and the user has to be told - the same + // way the sibling compensation catch tells them. + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining("rename")) + // The localized string, not the raw rollback error the log carries. + expect(vscode.window.showWarningMessage).not.toHaveBeenCalledWith(expect.stringContaining("rollback failed")) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("rename")) + }) + it("leaves another rename's record alone when its own compensation runs", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + // The store answers newName with a different record than the one this rename + // wrote: a rename in another webview (its own ProviderSettingsManager instance) + // stored its own profile under the same name first. + getProfile: vi.fn(async ({ name }: { name: string }) => + name === "new-profile" + ? { id: "other-rename-id", apiProvider: providerIdentifiers.anthropic } + : { id: "profile-id", apiProvider: providerIdentifiers.anthropic }, + ), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + // Only the forward re-point fails; the compensation's reverse re-point has to succeed + // for the guard under test to be reached at all. + provider.repointViewLocalStateAfterProfileRename = vi.fn().mockRejectedValueOnce(new Error("repoint failed")) + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // The compensation re-points the views it moved, but it must not delete a record it + // never created: the id under newName is not this rename's id. + expect(provider.repointViewLocalStateAfterProfileRename).toHaveBeenCalledWith("new-profile", "old-profile", { + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + }) + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalledWith("new-profile") + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Rename compensation skipped for 'new-profile'"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("rolls the new profile back when the re-point fails", async () => { + provider.providerSettingsManager = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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")) + // A log line is not something the user can act on: when the compensation fails the views + // and the stored records can disagree, so the failure is surfaced as well. + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining("rename")) + }) + + it("leaves a newer save under the new name alone when the rename compensates", async () => { + // saveConfig keeps a profile's id, so a save that another view makes under newName after this + // rename wrote it carries the same id and different settings. Compensation that only compared + // ids would delete that newer save. + provider.providerSettingsManager = { + 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", + }) + .mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.openai, + apiModelId: "someone-elses-model", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + 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, apiModelId: "new-model" }, + }) + + // The deletion that triggered the compensation is still attempted, and the rollback re-point + // still runs, but the record another view saved under newName survives the compensation. + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("old-profile") + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalledWith("new-profile") + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("no longer holds the settings this rename wrote"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("still removes the new profile when the stored settings are the ones the rename wrote", async () => { + // The equality check must not become a blanket refusal: on the ordinary compensation path the + // stored record is exactly what this rename wrote, so it is still removed. + provider.providerSettingsManager = { + 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", + }) + .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") + } + }), + } + 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, apiModelId: "new-model" }, + }) + + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("old-profile") + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(mockClineProvider.log).not.toHaveBeenCalledWith( + expect.stringContaining("no longer holds the settings this rename wrote"), + ) + }) +}) + describe("webviewMessageHandler - kimiCodeSignIn", () => { beforeEach(() => { vi.clearAllMocks() @@ -2804,6 +3764,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 +3950,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/profileDeletionError.ts b/src/core/webview/profileDeletionError.ts new file mode 100644 index 0000000000..86a25a790a --- /dev/null +++ b/src/core/webview/profileDeletionError.ts @@ -0,0 +1,19 @@ +/** + * The deletion of a profile committed, but completing the activation of the replacement + * profile failed. Distinct from a failed deletion so the caller reports "deleted, but the + * replacement failed to activate" instead of "could not delete", and so the committed + * state stays posted and in place rather than being rolled back or misreported. + * + * Lives in its own module so webviewMessageHandler can discriminate on it without + * importing ClineProvider at runtime (the module graph there pulls in the whole task + * stack, which the message-handler unit tests mock around). + */ +export class ProfileDeletionActivationError extends Error { + constructor( + public readonly replacementProfileName: string, + public readonly activationError: unknown, + ) { + super(`The profile was deleted, but activating replacement profile '${replacementProfileName}' failed.`) + this.name = "ProfileDeletionActivationError" + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4adb29264b..f8878fe16c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -4,6 +4,7 @@ import * as os from "os" import * as fs from "fs/promises" import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js" import pWaitFor from "p-wait-for" +import deepEqual from "fast-deep-equal" import * as vscode from "vscode" import { @@ -23,6 +24,8 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + providerSettingsSchema, + globalSettingsSchema, retiredProviderIdentifiers, LmStudioModelsMessageType, OllamaModelsMessageType, @@ -41,6 +44,7 @@ import { isInvalidDisplayValue } from "../config/displaySettings" import { importRooTaskHistory } from "../task-persistence/importRooTaskHistory" import { ClineProvider } from "./ClineProvider" +import { ProfileDeletionActivationError } from "./profileDeletionError" import { findOriginalContent } from "./stripOriginalContent" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { assertRecordedCheckpoint } from "../checkpoints/recordedCheckpoint" @@ -80,6 +84,7 @@ import { fileExistsAtPath } from "../../utils/fs" import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts" import { searchCommits } from "../../utils/git" import { exportSettings, importSettingsWithFeedback } from "../config/importExport" +import { ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { getOpenAiModels } from "../../api/providers/openai" import { getVsCodeLmModels } from "../../api/providers/vscode-lm" import { getGitHubCopilotViewState } from "../../services/language-models/copilotViewState" @@ -105,6 +110,27 @@ import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +// Keys that only host-side, validated paths may write. `apiConfiguration` and +// `listApiConfigMeta` are resolved by ProviderSettingsManager, which validates a +// configuration before it can become active, and `viewStates` is the per-view durable +// map that ClineProvider owns. The generic settings loop below writes straight into +// the shared store and into this view's local buffer - and getState() prefers the +// buffer - so a webview payload must not be allowed to carry them. +const HOST_OWNED_SETTINGS = new Set(["apiConfiguration", "listApiConfigMeta", "viewStates", "currentApiConfigName"]) + +// RooCodeSettings is GlobalSettings & ProviderSettings, so a payload can also carry flat profile +// fields ("apiProvider", "openAiApiKey", "openAiBaseUrl", ...) without leaving the message type. +// Those keys are only ever applied by the profile-management paths, which run them through +// ProviderSettingsManager before a configuration may become active; the generic loop below writes +// straight into the shared store, so an injected profile field would reach getState() and the next +// task unvalidated. Every settings payload the webview actually sends was checked (packages/types +// vs webview-ui) and carries global keys only, so a profile-only key here is never a real setting. +// Keys that are genuinely global too (rateLimitSeconds) stay on the ordinary route. +const GLOBAL_SETTINGS_KEYS = new Set(Object.keys(globalSettingsSchema.shape)) +const PROVIDER_PROFILE_SETTINGS = new Set( + Object.keys(providerSettingsSchema.shape).filter((key) => !GLOBAL_SETTINGS_KEYS.has(key)), +) + // Serializes handling of "telemetrySetting" messages. Each invocation reads the previous // setting, awaits a persistence write, then applies the new live telemetry state -- with no // serialization, two rapid messages (e.g. a fast toggle) can interleave across those awaits: @@ -593,7 +619,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 +681,47 @@ export const webviewMessageHandler = async ( } } - const currentConfigName = getGlobalState("currentApiConfigName") + let repairedViewPin = false + 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) { + // The re-pin changes this view's selection, so the merged state has to be posted + // once the list is refreshed: otherwise the webview keeps rendering the profile + // that no longer resolves. + repairedViewPin = true + // 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) + // 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 + } } } } @@ -661,6 +730,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("listApiConfigMeta", listApiConfig), await provider.postMessageToWebview({ type: "listApiConfig", listApiConfig }), ]) + + if (repairedViewPin) { + await provider.postStateToWebview() + } }) .catch((error) => provider.log( @@ -702,6 +775,7 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + } case "webviewHeartbeat": // Confirm renderer liveness and advance the watchdog recovery health window. provider.updateWebviewHeartbeat() @@ -780,6 +854,26 @@ export const webviewMessageHandler = async ( continue } + if (PROVIDER_PROFILE_SETTINGS.has(key)) { + // Same boundary as the host-owned keys above, one level lower: the key is a + // provider-settings field, and only a validated profile may carry it. + provider.log( + `[updateSettings] Ignoring provider-profile setting '${key}' supplied by the webview; it must go through the profile-management path.`, + ) + continue + } + + if (HOST_OWNED_SETTINGS.has(key)) { + // Boundary check, not normalization: these keys reach the runtime through the + // profile-management paths, which validate them first. Applying them here would + // let an injected webview swap in an attacker-controlled API configuration that + // getState() then serves to every consumer. + provider.log( + `[updateSettings] Ignoring host-owned setting '${key}' supplied by the webview; it must go through the profile-management path.`, + ) + continue + } + let newValue = value if (key === "language") { @@ -1709,9 +1803,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 } @@ -2513,18 +2605,169 @@ 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. + if (await provider.providerSettingsManager.hasConfig(newName)) { + throw new Error(`A profile named '${newName}' already exists`) + } + // 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 + + // Only remove a record this rename actually wrote. Two renames in the same extension + // host use different ProviderSettingsManager instances, so one rename's existence check + // and save are not ordered against the other's: both can see newName as absent, and a + // compensation here would then delete the record the other rename stored. The id is the + // record's identity, and every rename carries the id it loaded from oldName. + // + // Identity alone is not enough: saveConfig keeps a profile's id, so a save of newName + // that lands after this rename's write - another view editing the same profile - stores + // different settings under the same id, and an id-only check would let compensation + // delete that newer save. Compensation therefore also requires that the stored settings + // are still the ones this rename wrote. Before the read-back succeeds there is no such + // copy to compare against, and this rename had only its own write to newName, so the id + // check stays the rule there. + let writtenByThisRename: typeof previousRecord | undefined + const removeOwnRenameRecord = async (): Promise => { + let stored: typeof previousRecord | undefined + try { + const { name: _storedName, ...storedRecord } = + await provider.providerSettingsManager.getProfile({ name: newName }) + stored = storedRecord + } catch (readError) { + if (readError instanceof ProviderSettingsNotFoundError) { + // The record is gone: there is nothing of this rename left to undo, and a + // blind delete here would target whatever stored newName next. + return + } + // Any other read failure leaves the previous behaviour: this rename did store + // newName, so its compensation still removes it. + } + if (stored !== undefined && stored.id !== id) { + provider.log( + `Rename compensation skipped for '${newName}': the stored record (id ${stored.id}) is not the one this rename wrote (id ${id}).`, + ) + return + } + if (stored !== undefined && writtenByThisRename && !deepEqual(stored, writtenByThisRename)) { + provider.log( + `Rename compensation skipped for '${newName}': the stored record still carries this rename's id (${id}) but no longer holds the settings this rename wrote.`, + ) + return + } + await provider.providerSettingsManager.deleteConfig(newName) + } // Create a new configuration with the new name and old ID. await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) - // Delete the old configuration. - await provider.providerSettingsManager.deleteConfig(oldName) + // 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 + writtenByThisRename = renamedRecord + } catch (error) { + try { + await removeOwnRenameRecord() + } catch (cleanupError) { + provider.log( + `Rename rollback failed to remove '${newName}': ${JSON.stringify(cleanupError, Object.getOwnPropertyNames(cleanupError), 2)}`, + ) + } + throw error + } + + // 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 removeOwnRenameRecord() + } catch (compensationError) { + // The reverse re-point failed too: the views and the stored records can now + // disagree, and a log line is not something the user can act on. + vscode.window.showWarningMessage(t("common:errors.rename_api_config")) + 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 removeOwnRenameRecord() + } catch (compensationError) { + // Same inconsistent state as above, reached from the delete step. + vscode.window.showWarningMessage(t("common:errors.rename_api_config")) + 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. - await provider.activateProviderProfile({ name: newName }) + // currently activated provider profile. The rename has already committed by + // now (the new record is stored, the old name is deleted, and every live + // view was re-pointed). If that activation fails its own rollback puts the + // shared selection, the shared settings, this view's pin and this view's + // overlay back on oldName - the record this rename just deleted. Re-pointing + // the selection at newName would pair that name with settings that still + // belong to another profile, so the failure completes the rollback instead: + // restore the record this rename deleted, re-point the views at it with the + // record they were pinned to, and drop the record this rename authored. Every + // pair stays a pair, and nothing points at a name that does not resolve. + try { + await provider.activateProviderProfile({ name: newName }) + } catch (error) { + try { + await provider.providerSettingsManager.saveConfig(oldName, previousRecord) + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await removeOwnRenameRecord() + } catch (rollbackError) { + // Neither the rename nor its rollback landed, so both names can stay stored: the same + // inconsistent state the catch above warns about. The user needs to know that, not + // only the log, and the existing rename_api_config string is the honest one to show. + vscode.window.showWarningMessage(t("common:errors.rename_api_config")) + provider.log( + `Rename activation failed and the pre-rename state could not be restored: ${JSON.stringify(rollbackError, Object.getOwnPropertyNames(rollbackError), 2)}`, + ) + } + throw error + } } catch (error) { provider.log( `Error rename api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2572,19 +2815,45 @@ export const webviewMessageHandler = async ( const oldName = message.text - const newName = (await provider.providerSettingsManager.listConfig()).filter( - (c) => c.name !== oldName, - )[0]?.name + const profileToDelete = (await provider.providerSettingsManager.listConfig()).find( + (profile) => profile.name === oldName, + ) - if (!newName) { + if (!profileToDelete) { vscode.window.showErrorMessage(t("common:errors.delete_api_config")) return } + // Route the deletion through ClineProvider.deleteProviderProfile. That is where + // the profile list, the live views' pins and overlays, the closed views' + // persisted pins, the last-profile guard, and the compensation for a + // half-finished deletion live. Deleting the manager record here and activating + // a survivor afterwards leaves every one of those surfaces naming a profile + // that no longer exists, and the survivor's settings are never written to the + // shared store. try { - await provider.providerSettingsManager.deleteConfig(oldName) - await provider.activateProviderProfile({ name: newName }) + // Route through the provider: deleteProviderProfile serialises against the other + // profile mutations, snapshots and compensates the shared stores, and re-pins every + // sibling view still pinned to the deleted profile. Calling + // providerSettingsManager.deleteConfig here bypasses all of that and leaves those + // views with stale in-memory and persisted state. + await provider.deleteProviderProfile(profileToDelete) } catch (error) { + if (error instanceof ProfileDeletionActivationError) { + // The deletion itself landed; only the replacement activation failed. Reporting it as + // errors.delete_api_config would tell the user the profile is still there when it + // is not, prompting a second delete of the wrong thing. + provider.log( + `Profile was deleted, but replacement activation failed: ${ + error.activationError instanceof Error + ? error.activationError.message + : String(error.activationError) + }`, + ) + vscode.window.showWarningMessage(error.message) + break + } + provider.log( `Error delete api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) @@ -2754,9 +3023,18 @@ export const webviewMessageHandler = async ( } } - // Switch back to default mode after deletion - await updateGlobalState("mode", defaultModeSlug) + // Switch back to default mode after deletion. Route through provider.setValue so + // the per-view mutation path mirrors the shared write: a view that pinned the + // deleted mode would otherwise keep it in its own buffer (getState() and any new + // task created there) while the shared store says default. + await provider.setValue("mode", defaultModeSlug) await provider.postStateToWebview() + + // Sibling views are not covered by the write above: a view that pinned the deleted + // slug still holds it in its own per-view state, so its getState() reports a mode + // that no longer exists and any task created there stores it. Clear only the + // overrides that name the deleted slug, with the same fan-out resetState() uses. + await provider.clearViewLocalModeForDeletedSlug(message.slug) } break case "exportMode": diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index e59c4b6cfc..69b08043de 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -921,12 +921,12 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 195 + "count": 193 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 27 + "count": 25 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { diff --git a/src/package.json b/src/package.json index 8a30d614e1..d5bf917721 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,24 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "false" + } ] }, "keybindings": [ diff --git a/webview-ui/src/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..d0c9ef19c0 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -22,8 +22,16 @@ import { mergeExtensionState, createInitialExtensionState, } from "../ExtensionStateContext" +import { vscode } from "@src/utils/vscode" import { StringCache } from "@/utils/stringCache" +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + getViewStateId: vi.fn(() => "view-a"), + }, +})) + const TestComponent = () => { const { allowedCommands, @@ -140,8 +148,97 @@ const InitialStateTestComponent = () => { ) } +const ViewLocalStateTestComponent = () => { + const { mode, setMode, currentApiConfigName, setCurrentApiConfigName } = useExtensionState() + + return ( +
+
{mode}
+
{currentApiConfigName}
+ + +
+ ) +} + describe("ExtensionStateContext", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + afterEach(() => vi.restoreAllMocks()) + + 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 } 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..5e2aefb3b8 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,406 @@ +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 viewStateId longer than the host accepts with a generated one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const oversized = "persisted-view".repeat(8) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: oversized }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // ClineProvider.setViewStateId refuses anything longer than its 64-character bound and logs + // "Ignoring an oversized viewStateId", so reusing a stored value that long means the view + // never registers a durable id and its pre-launch entry stays under the temporary id on + // every launch with nothing that regenerates it. Only #2226's host-minted claim token makes + // this bound unnecessary on the webview side. + expect(oversized.length).toBeGreaterThan(64) + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-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("creates a new viewStateId when the stored state parses to JSON null", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "after-null-view") }, + }) + const storage = createMockStorage({ vscodeState: "null" }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("after-null-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "after-null-view" }) + }) + + it("replaces an empty persisted viewStateId with a freshly created one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "refilled-view" }) + }) + + it("replaces a non-object persisted state with a freshly created viewStateId", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "replaced-string-view") }, + }) + // A persisted JSON string is truthy but not an object: the guard must keep it out of + // the fresh state, so the persisted record contains only the new viewStateId. + const storage = createMockStorage({ vscodeState: JSON.stringify("stale-string-state") }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("replaced-string-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "replaced-string-view" }) + }) + + it("returns the normalized id when the persisted id needs the extension-side cleanup", () => { + const randomUUID = vi.fn(() => "should-not-be-generated") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // ClineProvider.setViewStateId trims and rewrites unsafe characters, so the + // webview must report the same normalized id instead of the raw stored value. + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " side 1 " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("side_1") + expect(wrapper.getViewStateId()).toBe("side_1") + expect(randomUUID).not.toHaveBeenCalled() + }) + + it("generates a fresh id when the persisted id is whitespace-only", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "ws-refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("ws-refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "ws-refilled-view" }) + }) + + it("generates a fresh id when the persisted id is the __proto__ string", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "proto-refused-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "__proto__" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("proto-refused-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "proto-refused-view" }) + }) + + it("keeps one generated id when a write fails while stale persisted state stays readable", () => { + const randomUUID = vi.fn().mockReturnValueOnce("gen-one").mockReturnValueOnce("gen-two") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // Storage still serves a stale persisted state (e.g. pre-identity webview state + // without a viewStateId) while setItem throws: without the write-failure flag, + // every getViewStateId call would re-read the stale JSON and generate a fresh id. + const storage: MockStorage = { + getItem: vi.fn(() => JSON.stringify({ mode: "architect" })), + setItem: vi.fn(() => { + throw new Error("write denied") + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + const first = wrapper.getViewStateId() + const second = wrapper.getViewStateId() + + expect(first).toBe("gen-one") + expect(second).toBe("gen-one") // no flap: the in-memory state stays authoritative + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "gen-one" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("keeps the in-memory viewStateId when setItem is unavailable while reads still work", () => { + const randomUUID = vi.fn().mockReturnValueOnce("no-setitem-view").mockReturnValueOnce("flapped-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + const storage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + // The persisted JSON never gained the id: the in-memory state stays authoritative + // instead of re-reading the stale JSON on every call. + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "no-setitem-view" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("treats persisted storage as authoritative again once a write recovers", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "recovered-view") }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + let writesFail = true + const storage: MockStorage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + if (writesFail) { + throw new Error("write denied") + } + backing[key] = value + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // The first generation cannot be persisted: the in-memory state is authoritative. + expect(wrapper.getViewStateId()).toBe("recovered-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "recovered-view" }) + + // A later write succeeds: the flag clears and the persisted JSON is + // authoritative again for reads. + writesFail = false + wrapper.setState({ mode: "code", viewStateId: "recovered-view" }) + expect(backing.vscodeState).toBe(JSON.stringify({ mode: "code", viewStateId: "recovered-view" })) + // An external writer refreshes the persisted record: a recovered wrapper must see + // it (flag cleared); a still-flagged wrapper would keep the in-memory copy. + backing.vscodeState = JSON.stringify({ mode: "code", viewStateId: "recovered-view", external: true }) + expect(wrapper.getState()).toEqual({ mode: "code", viewStateId: "recovered-view", external: true }) + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..583f3923f3 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 { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + // The host enforces the same bound in ClineProvider.setViewStateId (its + // MAX_VIEW_STATE_ID_LENGTH); the webview must not hand back an id the host would refuse. + 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. + * Follow-up #2226 replaces this client-side validation with a host-minted claim token, + * later in the merge order; until that lands this path has to apply the host's bound. + */ + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + // Mirror the ClineProvider.setViewStateId validation so the webview never + // registers an id the extension would reject: trim, replace unsafe + // characters, and drop whitespace-only values plus every Object.prototype + // member name. "__proto__" alone is not enough: ids like "constructor" or + // "toString" survive the host's normalization and would be refused there + // forever, stranding this view's pre-launch entry under the temporary id. + const normalizedViewStateId = + typeof existingViewStateId === "string" ? existingViewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") : "" + + // A stored value that breaks the host's length bound - an older build, a manual edit, + // a corrupted state - is one the host logs 'Ignoring an oversized viewStateId' for and + // refuses on every launch, which would strand this view's pre-launch entry forever. Fall + // through to a freshly generated id instead of reusing it. + 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 } }