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..162ff3a8e8 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" @@ -92,12 +94,12 @@ vi.mock("../../i18n", () => ({ t: (key: string) => key, })) -vi.mock("../../services/ripgrep/diagnostic", () => ({ - registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), +vi.mock("../../services/diagnostics", () => ({ + createDiagnosticsReport: vi.fn(), })) -vi.mock("../../services/diagnostics", () => ({ - createDiagnosticsReport: vi.fn().mockResolvedValue(undefined), +vi.mock("../../services/ripgrep/diagnostic", () => ({ + registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }), })) describe("getVisibleProviderOrLog", () => { @@ -140,16 +142,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 +181,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( @@ -198,6 +202,25 @@ describe("registerCommands handlers", () => { }) }) + it("creates diagnostics from all provider instances without requiring a visible provider", async () => { + // Restores the case 375e25546 dropped when it took this branch copy of the spec over + // main's. The command handler hands every live instance to the report builder and never + // consults the visible provider (registerCommands.ts:249-254), so a report still forms when + // no view is visible; the payload assertion is what pins that wiring. + 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.each([false, true])("retries the appropriate provider when a visible provider exists: %s", async (visible) => { vi.mocked(ClineProvider.getVisibleInstance).mockReturnValue(visible ? mockVisibleProviderInstance : undefined) await handlers["zoo-code.retryWebviewRecovery"]() @@ -267,59 +290,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 +470,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() }) - // Representative coverage for the .catch arm on all five void-prefixed - // postMessageToWebview sites in registerCommands.ts (settingsButtonClicked - // posts twice, plus historyButtonClicked, marketplaceButtonClicked, and - // acceptInput). Each handler is synchronous, so the .catch arm runs on a - // microtask; setImmediate ensures all microtasks are flushed before we assert. The + it("focusInput 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" }) + }) + + // 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 +639,126 @@ 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 }) + + const handler = commandHandlers["zoo-code.plusButtonClicked"]() + // 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() + + settleGate() + await handler + + 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(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") + expect(mockProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProvider.refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) - it("plusButtonClicked is a no-op when no visible provider", async () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + it("plusButtonClicked skips the sidebar focus and the view wait when the view is already launched", async () => { + // The "view/title" menu path invokes this command after resolveWebviewView has handed the + // provider its view, so the focus-and-wait branch must not run: re-focusing the pane and awaiting a + // resolution that already happened is work the menu click never asked for. The gate test above + // covers the opposite branch, where the view is not launched yet. + const launchedProvider = Object.assign({} as ClineProvider, { + isViewLaunched: true, + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + whenViewResolved: vi.fn().mockResolvedValue(undefined), + }) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ context: mockContext, outputChannel: mockOutputChannel, provider: launchedProvider }) - // Should not throw even with no visible provider - await handlers["zoo-code.plusButtonClicked"]() + await commandHandlers["zoo-code.plusButtonClicked"]() + + // The guard is the whole behaviour under test: without it the handler focuses the pane and waits on + // a resolution that has already landed, and every action still posts - so the assertions below + // are what pin the branch, not the posting. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + expect.stringContaining("SidebarProvider.focus"), + ) + expect(launchedProvider.whenViewResolved).not.toHaveBeenCalled() + // Both actions must still post, in the same order as the un-launched branch. + expect(launchedProvider.postMessageToWebview).toHaveBeenNthCalledWith(1, { + type: "action", + action: "chatButtonClicked", + }) + expect(launchedProvider.postMessageToWebview).toHaveBeenNthCalledWith(2, { + 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 +828,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 +841,556 @@ 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(), + }) + + expect(mockPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("re-points the tracked tab ref at the panel that becomes active", async () => { + // Panel A is created first and tracked... + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // ...then panel B is created, which re-points the tracked tab ref. + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Activating A must reassign the tracked tab ref to A's panel... + const stateChange = (panelA.onDidChangeViewState as Mock).mock.calls[0]![0] as (e: { + webviewPanel: vscode.WebviewPanel + }) => void + stateChange({ webviewPanel: { ...panelA, active: true, visible: true } }) + + // ...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), + } + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + (view as { marker?: string }).marker === "panel-A" ? mockProviderA : undefined, + ) + const handlers = new Map unknown>() + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + handlers.set(id, cb) + return { dispose: vi.fn() } + }, + ) + const mockSidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: mockSidebarProvider, + }) + + await handlers.get("zoo-code.plusButtonClickedInTab")!() + + expect(mockProviderA.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { + ;(MdmService.getInstance as Mock).mockImplementation(() => { + throw new Error("MDM service not initialized") + }) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The creation must survive the MDM lookup failure: the provider is + // constructed with an undefined MDM service and the tab panel is + // still created. + const ctor = vi.mocked(ClineProvider) + expect(ctor.mock.instances[0]).toBeDefined() + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + undefined, + ) + expect(provider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The fallback is observable in the output channel. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openClineInNewTab] MDM service unavailable, continuing without it: Error: MDM service not initialized", + ) + }) + + it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { + // The vscode mock factory declares a mutable visibleTextEditors slot that the + // readonly public API type hides, so seed it through Object.assign. + Object.assign(vscode.window, { visibleTextEditors: [] }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.lockEditorGroup") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + vscode.ViewColumn.Two, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [mockContext.extensionUri], + }, + ) + + // The panel icon points at the extension's asset files. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + iconPath?: { light: { path: string }; dark: { path: string } } + } + expect(panel.iconPath).toEqual({ + light: { path: "assets/icons/panel_light.png" }, + dark: { path: "assets/icons/panel_dark.png" }, + }) + }) + + it("treats editors without a viewColumn as column 0 when computing the target column", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixture keeps that single field. + const editorWithoutColumn = Object.assign({} as vscode.TextEditor, { viewColumn: undefined }) + Object.assign(vscode.window, { + visibleTextEditors: [editorWithoutColumn], + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // lastCol falls back to 0, so the panel lands on column 1 instead of + // opening a new editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 1, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("places the tab panel one column right of the rightmost visible editor", async () => { + // openClineInNewTab only reads viewColumn from each editor, so the + // fixtures keep that single field. + Object.assign(vscode.window, { + visibleTextEditors: [ + Object.assign({} as vscode.TextEditor, { viewColumn: 1 }), + Object.assign({} as vscode.TextEditor, { viewColumn: 3 }), + ], + }) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // lastCol is 3, so the panel lands on column 4 without opening a new + // editor group. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.newGroupRight") + expect(vscode.window.createWebviewPanel).toHaveBeenCalledWith( + "zoo-code.TabPanelProvider", + "Zoo Code", + 4, + expect.objectContaining({ enableScripts: true }), + ) + }) + + it("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { + // MdmService has a private constructor, so pin a sentinel stand-in. + const mockMdm = Object.assign({} as MdmService, { name: "mock-mdm" }) + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const ctor = vi.mocked(ClineProvider) + expect(ctor).toHaveBeenCalledTimes(1) + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + mockMdm, + ) + expect(provider).toBe(ctor.mock.instances[0]) + }) + + it("posts didBecomeVisible only for visible state changes and clears the tracked tab on dispose", async () => { + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Retain the panel returned during creation and pin the tracked tab + // against it with identity (not a weak defined check), so a wrong or + // duplicated tracked panel fails before the dispose assertions. + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + onDidChangeViewState: Mock + onDidDispose: Mock + } + expect(getPanel()).toBe(panel) + + const stateHandler = panel.onDidChangeViewState.mock.calls[0][0] as (event: { + webviewPanel: { visible: boolean; webview: { postMessage: (message: unknown) => void } } + }) => void + const visibleEvent = { webviewPanel: { visible: true, webview: { postMessage: vi.fn() } } } + stateHandler(visibleEvent) + expect(visibleEvent.webviewPanel.webview.postMessage).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + + const hiddenEvent = { webviewPanel: { visible: false, webview: { postMessage: vi.fn() } } } + stateHandler(hiddenEvent) + expect(hiddenEvent.webviewPanel.webview.postMessage).not.toHaveBeenCalled() + + const disposeHandler = panel.onDidDispose.mock.calls[0][0] as () => void + disposeHandler() + expect(getPanel()).toBeUndefined() + }) + + it("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("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..4cd762e8be 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,76 @@ 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 + } + } +} + +// 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 + } + } + // (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 +394,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 } @@ -291,6 +442,13 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr newPanel.onDidChangeViewState( (e) => { const panel = e.webviewPanel + // Re-point the tracked tab ref at the panel the user is actually + // looking at: several tab panels can stay visible at once, but + // only the active one is the current tab, and the title-bar + // commands must resolve that instance, not the last created one. + if (panel.active) { + setPanel(panel, "tab") + } if (panel.visible) { panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx } @@ -299,7 +457,9 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr context.subscriptions, // Register listener for disposal ) - // Handle panel closing events. + // Handle panel closing events: clear the tracked ref only if this panel + // is still the tracked one, so a late disposal of an already-replaced + // panel cannot clobber the replacement's ref. newPanel.onDidDispose( () => { // A recovery may already have installed a replacement panel. 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.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 5d78e16351..33e4a9038b 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -1170,4 +1170,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..700b86bbae 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 @@ -936,6 +936,45 @@ describe("ProviderSettingsManager", () => { }) }) + describe("GetProfile", () => { + it("rejects a missing profile name with the typed not-found signal", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: { id: "default-id" } }, + }), + ) + + // Callers branch on the error type to treat an already-deleted profile as an idempotent + // no-op, so the not-found type and its message both have to survive getProfile's own + // catch instead of being rewrapped in a generic failure. + await expect(providerSettingsManager.getProfile({ name: "gone" })).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) + await expect(providerSettingsManager.getProfile({ name: "gone" })).rejects.toThrow( + "Config with name 'gone' not found", + ) + }) + + it("rejects a missing profile id with the typed not-found signal", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: { id: "default-id" } }, + }), + ) + + // The id lookup has its own not-found branch: a caller that resolved an id earlier and + // finds the profile gone has to be able to tell that from a storage failure too. + await expect(providerSettingsManager.getProfile({ id: "vanished-id" })).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) + await expect(providerSettingsManager.getProfile({ id: "vanished-id" })).rejects.toThrow( + "Config with ID 'vanished-id' not found", + ) + }) + }) + describe("DeleteConfig", () => { it("should delete existing config", async () => { const existingConfig: ProviderProfiles = { @@ -973,6 +1012,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 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 6cec3bdbe7..c11d4bf2ed 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, @@ -132,7 +134,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 +162,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 +247,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) { @@ -248,6 +322,29 @@ 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 or clear). + * 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. + * + * A tombstone is only ever consulted for an id in some provider's current + * prunedViewIdsDuringActivation list, and that list is rebuilt at the start of every + * activation group, so the set has a natural bound: each provider records the tombstones it + * added and releases them once no live group guards the id. A tombstone that a live group + * still guards is handed to clearedViewStateIdsWithoutOwner and drained by the next group, + * which keeps the set from growing for as long as the host runs. + */ + private static clearedViewStateIds = new Set() + /** + * Tombstones whose provider was disposed while an activation group still guarded the id. + * Drained by the next activation group, once no live pruned-eviction list names them. + */ + private static clearedViewStateIdsWithoutOwner = new Set() private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< string, @@ -337,51 +434,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 +510,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 + + /** + * Selections made before the webview registered its stable id. The session-local id cannot + * serve as a persisted key: it restarts from 0 on every host start, so an entry written under + * it is not attributable to this view and is never loadable by the next session's provider. The + * change waits here and is written by the registration flush, under the name a later session + * can actually read. + */ + private pendingViewState: Partial | undefined + /** + * The tombstones this instance added to the shared set, so dispose can release them. + */ + private addedClearedViewStateIds = new Set() + + /** + * 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[] = [] + + /** + * 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 = "nov-2026-v3.96.0-settings-costs-reliability" // v3.96.0 chat settings, provider costs, and reliability public readonly providerSettingsManager: ProviderSettingsManager @@ -430,6 +582,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 +620,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -599,6 +759,506 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy. + * When fresh is set, the map is read directly from globalState (bypassing the + * ContextProxy cache) so serialized writes never observe a stale in-memory value. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return Object.create(null) + } + + // Copy onto a null prototype. 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) + } + + /** + * 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. A change captured while + * the provider still holds its session-local (pre-registration) id is not written at all: + * temporary ids restart from 0 on every host start, so an entry under one is not attributable + * to this view and no later session can load it, yet it would occupy a slot in the shared map + * and outrank loadable entries when the map is pruned. Such a change waits in pendingViewState + * and is written by the registration flush under the stable id, which is the only name another + * session could load it under. A provider that goes away while a change is still held drops it: + * writing it under the session-local id would put an entry in the shared map that loadViewState + * refuses to read back, for the price of a slot that outranks loadable entries when the map is + * pruned. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + + // Pre-registration change: hold it for the registration flush instead of persisting it under + // the session-local id. Temporary ids restart from 0 on every host start, so an entry under + // one is a slot in the shared map that no view can ever load, and it outranks loadable entries + // when the map is pruned. Spreading keeps a key the change explicitly cleared present with an + // undefined value, which is what the write below turns back into a deletion. + if (viewStateId === this.viewId) { + this.pendingViewState = { ...this.pendingViewState, ...values } + return + } + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + // Merge onto what the entry holds now. The guard above means the id is a stable one, so + // the entry under it belongs to this view, while another view's concurrent write to the + // shared map must survive this one. + const current = states[viewStateId] ?? {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + // The tombstone transition rides on this write, so it is provisional until the write + // lands: the catch below puts it back, or a rollback would treat an entry that durable + // storage still holds as an intentional clear. + const tombstoneTransition: "added" | "lifted" | undefined = + next.mode || next.currentApiConfigName ? "lifted" : "added" + + if (tombstoneTransition === "added") { + delete states[viewStateId] + this._rememberClearedViewStateId(viewStateId) + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + this._forgetClearedViewStateId(viewStateId) + } + + 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) { + // Undo the provisional tombstone transition first: a failed write means storage is + // unchanged, and leaving the transition in place would either hide an entry that is + // still persisted behind an intentional clear or resurrect one the clear did delete. + if (tombstoneTransition === "added") { + this._forgetClearedViewStateId(viewStateId) + } else if (tombstoneTransition === "lifted") { + this._rememberClearedViewStateId(viewStateId) + } + + // 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 + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + try { + await this.contextProxy.setValue("viewStates", states) + // 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. + this._rememberClearedViewStateId(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 + } + + /** + * Re-points persisted view pins that reference a removed profile so views do not + * rehydrate a missing profile name after a reload. Runs through the serialized + * write queue like every other viewStates mutation. + */ + private async repointPersistedViewStates( + removedProfileName: string, + replacementProfileName: string, + ): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + let changed = false + + for (const [viewId, entry] of Object.entries(states)) { + if (entry?.currentApiConfigName !== removedProfileName) { + continue + } + + changed = true + const { currentApiConfigName: _removed, ...rest } = entry + + if (rest.mode) { + states[viewId] = { ...rest, currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } else { + states[viewId] = { currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } + } + + if (changed) { + 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: restore the cache to the value durable + // storage actually holds so closed-view pins cannot serve re-pointed + // names that never reached disk, then let the caller compensate. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Keeps only the most recently updated entries of the persisted view states map, + * bounded by MAX_PERSISTED_VIEW_STATES so the global key cannot grow unboundedly. + */ + /** + * Records a tombstone in the shared set and under this instance, so its lifetime is bounded by + * the provider that wrote it rather than by the life of the host. + */ + private _rememberClearedViewStateId(viewStateId: string): void { + ClineProvider.clearedViewStateIds.add(viewStateId) + this.addedClearedViewStateIds.add(viewStateId) + } + + /** Lifts a tombstone from the shared set and from this instance's record of them. */ + private _forgetClearedViewStateId(viewStateId: string): void { + ClineProvider.clearedViewStateIds.delete(viewStateId) + this.addedClearedViewStateIds.delete(viewStateId) + } + + /** + * Whether any live provider's current pruned-eviction list still names this id. That list is the + * only consumer of the tombstone set, and it is rebuilt at the start of every activation group, + * so an id no live list names cannot affect a rollback now or in a later group: storage no + * longer holds the entry, so nothing can evict it again, and a write that re-lands the entry + * lifts the tombstone itself. + */ + private static _tombstoneIsGuarded(viewStateId: string): boolean { + for (const instance of ClineProvider.activeInstances) { + if (instance.prunedViewIdsDuringActivation.includes(viewStateId)) { + return true + } + } + return false + } + + /** + * Releases the tombstones this instance added. Anything a live group still guards is handed to + * the ownerless set, which the next activation group drains once nothing guards it. + */ + private _releaseClearedViewStateIds(): void { + for (const viewStateId of this.addedClearedViewStateIds) { + if (ClineProvider._tombstoneIsGuarded(viewStateId)) { + ClineProvider.clearedViewStateIdsWithoutOwner.add(viewStateId) + } else { + ClineProvider.clearedViewStateIds.delete(viewStateId) + } + } + this.addedClearedViewStateIds.clear() + } + + /** + * Drops the ownerless tombstones that no live pruned-eviction list guards any more. Runs at the + * start of an activation group, which is the point where the last group's eviction list is let go. + */ + private static _drainUnguardedClearedViewStateIds(): void { + for (const viewStateId of [...ClineProvider.clearedViewStateIdsWithoutOwner]) { + if (!ClineProvider._tombstoneIsGuarded(viewStateId)) { + ClineProvider.clearedViewStateIds.delete(viewStateId) + ClineProvider.clearedViewStateIdsWithoutOwner.delete(viewStateId) + } + } + } + + private prunePersistedViewStates(states: Record): Record { + return Object.fromEntries( + Object.entries(states) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + /** + * 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 + } + + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject "__proto__": writing states["__proto__"] would go through the + // Object.prototype setter and be silently dropped by the later spread. + normalizedViewStateId === "__proto__" + ) { + return + } + + // Bound the key: the webview generates a UUID, so a much longer value is not a + // view id, and an unbounded key is unbounded growth in the shared viewStates map. + if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { + this.log( + `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, + ) + return + } + + const claimant = ClineProvider.getAllInstances().find( + (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, + ) + if (claimant) { + // Ownership check: a key another live view holds must not be adopted. Doing so + // would load that view's pinned profile, nested overlay, and mode into this + // provider, and this webview could then overwrite that view's persisted entry. + // Staying on the temporary id keeps this view working against the shared state. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, + ) + return + } + + const previousViewStateId = this.viewStateId + + this.viewStateId = normalizedViewStateId + + try { + // Flush the selections that were held back while the view was unregistered, now that the + // name they will be read under is known. The buffer is cleared only after the write lands, + // so a registration that fails and restores the previous id can retry the flush instead of + // dropping the user's change. + const pendingViewState = this.pendingViewState + + if (pendingViewState) { + await this.savePersistedViewState(pendingViewState) + this.pendingViewState = undefined + } + + 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, the + // flush and the load instead of the guard above early-returning for the failed id. The + // held selections stay in pendingViewState for the same reason. + 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 { + // 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 + + // Until the stable id is registered the only name available is this instance's session-local + // one, and temporary ids restart from 0 on every host start, so an entry under it cannot be + // attributed to this view. There is nothing to load: pre-registration selections live in + // pendingViewState and the in-memory buffer until the registration flush lands them. + if (loadedForViewId === this.viewId) { + return + } + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + // Snapshot the in-memory buffer before the async profile lookup. The + // mutation paths update viewLocalState in place, so a shallow copy is + // what makes fields mutated during the load window observable below. + const preLoadBuffer = { ...this.viewLocalState } + + 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 + } + } + + 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 stable + * view id, or held in pendingViewState until the webview registers one. + * + * 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 */ @@ -986,6 +1646,7 @@ export class ClineProvider } this.clearWebviewResources() + this.settleViewResolvedGate() // Clean up cloud service event listener if (CloudService.hasInstance()) { @@ -1002,7 +1663,16 @@ export class ClineProvider this.customModesManager?.dispose() this.taskHistoryStore.dispose() this.log("Disposed all disposables") + // A change made before the webview registered a stable id waits in memory, and the only thing + // that ever writes it is that registration. A provider that goes away before it lands drops the + // change rather than writing it under its session-local id: loadViewState refuses to attribute + // a temporary-id entry to any view, so such a write is a record nobody can read back, and its + // cost is a slot in the shared map that outranks loadable entries when the map is pruned. + this.pendingViewState = undefined ClineProvider.activeInstances.delete(this) + // Tombstones this provider wrote are no longer its responsibility. Done after leaving + // activeInstances so this provider's own eviction list cannot keep its tombstone guarded. + this._releaseClearedViewStateIds() this.diagnosticsRequestBroker.dispose() // Clean up any event listeners attached to this provider @@ -1023,6 +1693,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 +1842,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 +1866,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 +2168,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,51 +2385,114 @@ 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 + const stateSequence = message.type === "state" ? message.state?.clineMessagesSeq : undefined this.diagnosticsRecorder.record({ boundary: "webview-out", phase: "start", type: message.type, action: message.action, - stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, + stateSequence, taskId, }) if (this._disposed) { this.diagnosticsRecorder.record({ boundary: "webview-out", - phase: "failure", - type: message.type, - action: message.action, - elapsedMs: Date.now() - startedAt, - taskId, - }) - return - } - - try { - const delivered = await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) - this.diagnosticsRecorder.record({ - boundary: "webview-out", - phase: delivered ? "success" : "failure", + phase: "failure", type: message.type, action: message.action, elapsedMs: Date.now() - startedAt, - stateSequence: message.type === "state" ? message.state?.clineMessagesSeq : undefined, + stateSequence, taskId, }) - } catch { + return + } + + const webview = this.view?.webview + if (!webview) { this.diagnosticsRecorder.record({ boundary: "webview-out", phase: "failure", type: message.type, action: message.action, elapsedMs: Date.now() - startedAt, + stateSequence, taskId, }) - // View disposed, drop message silently + 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 handlers. + // The delivery outcome is recorded on the settlement of the promise already in hand - + // postMessage resolves with whether the page took the message, so an undelivered or + // rejected post still leaves an outcome in diagnostics without the caller waiting for it. + void Promise.resolve(webview.postMessage(omitOriginalContentFromExtensionMessage(message))) + .then((delivered) => { + this.diagnosticsRecorder.record({ + boundary: "webview-out", + phase: delivered ? "success" : "failure", + type: message.type, + action: message.action, + elapsedMs: Date.now() - startedAt, + stateSequence, + taskId, + }) + }) + .catch((error) => { + // Swallow: postMessage rejects when the webview is disposed in flight. + // Record the failure and log the dropped message type so a wedged webview channel + // is diagnosable instead of silently losing state updates. + this.diagnosticsRecorder.record({ + boundary: "webview-out", + phase: "failure", + type: message.type, + action: message.action, + elapsedMs: Date.now() - startedAt, + stateSequence, + taskId, + }) + this.log( + `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -2044,21 +2793,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 +2816,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,7 +2832,53 @@ 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) @@ -2095,14 +2891,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 +2916,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 +2994,280 @@ 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 = [] + // This group is the point at which the previous group's eviction list is let go, so it is + // also where tombstones that nothing guards any more are dropped. + ClineProvider._drainUnguardedClearedViewStateIds() + // 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( @@ -2232,84 +3288,75 @@ export class ClineProvider let partialState = false try { - await this.enqueueProviderProfileMutation( - async (signal) => { - const { name: _name, ...stored } = await this.providerSettingsManager.getProfile({ name }) + await this.enqueueProviderProfileMutation(async () => { + 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 - - 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,61 +3374,189 @@ 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 + } + + // 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 (failedActivation) throw failedActivation.reason + // The mode mapping is compensated from a record of the mapping write that was actually issued, + // not from the mode captured below. The capture runs before the sibling snapshot and the + // prior-mapping read, so gating the rollback on it alone would replay a restore for a write + // that never started and clear a mapping this call never overwrote. + let modeMappingWrite: { mode: Mode; priorModeConfigId: string | undefined } | undefined + try { + if (activate) { + const { mode } = await this.getState() + + // 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 }) + + let priorModeConfigId: string | undefined + 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. + } + // Issue the mapping write and record the pairing it used at the same moment: the + // compensation below restores exactly this mapping, and only if this line ran. + modeMappingWrite = { mode, priorModeConfigId } + // `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", + ) - if (signal.aborted) return id + if (failedActivation) throw failedActivation.reason - // Change the provider for the current task. - // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + // 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) - // 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()) - } + // Change the provider for the current task. + // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) - if (signal.aborted) return id + // 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 }) + // Only rebuild when the task actually runs under the saved profile: a + // view pinned to this profile can hold a task that follows a different + // sticky profile, and the forced rebuild would silently switch that + // conversation to the saved profile's settings. + if (this.getCurrentTask()?.taskApiConfigName === name) { + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) + } + } + await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) + } - await this.postStateToWebview() - return id + 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 (modeMappingWrite) { + try { + await this.providerSettingsManager.setModeConfig( + modeMappingWrite.mode, + modeMappingWrite.priorModeConfigId as string, + ) + } catch (restoreError) { + this.log( + `[ClineProvider] profile-activation rollback failed for the mode mapping of ${modeMappingWrite.mode}: ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + } + await this._restoreProfileActivationState(preWriteSnapshot) + throw error + } } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { @@ -2395,16 +3570,282 @@ export class ClineProvider if (!profileToActivate) { throw new Error("You cannot delete the last profile") } - - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) - - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) - - await this.postStateToWebview() + + // Snapshot the manager record and every persistence surface this + // deletion rewrites BEFORE the manager delete commits, so a failure + // inside the write group below can be compensated instead of leaving + // the secrets-backed record gone while metadata and view pins still + // reference it. + let previousRecord: Awaited> | undefined + try { + previousRecord = await this.providerSettingsManager.getProfile({ name: profileToDelete.name }) + } catch (error) { + // 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`, + ) + } + + 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 + const 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. + const 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) { + if (survivingSettings) { + this.updateTaskApiHandlerIfNeeded(survivingSettings, { forceRebuild: true }) + } + await this.persistStickyProviderProfileToCurrentTask(profileToActivate) + } + // 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)}`, + ) + } + 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 + } } private async persistStickyProviderProfileToCurrentTask( @@ -2437,6 +3878,79 @@ export class ClineProvider } } + /** + * 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] + } + } + async activateProviderProfile( args: { name: string } | { id: string }, options?: { @@ -2447,9 +3961,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 +3976,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 +4098,206 @@ 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. + * + * A failure part-way through is compensated here: the caller's rollback cannot reach another + * view's in-memory buffer, so every sibling this refresh already moved is put back, unless it + * has since taken a newer change of its own. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === name, + ) + + if (affected.length === 0) { + return + } + + // Each sibling's write is independent, so a later sibling can fail after earlier ones already + // took the saved settings. The caller's activation rollback only reaches this view's own overlay + // plus the durable maps, so anything this refresh already applied to another view has to be + // undone here: otherwise a sibling keeps serving the settings of a refresh the caller reports as + // failed. Sequential application is what makes the order below a real apply order. + const applied: { + instance: ClineProvider + task: Task | undefined + previousApiConfiguration: ProviderSettings | undefined + previousTaskApiConfiguration: ProviderSettings | undefined + }[] = [] + + try { + for (const instance of affected) { + // Direct private access: compile-time safe across sibling instances. + const task = instance.getCurrentTask() + const { apiConfiguration: previousApiConfiguration } = instance.viewLocalState + // The task's own handler was built from these settings and the rebuild below replaces + // them, so they have to be remembered here rather than read back afterwards. + const previousTaskApiConfiguration = + task?.taskApiConfigName === name ? task.apiConfiguration : undefined + + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + applied.push({ instance, task, previousApiConfiguration, previousTaskApiConfiguration }) + + // The active task of a pinned view holds its own API handler built from + // the profile's settings: rebuild it with the saved settings or the + // running conversation keeps sending the old token even though the + // view's buffer has caught up. Only rebuild when the task actually runs + // under the saved profile: a task that follows a different sticky + // profile must keep its own settings. Direct private access across + // sibling instances of the same class is compile-time safe. + if (task && task.taskApiConfigName === name) { + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } + await instance.postStateToWebview() + } + } catch (error) { + // Undo in reverse, and only where this refresh's values are still in place: a sibling that + // moved its own overlay afterwards is newer than this operation, and overwriting it would + // drop the sibling's own change instead of this one. Each restore is logged and swallowed: + // a failed restore must not strand the remaining siblings, and the caller rethrows the + // original failure. + for (const { instance, task, previousApiConfiguration, previousTaskApiConfiguration } of [ + ...applied, + ].reverse()) { + if (instance.viewLocalState.apiConfiguration !== providerSettings) { + continue + } + + try { + await instance._saveViewLocalStateFromMutation({ apiConfiguration: previousApiConfiguration }) + + // The same guard on the task side: only a handler this refresh rebuilt, on a task that + // still runs under the saved profile, may be rebuilt back to the previous settings. + if ( + previousTaskApiConfiguration && + instance.getCurrentTask() === task && + task?.taskApiConfigName === name + ) { + instance.updateTaskApiHandlerIfNeeded(previousTaskApiConfiguration, { forceRebuild: true }) + } + + await instance.postStateToWebview() + } catch (restoreError) { + this.log( + `[refreshViewLocalStateForUpdatedProfile] Unable to restore the overlay of view ${instance.viewId} after the refresh of profile '${name}' failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + } + + throw error + } + } + + /** + * Re-pin the other live views whose buffer still names a deleted profile: + * without this their in-memory buffer and durable viewStates entry keep + * serving the deleted profile's name and configuration on top of the + * surviving shared state. Each affected view's durable entry is re-pinned + * through the serialized write queue, so the rename survives reloads. + */ + private async rePinViewLocalStateForDeletedProfile( + deletedProfileName: string, + replacementName: string, + replacementSettings: ProviderSettings | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + 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 + + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation(values) + // The active task of a view pinned to the deleted profile holds its own API + // handler and sticky profile name built from the deleted profile: rebuild + // the handler with the replacement's settings and re-point the sticky name, + // or the running conversation keeps sending the deleted profile's + // configuration even though the view's buffer was re-pointed (the posted + // state prefers the task's own apiConfiguration). Only re-point when the + // task actually runs under the deleted profile: a view pinned to it can + // hold a task that follows a different sticky profile, and forcing the + // replacement would silently switch that conversation and rewrite its + // persisted profile name without the user asking. + if (instance.getCurrentTask()?.taskApiConfigName === deletedProfileName) { + if (replacementSettings) { + instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(replacementName) + } + await instance.postStateToWebview() + }), + ) + } + + /** + * 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 +4433,10 @@ export class ClineProvider // picks up the new token immediately for the current task. await this.upsertProviderProfile(entry.name, updated, true) } else { - // Non-active profiles just need the token saved to disk. - await this.providerSettingsManager.saveConfig(entry.name, updated) + // Non-active profiles need the token saved to disk and the views pinned + // to them refreshed: a direct saveConfig leaves a pinned view serving the + // old token until it reloads. + await this.upsertProviderProfile(entry.name, updated, false) } } } @@ -3443,12 +5232,18 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // Merge viewLocalState on top of global state so a provider can serve + // state values scoped to its own view while preserving ContextProxy defaults. + const mergedStateValues = { ...stateValues, ...this.viewLocalState } + const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. + // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = - stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) - ? stateValues.apiProvider + mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) + ? mergedStateValues.apiProvider : providerIdentifiers.anthropic // Build the apiConfiguration object combining state values and secrets. @@ -3509,127 +5304,143 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // A view-local profile is a complete snapshot, not a patch over the shared store. + // Spreading it on top of providerSettings would let a key the profile never defined + // leak in from another profile, which ContextProxy.setProviderSettings deliberately + // prevents by clearing omitted keys. Use the view's own settings in place and take + // only apiProvider from the shared state when the profile does not carry one. + // The overlay is read from the buffer itself: the raw apiConfiguration state key + // is a partial patch from shared-store paths, not a view snapshot. + const viewApiConfiguration = this.viewLocalState.apiConfiguration + const resolvedApiConfiguration = viewApiConfiguration + ? { + ...viewApiConfiguration, + apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, + } + : providerSettings + return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - allowedReadFiles: stateValues.allowedReadFiles ?? [], - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - allowedWriteFiles: stateValues.allowedWriteFiles ?? [], - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + apiConfiguration: resolvedApiConfiguration, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysDenyUnapprovedCommands: - stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + mergedStateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, collapseCommandOutputByDefault: - stateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, + mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - timeFormat: stateValues.timeFormat ?? "auto", - dateFormat: stateValues.dateFormat ?? "auto", - showRelativeTimes: stateValues.showRelativeTimes ?? true, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + timeFormat: mergedStateValues.timeFormat ?? "auto", + dateFormat: mergedStateValues.dateFormat ?? "auto", + showRelativeTimes: mergedStateValues.showRelativeTimes ?? true, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } @@ -3701,7 +5512,55 @@ 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) + } catch (rollbackError) { + this.log( + `[ClineProvider] Shared-state rollback after failed per-view write failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } } public getValue(key: K) { @@ -3709,13 +5568,174 @@ 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}`)), - ) + // The display settings are filtered before anything is written, as upstream does: an invalid + // value never reaches ContextProxy. Everything after this is this unit's write behaviour - + // the mode guard, the view-local half of the write, and the rollback of both halves when a + // write fails - kept beside it rather than replaced. + const sanitizedValues = { + ...withoutInvalidDisplaySettings(values, (key) => + this.log(`[setValues] Ignored an invalid value for ${key}`), + ), + } + + if ("viewStates" in sanitizedValues) { + // The registered viewStates map is host-owned: this provider rewrites it from a full read of + // the map (savePersistedViewState, clearPersistedViewState, prunePersistedViewStates), so a map + // handed to a settings write by a caller - the webview's newTask configuration, or the extension + // API's - would replace every other view's durable selection in one write, and an explicit unset + // would erase the map outright. Entries arriving this way are validated nowhere, so the key is + // dropped rather than sanitised; the rest of the settings object is the caller's to set. + this.log( + "[setValues] Ignored a caller-supplied viewStates map; the registered map is written by the extension host.", + ) + delete sanitizedValues.viewStates + } + + 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.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() + } } // dev @@ -3744,6 +5764,35 @@ 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) + }`, + ) + } + await instance.postStateToWebview() + } + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() 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 97a25a7038..1d3c09deac 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -28,14 +28,18 @@ 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 { 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 +429,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 +505,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 +650,18 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) + }) + + it("returns undefined when no live instance owns the view", () => { + expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined() + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -1058,7 +1120,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 +1158,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 @@ -3221,8 +3314,9 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) - test("postMessageToWebview does not throw when webview is disposed", async () => { + test("postMessageToWebview logs the dropped message type when the webview post rejects", async () => { await provider.resolveWebviewView(mockWebviewView) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() // Simulate postMessage throwing after webview disposal mockPostMessage.mockRejectedValueOnce(new Error("Webview is disposed")) @@ -3231,6 +3325,116 @@ describe("ClineProvider", () => { // Should not throw await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + + // The rejection is handled by a detached catch, so awaiting the post alone cannot prove + // anything about the log: flush the handler and assert on what it wrote, message type and + // Error.message both included. + await vi.waitFor(() => + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("dropped message type=action: Webview is disposed"), + ), + ) + }) + + test("postMessageToWebview logs a non-Error webview rejection", async () => { + await provider.resolveWebviewView(mockWebviewView) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() + + // VS Code settles postMessage from the renderer, so a rejection reason is not guaranteed to + // be an Error; the log has to carry the value itself instead of "[object Object]". + mockPostMessage.mockRejectedValueOnce("disposed without an error object") + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + + await vi.waitFor(() => + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("dropped message type=action: disposed without an error object"), + ), + ) + }) + + 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() + }) + + const webviewOutPhases = (type: string, action?: string) => + provider + .getDiagnosticsSnapshot() + .events.filter( + (event) => event.boundary === "webview-out" && event.type === type && event.action === action, + ) + .map((event) => event.phase) + + test("postMessageToWebview records the delivery outcome when the ack settles, not in the caller's path", async () => { + await provider.resolveWebviewView(mockWebviewView) + + let releaseAck!: (delivered: boolean) => void + const ack = new Promise((resolve) => { + releaseAck = resolve + }) + mockPostMessage.mockImplementationOnce(() => ack) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + // The caller is already back while the renderer ack is still outstanding, so only the start + // event may exist: an outcome recorded at this point would mean the caller awaited the ack. + expect(webviewOutPhases("action", "chatButtonClicked")).toEqual(["start"]) + + // This continuation attaches after the recorder's handler on the same promise, so by the + // time it runs the outcome has been recorded. + releaseAck(true) + await ack + + expect(webviewOutPhases("action", "chatButtonClicked")).toEqual(["start", "success"]) + }) + + test("postMessageToWebview records a failure outcome when the webview post rejects", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockRejectedValueOnce(new Error("Webview is disposed")) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + + await vi.waitFor(() => expect(webviewOutPhases("action", "chatButtonClicked")).toEqual(["start", "failure"])) + }) + + test("postMessageToWebview records a failure outcome when the ack says the page did not take the message", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockResolvedValueOnce(false) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + + await vi.waitFor(() => expect(webviewOutPhases("action", "chatButtonClicked")).toEqual(["start", "failure"])) + }) + + test("postMessageToWebview records a failure outcome when no webview is attached", async () => { + provider["view"] = undefined + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + + expect(webviewOutPhases("action", "chatButtonClicked")).toEqual(["start", "failure"]) + expect(mockPostMessage).not.toHaveBeenCalled() }) test("postMessageToWebview leaves originalContent of file-edit tool messages out of the state it posts", async () => { @@ -3352,10 +3556,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 +3574,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,113 +3593,4912 @@ 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() - void statePost.then(() => { - statePostSettled = true + const statePost = provider.postStateToWebviewWithoutTaskHistory() + void statePost.then(() => { + statePostSettled = true + }) + await Promise.resolve() + + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(statePostSettled).toBe(false) + + releasePost() + await statePost + expect(statePostSettled).toBe(true) + }) + + test.each([ + ["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()], + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { + let releaseOlderSnapshot!: (state: ExtensionState) => void + const olderSnapshot = new Promise((resolve) => { + releaseOlderSnapshot = resolve + }) + const baseState = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + const emptyState: ExtensionState = { ...baseState, taskHistory: [], clineMessages: [] } + const readyState: ExtensionState = { + ...baseState, + taskHistory: [], + clineMessages: [{ ts: 1, type: "say", say: "text", text: "child ready" }], + } + + vi.spyOn(provider, "getStateToPostToWebview") + .mockReturnValueOnce(olderSnapshot) + .mockResolvedValueOnce(readyState) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + const olderPost = postState(provider) + await Promise.resolve() + const newerPost = postState(provider) + await newerPost + releaseOlderSnapshot(emptyState) + await olderPost + + expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ + readyState.clineMessages, + emptyState.clineMessages, + ]) + expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) + }) + + test.each([ + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + [ + "postStateToWebviewWithoutClineMessages", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), + ], + ])("%s skips task history computation", async (_methodName, postState) => { + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await postState(provider) + + expect(getAllSpy).not.toHaveBeenCalled() + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") + }) + + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { + const historyItem = { + id: "history-task", + number: 1, + ts: 1, + task: "History task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const originalGetState = provider.getState.bind(provider) + let baseStateResolved = false + const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { + const state = await originalGetState(options) + baseStateResolved = true + return state + }) + const historyReadPhases: boolean[] = [] + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { + historyReadPhases.push(baseStateResolved) + return [historyItem] + }) + + const state = await provider.getStateToPostToWebview() + + expect(getStateSpy).toHaveBeenCalledOnce() + expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) + expect(getAllSpy).toHaveBeenCalledOnce() + expect(historyReadPhases).toEqual([true]) + expect(state.taskHistory).toEqual([historyItem]) + }) + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await provider["setViewStateId"]("stable-sidebar-view") + + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "my-profile") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const testApiConfig = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("apiConfiguration", testApiConfig) + + expect(provider["viewLocalState"].apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear the currentApiConfigName override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("currentApiConfigName", "my-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + + await provider.saveViewState("currentApiConfigName", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "currentApiConfigName")).toBe(false) + + await provider.dispose() + }) + + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await provider["setViewStateId"]("stable-sidebar-view") + + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("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("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") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + + await provider.dispose() + }) + }) + + describe("view state persistence edge cases", () => { + it("should read viewStates from the ContextProxy cache when not fresh", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + expect(provider["getPersistedViewStates"]()).toEqual({ "stable-sidebar-view": { mode: "architect" } }) + await provider.dispose() + }) + + it("should treat a corrupted non-object viewStates value as an empty map", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A string in storage is corrupt: the fresh-read guard must not spread it. + await mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + }) + await save("mode", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("mode") + await provider.saveViewState("mode", "architect") + await save("currentApiConfigName", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + mode: "architect", + updatedAt: expect.any(Number), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("should rekey a pre-launch entry under the temporary id to the registered stable id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed through the write path so this instance is the author of the entry under + // its temporary id; an entry authored by another session must not be adopted. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + await provider.dispose() + }) + + it("holds a change made before registration in memory instead of writing it under the session-local id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + + // The temporary id restarts from 0 on the next host start, so an entry written under it + // is neither loadable by the next session's provider nor attributable to this view. It + // is held in memory instead, and getState() still serves the change from the buffer. + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + expect((await provider.getState()).mode).toBe("architect") + + await provider["setViewStateId"]("stable-sidebar-view") + + // The registration flush writes the held change under the registered name, the only key + // another session could load it under. + expect(mockContext.globalState.update).toHaveBeenCalledWith("viewStates", { + "stable-sidebar-view": { mode: "architect", updatedAt: expect.any(Number) }, + }) + expect(provider["pendingViewState"]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("architect") + await provider.dispose() + }) + + it("merges a held pre-registration change into the stable entry it lands on", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Hold the change while the provider is unregistered, then let storage gain the stable + // entry an earlier session of this view left behind. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + + // The held change is the newer selection, so it lands on the stable entry field by + // field, and nothing occupies a slot under the session-local id. + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "temp-mode", updatedAt: 1 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + await mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("a b/c") + expect(provider["viewStateId"]).toBe("a_b_c") + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + expect(provider["viewStateId"]).toBe("a_b_c") + logSpy.mockClear() + await provider["setViewStateId"]("a_b_c") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should reapply fields mutated while the load is in flight and keep persisted values for untouched fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutations and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // Selections made while the profile lookup is in flight must survive the load. + await provider.saveViewState("mode", "architect") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // Dirty fields win over the loaded state; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-y" }, + }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted mode authoritative when the pre-load buffer is untouched", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "code") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should not restore a persisted mode whose custom mode no longer exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // Persist a mode too: without it the load has no mode to resurrect, so the + // cleared-field assertion below would pass even if the loaded value clobbered + // the in-flight clear. + await provider.saveViewState("currentApiConfigName", "cfg-a") + await provider.saveViewState("mode", "code") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight clear and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // The user clears the mode while the load is in flight. + await provider.saveViewState("mode", undefined) + resolveProfile({ name: "cfg-a" }) + await load + + // The cleared field must stay absent rather than keeping the persisted or + // pre-load value; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the mode is mutated while the profile lookup is in flight. + await provider.saveViewState("mode", "architect") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should reapply an independently mutated profile name when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // The in-flight selection wins over the loaded state. 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({}) + expect(mockContext.globalState.get("viewStates")).toEqual({}) + 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("releases a disposed provider's tombstone once no live eviction list guards it", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tombstone-release") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["clearPersistedViewState"]() + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-release")).toBe(true) + + await provider.dispose() + + // The entry is gone from durable storage, so no later activation group can evict it and + // nothing consults the tombstone: it must not outlive the provider that wrote it, or the + // shared set grows for as long as the host runs. + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-release")).toBe(false) + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-release")).toBe(false) + }) + + it("keeps a tombstone an in-flight eviction list guards and drops it when the next group lets go", async () => { + const owner = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const guardedProvider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await owner["setViewStateId"]("stable-tombstone-guarded") + await owner["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await owner["clearPersistedViewState"]() + + // Another view is mid-activation and its eviction list names the id, so its rollback still + // has to see the tombstone: dropping it here would resurrect an intentional clear. + guardedProvider["prunedViewIdsDuringActivation"].push("stable-tombstone-guarded") + + await owner.dispose() + + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-guarded")).toBe(true) + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-guarded")).toBe(true) + + // The next activation group is where the previous group's eviction list is let go, and that + // is when the orphaned tombstone is dropped. + guardedProvider["prunedViewIdsDuringActivation"] = [] + guardedProvider["_snapshotProfileActivationState"]() + + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-guarded")).toBe(false) + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-guarded")).toBe(false) + + await guardedProvider.dispose() + }) + + it("lifts the tombstone of a deletion whose durable write failed", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tombstone-failed-write") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + 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() + }) + + // The queued write clears the entry's only field, so it tombstones the id before the + // durable write it rides on fails. + await expect(provider["savePersistedViewState"]({ mode: undefined })).rejects.toThrow("storage unavailable") + mockContext.globalState.update = originalUpdate + + // Storage never dropped the entry, so the tombstone has to go back with the write: an id + // that is still persisted must stay restorable by an activation rollback. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({ + "stable-tombstone-failed-write": { mode: "architect", updatedAt: 1 }, + }) + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-failed-write")).toBe(false) + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-failed-write")).toBe(false) + + await provider.dispose() + }) + + it("drops a held pre-registration change at teardown instead of storing it under the session-local id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.saveViewState("mode", "plan") + const viewId = provider["viewId"] + + // Nothing is on disk while the change waits for a registration that has not happened. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + expect(provider["pendingViewState"]).toEqual({ mode: "plan" }) + + await provider.dispose() + + // The only name this view ever had was session-local, and loadViewState refuses to read an + // entry under one back. Writing it would put a record nobody can load into the shared map, + // where its fresh updatedAt outranks loadable entries when the map is pruned, so teardown + // drops the change and claims nothing. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + expect(provider["pendingViewState"]).toBeUndefined() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalledWith( + expect.stringContaining("unregistered view state of view " + viewId), + ) + }) + + it("does not write anything when a provider is disposed with nothing held", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["_updateViewLocalStateFromMutation"]({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "own" }, + }) + + await provider.dispose() + + // Only a change that was waiting on a registration gets a teardown write. A provider that + // held none must not put an entry under its session-local id, nor claim that it flushed one. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + expect(mockOutputChannel.appendLine).not.toHaveBeenCalledWith( + expect.stringContaining("the unregistered view state of view " + provider["viewId"]), + ) + }) + + it("tombstones an id whose entry a write cleared and releases it at dispose", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tombstone-clear-write") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // Both persisted fields go undefined in one write, which drops the entry. + await provider["savePersistedViewState"]({ mode: undefined, currentApiConfigName: undefined }) + + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-clear-write")).toBe(true) + expect(provider["addedClearedViewStateIds"].has("stable-tombstone-clear-write")).toBe(true) + + await provider.dispose() + + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-clear-write")).toBe(false) + expect(provider["addedClearedViewStateIds"].size).toBe(0) + }) + + it("lifts the tombstone and this provider's claim when a write lands the entry again", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tombstone-relift") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["clearPersistedViewState"]() + expect(provider["addedClearedViewStateIds"].has("stable-tombstone-relift")).toBe(true) + + await provider["savePersistedViewState"]({ mode: "code", updatedAt: 2 }) + + // An entry that is in storage again was not cleared, so neither the shared tombstone nor + // this provider's claim on it may survive: a stale claim would let dispose drop a + // tombstone another view had already lifted. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({ + "stable-tombstone-relift": { mode: "code", updatedAt: 2 }, + }) + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-relift")).toBe(false) + expect(provider["addedClearedViewStateIds"].has("stable-tombstone-relift")).toBe(false) + + await provider.dispose() + }) + + it("puts the tombstone back when the write that would have landed the entry fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tombstone-lift-fail") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["clearPersistedViewState"]() + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-lift-fail")).toBe(true) + + 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() + }) + + // This write would have landed the entry and lifted the tombstone with it. + await expect(provider["savePersistedViewState"]({ mode: "code", updatedAt: 2 })).rejects.toThrow( + "storage unavailable", + ) + mockContext.globalState.update = originalUpdate + + // Storage never changed, so the lift is undone together with the write: an id that is + // still cleared on disk has to stay tombstoned. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-lift-fail")).toBe(true) + expect(provider["addedClearedViewStateIds"].has("stable-tombstone-lift-fail")).toBe(true) + + await provider.dispose() + }) + + it("drains an ownerless tombstone only once no live eviction list guards it", async () => { + const owner = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const firstGuard = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const secondGuard = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await owner["setViewStateId"]("stable-tombstone-drain") + await owner["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await owner["clearPersistedViewState"]() + + // Two views are mid-activation with this id in their eviction lists when the provider + // that cleared the entry goes away. + firstGuard["prunedViewIdsDuringActivation"].push("stable-tombstone-drain") + secondGuard["prunedViewIdsDuringActivation"].push("stable-tombstone-drain") + await owner.dispose() + + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-drain")).toBe(true) + + // The first group lets go of its list, but the second still names the id, so that + // rollback still needs the tombstone. + firstGuard["_snapshotProfileActivationState"]() + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-drain")).toBe(true) + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-drain")).toBe(true) + + // Once the last list naming it is let go, the next group drops it. + secondGuard["_snapshotProfileActivationState"]() + expect(ClineProvider["clearedViewStateIds"].has("stable-tombstone-drain")).toBe(false) + expect(ClineProvider["clearedViewStateIdsWithoutOwner"].has("stable-tombstone-drain")).toBe(false) + + await firstGuard.dispose() + await secondGuard.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() + }) + + // Register first: an unregistered provider holds the change instead of writing it, so + // there would be no durable write left to fail. + await provider["setViewStateId"]("stable-sidebar-view") + + 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() + }) + + // Register first: an unregistered provider holds the change instead of writing it, so + // there would be no durable write left to fail. + await provider["setViewStateId"]("stable-sidebar-view") + + 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("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" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "fresh-id", + ...savedForReadBack1[name], + })), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.setValue("currentApiConfigName", "stale-profile") + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + expect(provider.getValues().currentApiConfigName).toBe("fresh-profile") + await provider.dispose() + }) + + it("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 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 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), + } + 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("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("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) + // The global selection and this view's pin both name surviving profiles: + // the deletion must not reconfigure this view's settings, and the + // pinned view's nested overlay must survive byte-for-byte. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // No reconfiguration: the guard must stay false when neither the global + // selection nor the view pin names the deleted profile, so the view + // keeps its own pin and its nested overlay while the shared store + // reports the global selection. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + }) + await provider.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider2.saveViewState("mode", "debugger") + await provider1.saveViewState("mode", "architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const saveViewState1 = provider1.saveViewState.bind(provider1) + const saveViewState2 = provider2.saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await provider.saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + expect(state.language).toBeDefined() + expect(state.customModes).toBeDefined() + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + 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" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "activated-id", + ...savedForReadBack2[name], + })), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile( + "activated-profile", + { apiProvider: providerIdentifiers.anthropic }, + true, + ) + + // Simulate api.setConfiguration (src/extension/api.ts): a global-only + // write that does not refresh the view-local buffer. + await provider.contextProxy.setValues({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + }) + + const state = await provider.getState({ includeTaskHistory: false }) + + // The fresh global selection must win: the activation's buffer write must + // not mask the later shared update (regression guard for the e2e + // provider-probe suites, which start tasks after a profile activation). + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when activating a different profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + const 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("does not restore a mode mapping the activation never issued", async () => { + // The sibling snapshot runs before the mapping write is issued, so a failure there means this + // call never touched the mode mapping. Replaying the restore anyway would clear the current + // mode's mapping on an upsert that never reached it. + 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, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + // Typed alias for the private activation step: the failure under test is that the sibling + // snapshot rejects before any mapping write starts. + const activationStep = provider as unknown as { + snapshotSiblingOverlaysBeforeSharedSettingsWrite: (name: string, options: unknown) => Promise + } + vi.spyOn(activationStep, "snapshotSiblingOverlaysBeforeSharedSettingsWrite").mockRejectedValue( + new Error("sibling pin unresolvable"), + ) + const setModeConfigSpy = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + expect(setModeConfigSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + }) + + it("restores the mapping of the mode the activation captured when the live mode changes mid-window", async () => { + // The prior-mapping read sits between the capture and the mapping write, so it is the point a + // concurrent mode change lands in this flow. The rollback must name the mode the write + // targeted, not whatever mode is live when the catch runs. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "code") + 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, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + // The mid-window change, through the same path a user message takes. + await provider.setValue("mode", "architect") + return "old-mode-id" + }) + const setModeConfigSpy = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockRejectedValueOnce(new Error("mode write failed")) + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + // The change landed - otherwise the assertion below is vacuous. + expect(provider.contextProxy.getValue("mode")).toBe("architect") + // The rollback names the captured mode, not the live one. + expect(setModeConfigSpy).toHaveBeenCalledWith("code", "old-mode-id") + expect(setModeConfigSpy).not.toHaveBeenCalledWith("architect", expect.anything()) + }) + + it("restores the captured mapping when the shared mode is cleared mid-window", async () => { + // The other direction of the same window: the shared store stops naming a mode between the + // capture and the write, and the restore must still target the captured one. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "code") + 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, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + await provider.contextProxy.setValue("mode", undefined) + return "old-mode-id" + }) + const setModeConfigSpy = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockRejectedValueOnce(new Error("mode write failed")) + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(setModeConfigSpy).toHaveBeenCalledWith("code", "old-mode-id") + }) + 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("compensates the siblings that already took the settings when a later sibling refresh fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const firstSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const lastSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profileName = "profile-sibling-refresh" + // Each view pinned the profile earlier and holds its own copy of the settings it + // loaded then; the refresh replaces those copies with the saved settings. + const firstOverlay: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "first" } + const lastOverlay: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "last" } + const savedSettings: ProviderSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "saved" } + + for (const [index, instance] of [provider, firstSibling, lastSibling].entries()) { + await instance.saveViewState("currentApiConfigName", profileName) + await instance["_updateViewLocalStateFromMutation"]({ + apiConfiguration: index === 0 ? undefined : index === 1 ? firstOverlay : lastOverlay, + }) + // The originating view keeps its own overlay; only the siblings are refreshed. + if (index === 0) { + await instance["_updateViewLocalStateFromMutation"]({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "origin" }, + }) + } + } + + // The second sibling's post is what fails, so the first sibling has already taken the + // saved settings by the time the caller is told the refresh failed. The first sibling's + // post succeeds for that apply and rejects afterwards, so both restores fail and the + // order the compensation works through them in becomes observable. + vi.spyOn(firstSibling, "postStateToWebview") + .mockResolvedValueOnce(undefined) + .mockRejectedValue(new Error("first restore gone")) + vi.spyOn(lastSibling, "postStateToWebview").mockRejectedValue(new Error("webview gone")) + + await expect( + provider["refreshViewLocalStateForUpdatedProfile"](profileName, savedSettings), + ).rejects.toThrow("webview gone") + + // The activation rollback only reaches this view's own overlay and the durable maps, so + // the sibling that already applied the saved settings must be put back: leaving it there + // serves the settings of a refresh the caller saw fail. + expect(firstSibling["viewLocalState"].apiConfiguration).toEqual(firstOverlay) + expect(lastSibling["viewLocalState"].apiConfiguration).toEqual(lastOverlay) + expect(provider["viewLocalState"].apiConfiguration).toMatchObject({ apiKey: "origin" }) + + // The last sibling's post rejects for good, so putting its own overlay back fails too: + // the compensation logs which view it could not restore instead of swallowing that as + // well, and it works through the siblings in the reverse of the order it applied them. + const restoreFailures = vi + .mocked(mockOutputChannel.appendLine) + .mock.calls.map(([line]: [string]) => String(line)) + .filter((line: string) => line.includes("Unable to restore the overlay of view")) + expect( + restoreFailures.some((line: string) => + line.includes("after the refresh of profile 'profile-sibling-refresh' failed: webview gone"), + ), + ).toBe(true) + expect(restoreFailures.some((line: string) => line.includes("failed: first restore gone"))).toBe(true) + const lastRestored = restoreFailures.findIndex((line: string) => line.includes("failed: webview gone")) + const firstRestored = restoreFailures.findIndex((line: string) => + line.includes("failed: first restore gone"), + ) + expect(lastRestored).toBeGreaterThanOrEqual(0) + expect(firstRestored).toBeGreaterThan(lastRestored) + + await provider.dispose() + await firstSibling.dispose() + await lastSibling.dispose() + }) + + it("leaves a newer sibling mutation alone when compensating a failed profile refresh", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const firstSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const lastSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profileName = "profile-sibling-refresh-newer" + const firstOverlay: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "first" } + const lastOverlay: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "last" } + const savedSettings: ProviderSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "saved" } + const newerSiblingSettings: ProviderSettings = { apiProvider: providerIdentifiers.openai, apiKey: "newer" } + + for (const [index, instance] of [provider, firstSibling, lastSibling].entries()) { + await instance.saveViewState("currentApiConfigName", profileName) + await instance["_updateViewLocalStateFromMutation"]({ + apiConfiguration: index === 0 ? undefined : index === 1 ? firstOverlay : lastOverlay, + }) + } + + // The sibling moves its own overlay once, right after the refresh wrote the saved + // settings, so that change is newer than this operation. Exactly once: the compensation + // posts this view too, and re-applying the change there would hide a compensation that + // ignored the newer write. + let moved = false + vi.spyOn(firstSibling, "postStateToWebview").mockImplementation(async () => { + if (moved) { + return + } + moved = true + await firstSibling["_updateViewLocalStateFromMutation"]({ apiConfiguration: newerSiblingSettings }) + }) + vi.spyOn(lastSibling, "postStateToWebview").mockRejectedValue(new Error("webview gone")) + + await expect( + provider["refreshViewLocalStateForUpdatedProfile"](profileName, savedSettings), + ).rejects.toThrow("webview gone") + + expect(firstSibling["viewLocalState"].apiConfiguration).toEqual(newerSiblingSettings) + expect(lastSibling["viewLocalState"].apiConfiguration).toEqual(lastOverlay) + + await provider.dispose() + await firstSibling.dispose() + await lastSibling.dispose() + }) + + it("restores the rebuilt task handler of a sibling when a later sibling refresh fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const firstSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const lastSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profileName = "profile-sibling-refresh-task" + const firstOverlay: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "first" } + const lastOverlay: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "last" } + const savedSettings: ProviderSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "saved" } + const taskSettings: ProviderSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "task" } + + for (const [index, instance] of [provider, firstSibling, lastSibling].entries()) { + await instance.saveViewState("currentApiConfigName", profileName) + await instance["_updateViewLocalStateFromMutation"]({ + apiConfiguration: index === 0 ? undefined : index === 1 ? firstOverlay : lastOverlay, + }) + } + + // The first sibling's task runs under the profile, so the refresh rebuilds its API + // handler with the saved settings; the double keeps the calls observable. + const task = Object.assign( + {} as Task, + { + taskId: "sibling-refresh-task", + taskApiConfigName: profileName, + apiConfiguration: taskSettings, + updateApiConfiguration: vi.fn(), + } as Partial, + ) + vi.spyOn(firstSibling, "getCurrentTask").mockReturnValue(task) + vi.spyOn(firstSibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(lastSibling, "postStateToWebview").mockRejectedValue(new Error("webview gone")) + + await expect( + provider["refreshViewLocalStateForUpdatedProfile"](profileName, savedSettings), + ).rejects.toThrow("webview gone") + + // Rebuilt with the saved settings, then put back: a conversation that keeps running on + // the saved token after the caller was told the refresh failed is the bug this covers. + expect(task.updateApiConfiguration).toHaveBeenCalledTimes(2) + expect(task.updateApiConfiguration).toHaveBeenNthCalledWith(1, savedSettings) + expect(task.updateApiConfiguration).toHaveBeenNthCalledWith(2, taskSettings) + expect(firstSibling["viewLocalState"].apiConfiguration).toEqual(firstOverlay) + + await provider.dispose() + await firstSibling.dispose() + await lastSibling.dispose() + }) + + it("leaves a sibling task outside the compensation guards alone when a later sibling refresh fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const otherProfileSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const evictedSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const lastSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profileName = "profile-sibling-task-guards" + const savedSettings: ProviderSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "saved" } + const overlays: ProviderSettings[] = [ + { apiProvider: providerIdentifiers.anthropic, apiKey: "other-profile" }, + { apiProvider: providerIdentifiers.anthropic, apiKey: "evicted" }, + { apiProvider: providerIdentifiers.anthropic, apiKey: "last" }, + ] + + for (const [index, instance] of [provider, otherProfileSibling, evictedSibling, lastSibling].entries()) { + await instance.saveViewState("currentApiConfigName", profileName) + await instance["_updateViewLocalStateFromMutation"]({ + apiConfiguration: index === 0 ? undefined : overlays[index - 1], + }) + } + + // A task pinned to another profile: neither the refresh nor its compensation has any + // business rebuilding it. + const otherProfileTask = Object.assign( + {} as Task, + { + taskId: "other-profile-task", + taskApiConfigName: "another-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiKey: "other" } as ProviderSettings, + updateApiConfiguration: vi.fn(), + } as Partial, + ) + vi.spyOn(otherProfileSibling, "getCurrentTask").mockReturnValue(otherProfileTask) + + // A task that is replaced by a newer one between the refresh and the compensation: the + // handler recorded for it belongs to a task this view no longer runs. + const evictedTask = Object.assign( + {} as Task, + { + taskId: "evicted-task", + taskApiConfigName: profileName, + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiKey: "task", + } as ProviderSettings, + updateApiConfiguration: vi.fn(), + } as Partial, + ) + const replacementTask = Object.assign( + {} as Task, + { + taskId: "replacement-task", + taskApiConfigName: "yet-another-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + apiKey: "replacement", + } as ProviderSettings, + updateApiConfiguration: vi.fn(), + } as Partial, + ) + let currentTask: Task | undefined = evictedTask + vi.spyOn(evictedSibling, "getCurrentTask").mockImplementation(() => currentTask) + vi.spyOn(evictedSibling, "postStateToWebview").mockImplementation(async () => { + currentTask = replacementTask + }) + + vi.spyOn(otherProfileSibling, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(lastSibling, "postStateToWebview").mockRejectedValue(new Error("webview gone")) + + await expect( + provider["refreshViewLocalStateForUpdatedProfile"](profileName, savedSettings), + ).rejects.toThrow("webview gone") + + // The refresh rebuilds only tasks that run under the profile being saved, and the + // compensation only puts back a handler it had actually replaced. A view whose task belongs + // to another profile has no recorded handler, so compensation must leave it alone - and a + // compensation that called the rebuild anyway would crash on the missing settings and show + // up as a restore failure for this view. + expect(otherProfileTask.updateApiConfiguration).not.toHaveBeenCalled() + const restoreFailures = vi + .mocked(mockOutputChannel.appendLine) + .mock.calls.map(([line]: [string]) => String(line)) + .filter((line: string) => line.includes("Unable to restore the overlay of view")) + expect( + restoreFailures.some((line: string) => line.includes(`of view ${otherProfileSibling["viewId"]} after`)), + ).toBe(false) + + // This task was rebuilt with the saved settings while it still belonged to the view, and + // the compensation must not rebuild it back: the view now runs a different task, and that + // task's own handler belongs to its own profile. + expect(evictedTask.updateApiConfiguration).toHaveBeenCalledTimes(1) + expect(evictedTask.updateApiConfiguration).toHaveBeenNthCalledWith(1, savedSettings) + expect(replacementTask.updateApiConfiguration).not.toHaveBeenCalled() + + await provider.dispose() + await otherProfileSibling.dispose() + await evictedSibling.dispose() + await lastSibling.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 the registration flush fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The change is made while the provider is still unregistered, so the registration + // flush is the write that has to survive. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The flush's durable write fails; the cache was already updated optimistically. + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("durable write failed")) + } + return Promise.resolve() + }) + + await expect(provider["setViewStateId"]("stable-sidebar-view")).rejects.toThrow("durable write failed") + + // The cache must match durable storage: the stable entry never reached disk. + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + // The registration rolled the id back and kept the held selection, so a retried + // registration can still land it instead of dropping the user's change. + expect(provider["viewStateId"]).toBe(provider.viewId) + mockContext.globalState.update = originalUpdate + + await provider["setViewStateId"]("stable-sidebar-view") + + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + 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() + }) + + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" }) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should drop an unknown mode from setValues while keeping valid modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab panel/with.dots and spaces") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + const firstSave = provider.saveViewState("mode", "architect") + await firstWriteStarted + await provider["setViewStateId"]("view-b") + releaseFirstWrite() + await firstSave + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("view-b") + + await provider.dispose() + }) + + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + }) + + it("should read viewStates fresh from storage so out-of-proxy writes are not clobbered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + await provider.saveViewState("mode", "architect") + + // Simulate a concurrent writer (another view's provider) updating the shared + // map directly in storage, bypassing this proxy's cache. + const stored = (await mockContext.globalState.get>("viewStates")) ?? {} + await mockContext.globalState.update("viewStates", { + ...stored, + "view-b": { mode: "debug", updatedAt: 1 }, + }) + + await provider.saveViewState("mode", "code") + + // The serialized write must have merged on top of the fresh storage value, not + // on top of this proxy's stale cache. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "code" }, + "view-b": { mode: "debug" }, + }) + + await provider.dispose() + }) + + it("persists a pre-registration change under the registered view id instead of the temporary one", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A change made before the stable id is registered waits in memory: the temporary id + // is not a key the next session's provider could load it under. + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + + 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("keeps the held pre-registration change over the older stable entry it lands on", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A stable entry already exists (e.g. persisted by an earlier session of this + // view) and holds an older selection than the change this view is holding. + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + // The user picked architect in this window before the id was registered. + await provider.saveViewState("mode", "architect") + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + // The held change is the newer selection, and the load that follows the flush must + // not roll the view back to the older persisted one. + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "architect" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.dispose() + }) + + it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Seed persisted entries under both registered ids through the proxy so the loads + // observe them via the cached read path: the first id's entry pins a profile whose + // lookup is hung below, the second id's entry is the one that must survive. + await provider.contextProxy.setValue("viewStates", { + "first-sidebar-view": { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, + }) + + // Hang the temporary entry's profile lookup so that load is still in flight + // when the stable id is registered. + let releaseGhost!: () => void + const ghostLoad = new Promise((resolve) => { + releaseGhost = resolve + }) + const ghostGetProfile = vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( + ghostLoad.then(() => + Object.assign({} as Awaited>, { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }), + ), + ) + + // Register the first id and leave its load in flight: the profile lookup below is + // what keeps this load open across the newer registration. + const staleLoad = provider["setViewStateId"]("first-sidebar-view") + + // The lookup has to be in flight before the newer id is registered; asserting only + // after the call completes cannot prove the stale check fires mid-load. + await vi.waitFor(() => expect(ghostGetProfile).toHaveBeenCalledTimes(1)) + + const register = provider["setViewStateId"]("stable-sidebar-view") + await register + + releaseGhost() + await staleLoad + + // The stale (superseded-id) load must not overwrite the newer id's load. + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) + expect(provider["viewStateId"]).toBe("stable-sidebar-view") + + 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() }) - await Promise.resolve() - expect(postMessageSpy).toHaveBeenCalledOnce() - expect(statePostSettled).toBe(false) + 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(), + ) - releasePost() - await statePost - expect(statePostSettled).toBe(true) - }) + const state = await provider.getState() - test.each([ - ["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()], - [ - "postStateToWebviewWithoutTaskHistory", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), - ], - ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { - let releaseOlderSnapshot!: (state: ExtensionState) => void - const olderSnapshot = new Promise((resolve) => { - releaseOlderSnapshot = resolve + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + await provider.dispose() }) - const baseState = await provider.getStateToPostToWebview({ includeTaskHistory: false }) - const emptyState: ExtensionState = { ...baseState, taskHistory: [], clineMessages: [] } - const readyState: ExtensionState = { - ...baseState, - taskHistory: [], - clineMessages: [{ ts: 1, type: "say", say: "text", text: "child ready" }], - } - vi.spyOn(provider, "getStateToPostToWebview") - .mockReturnValueOnce(olderSnapshot) - .mockResolvedValueOnce(readyState) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + it("should 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 olderPost = postState(provider) - await Promise.resolve() - const newerPost = postState(provider) - await newerPost - releaseOlderSnapshot(emptyState) - await olderPost + const state = await provider.getState() - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ - readyState.clineMessages, - emptyState.clineMessages, - ]) - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) - }) + expect(state.apiConfiguration.apiProvider).toBe("bogus-provider") - test.each([ - [ - "postStateToWebviewWithoutTaskHistory", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), - ], - [ - "postStateToWebviewWithoutClineMessages", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), - ], - ])("%s skips task history computation", async (_methodName, postState) => { - const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + await provider.dispose() + }) - await postState(provider) + 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", () => { @@ -4071,6 +9081,30 @@ describe("ClineProvider", () => { expect(setValues).toHaveBeenCalledWith({ mode: "code" }) }) + + it("drops a caller-supplied viewStates map so another view's selection survives the write", async () => { + // The registered viewStates map is host-owned: this provider rewrites it from a full read of the + // map (savePersistedViewState, clearPersistedViewState, prunePersistedViewStates), so a map that a + // caller hands to a settings write - the webview's newTask configuration, or the extension API's - + // would replace every view's durable selection in one write, and an unset would erase it outright. + // The key is dropped; the rest of the settings object is the caller's to set. + const log = vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.contextProxy.setValue("viewStates", { + "stable-editor-view": { mode: "code", updatedAt: 2 }, + }) + + await provider.setValues( + hand({ viewStates: { "crafted-view": { mode: "ask", updatedAt: 3 } }, customInstructions: "keep me" }), + ) + + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-editor-view": { mode: "code", updatedAt: 2 }, + }) + expect(mockContext.globalState.get("customInstructions")).toBe("keep me") + expect(log).toHaveBeenCalledWith( + expect.stringContaining("[setValues] Ignored a caller-supplied viewStates map"), + ) + }) }) test("getState returns the saved destructive command guard setting", async () => { @@ -4823,13 +9857,22 @@ 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(), } as any @@ -5229,6 +10272,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 +10377,10 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("non-existent-mode", expect.any(Array)) - // Verify fallback to default mode - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "code") + // Verify fallback to default mode, view-locally: history restore no longer + // writes the shared global mode + expect(provider["viewLocalState"].mode).toBe("code") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "code") expect(logSpy).toHaveBeenCalledWith( "Mode 'non-existent-mode' from history no longer exists. Falling back to default mode 'code'.", ) @@ -5359,8 +10452,9 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("custom-mode", expect.any(Array)) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "custom-mode") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("custom-mode") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "custom-mode") expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("no longer exists")) // Verify history item mode was not changed @@ -5407,8 +10501,9 @@ describe("ClineProvider", () => { // Initialize with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") // Verify history item mode was not changed expect(historyItem.mode).toBe("architect") @@ -5597,9 +10692,18 @@ 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([ @@ -5642,9 +10746,18 @@ 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([ @@ -5685,9 +10798,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 +11595,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 +11618,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 +11664,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 @@ -8180,12 +13317,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, ) }) @@ -8282,4 +13422,284 @@ 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", + }) + } finally { + await sibling.dispose() + } + }) + + it("does not rebuild a pinned sibling's task that runs under another profile", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling pins the saved profile, but its active task follows a + // different sticky profile: the save must refresh the view overlay + // without switching that conversation to the saved profile's settings. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const 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..b3ff4675c4 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,66 @@ 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. + // The view is registered first, because that is what makes the restored mode a + // durable entry rather than a held change. The id is unique to this test: the + // describe-level provider is never disposed, so a shared id would stay claimed. + await provider["setViewStateId"]("stable-mode-sync-view") + 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("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 +548,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 +833,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") @@ -1275,4 +1340,167 @@ 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("leaves a stale temporary-id entry untouched and flushes the change under the stable id", async () => { + // Temporary ids restart from 0 on every host start, so an entry under this name can be a + // leftover from an earlier session. Nothing is written under the session-local id, so the + // leftover is neither adopted nor merged into it; the change waits for the stable id. + 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") + + expect(fresh["getPersistedViewStates"]({ fresh: true })).toEqual({ + [tempId]: { currentApiConfigName: "old-profile", updatedAt: 1 }, + }) + expect(fresh["viewLocalState"].currentApiConfigName).toBeUndefined() + + await fresh["setViewStateId"]("stable-stale-leftover-view") + + expect(fresh["getPersistedViewStates"]({ fresh: true })).toEqual({ + [tempId]: { currentApiConfigName: "old-profile", updatedAt: 1 }, + "stable-stale-leftover-view": { mode: "code", updatedAt: expect.any(Number) }, + }) + // Adopting the leftover would have pinned this view to the earlier session's profile. + expect(fresh["viewLocalState"].currentApiConfigName).toBeUndefined() + }) + + it("keeps a pre-registration change retryable when the registration flush fails", async () => { + // A change made before registration is only buffered, so a storage backend that rejects + // every write cannot leave a half-written entry under a name no later session could + // load. The failure lands on the flush instead, and the change stays held for a retry. + 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 fresh.saveViewState("mode", "code") + expect(fresh["getPersistedViewStates"]({ fresh: true })).toEqual({}) + + await expect(fresh["setViewStateId"]("stable-flush-retry-view")).rejects.toThrow("storage unavailable") + expect(fresh["viewStateId"]).toBe(tempId) + expect(fresh["pendingViewState"]).toEqual({ mode: "code" }) + + mockContext.globalState.update = originalUpdate + + await fresh["setViewStateId"]("stable-flush-retry-view") + expect(fresh["getPersistedViewStates"]({ fresh: true })).toEqual({ + "stable-flush-retry-view": { mode: "code", updatedAt: expect.any(Number) }, + }) + }) + + 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..49f66721c0 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1110,4 +1110,236 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) }) + + describe("deleteProviderProfile", () => { + it("removes the stored profile so a dangling mode mapping can no longer re-activate it", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed the stored profile settings: the default profile plus a child + // profile (mirroring the cross-profile subtasks e2e scenario), with the + // "ask" mode mapped to the child profile. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.providerSettingsManager.setModeConfig("ask", childId) + + // The UI-facing list mirrors the store, as maintained by upsert/activate. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The deleted profile's settings are gone from the manager store. + const remaining = await provider.providerSettingsManager.listConfig() + expect(remaining.some((config) => config.name === "subtask-child-profile")).toBe(false) + await expect( + provider.providerSettingsManager.getProfile({ name: "subtask-child-profile" }), + ).rejects.toThrow(/subtask-child-profile.*not found/) + + // The mode mapping still points at the deleted id, but it no longer + // resolves to a stored profile, so handleModeSwitch falls through to the + // current configuration instead of re-activating the deleted profile. + const savedConfigId = await provider.providerSettingsManager.getModeConfigId("ask") + expect(savedConfigId).toBe(childId) + expect(remaining.find(({ id }) => id === savedConfigId)).toBeUndefined() + + // The context was repointed at the surviving profile. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("treats an already-gone secret as success so the stale list entry is still pruned", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Only the default profile exists in the store: the ghost profile's secret + // was already gone (e.g. pruned by an earlier run) but its list entry + // survived. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "ghost-profile", + }) + + // The manager's "not found" rejection must not surface to the caller ... + await expect( + provider.deleteProviderProfile({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.not.toThrow() + + // ... it prunes the stale list entry and repoints the selection. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("still refuses to delete the last stored profile when a stale list entry remains", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + // A ghost list entry survives next to the only real profile, so the + // provider-level "last profile" guard does not fire: the refusal must come + // from the settings store itself. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + await expect( + provider.deleteProviderProfile({ + name: "default", + id: defaultId, + apiProvider: providerIdentifiers.openrouter, + }), + ).rejects.toThrow("Cannot delete the last remaining configuration") + + // Nothing was repointed or pruned. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "ghost-profile"]) + }) + + it("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", + }) + + // Seed the shared provider settings the way a real activation leaves them: through the same + // 'setProviderSettings' call an activation makes, holding the payload of the profile the + // selection names once the sibling view has switched to 'other-survivor'. The assertion at the + // bottom then has something to lose - every profile in this test was stored with a + // different model id, so pairing the survivor name with another profile's settings shows up. + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4o", + }) + + 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"]) + // Invariant: no shared write may pair the survivor's name with another profile's settings. The shared + // provider keys carry the settings of the profile the selection names, and the selection + // moved onto 'other-survivor' mid-deletion, so the store must now hold that profile's payload - + // "openai/gpt-4o", a model id no other profile in this test was stored with. + 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..cc90ebc664 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") @@ -118,6 +119,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 +134,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(), @@ -1684,6 +1697,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 +1768,58 @@ 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("keeps the shared mode on a slug that exists when the per-view write fails", async () => { + const slug = "test-failed-view-write-mode" + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([ + { name: "Test Failed View Write Mode", slug, roleDefinition: "Test Role", groups: [] } as ModeConfig, + ]) + vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false) + vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + // The provider write rolls the shared store back to what it captured before the write - + // here the slug that was just deleted - and rethrows. The handler has to log, put the + // shared mode back on a slug that exists, and still run the post and the fan-out below. + // Once: the shared rejection must not leak into the tests that follow, which reuse this double. + vi.mocked(mockClineProvider.setValue).mockRejectedValueOnce(new Error("per-view write failed")) + + await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug }) + + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Failed to write the default mode after deleting"), + ) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("mode", defaultModeSlug) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + 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 () => { @@ -2010,6 +2116,51 @@ it("persists alwaysDenyUnapprovedCommands through the generic updateSettings loo expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) +// The registered viewStates map is host-owned: ClineProvider.setValues drops a caller-supplied map +// because the provider rewrites that map from a full read of it. This loop writes through ContextProxy +// directly, so the same key has to be dropped here too - a webview-supplied map would replace every +// view's durable selection, not just this view's, and an unset would erase the map. +it("ignores a webview-supplied viewStates map in the generic updateSettings loop", async () => { + vi.clearAllMocks() + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { + viewStates: { "crafted-view": { mode: "ask" } }, + alwaysDenyUnapprovedCommands: true, + }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignored a webview-supplied viewStates map"), + ) +}) + +// An unset is the shape that erases the stored map: ContextProxy reads an undefined value as a delete +// (ContextProxy.ts:464-466 for secrets; globalState.update(key, undefined) deletes, which the repo relies +// on at :173, :280-282, :305 and :346), and expect.anything() cannot see it because it excludes +// undefined and null - so the matcher in the case above is not enough on its own, and the call itself +// has to be pinned here. The asymmetry to copy when extending this family: the provider-side test is +// erase-safe because it asserts on the stored state, while this double is a bare setValue mock and so +// must pin the call and its value, exactly as the case above pins the unset shape for another key. +it("ignores an unset viewStates map in the generic updateSettings loop", async () => { + vi.clearAllMocks() + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { viewStates: undefined, alwaysDenyUnapprovedCommands: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("viewStates", undefined) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignored a webview-supplied viewStates map"), + ) +}) + // Both allowlists are normalized by the same branch, so both are held to the // same contract. describe.each(["allowedReadFiles", "allowedWriteFiles"] as const)("webviewMessageHandler - %s", (key) => { @@ -2567,6 +2718,476 @@ 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 deleteOldMock = vi.mocked(mockClineProvider.providerSettingsManager.deleteConfig) + const saveOldMock = vi.mocked(mockClineProvider.providerSettingsManager.saveConfig) + const deleteOldCall = deleteOldMock.mock.calls.findIndex(([name]) => name === "old-profile") + const restoreOldCall = saveOldMock.mock.calls.findIndex(([name]) => name === "old-profile") + // The two indices come from two different mocks, so they are not comparable: which slot + // deleteConfig used says nothing about when saveConfig ran, and the pair below stays + // 0-and-1 whichever way the handler orders the two calls. Vitest numbers + // mock.invocationCallOrder across every call, and that array is parallel to calls, so the + // index picks the call and the value read from it is the order the events really happened + // in - the same shape the deleteCustomMode test above uses. + expect(saveOldMock.mock.invocationCallOrder[restoreOldCall]).toBeGreaterThan( + deleteOldMock.mock.invocationCallOrder[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("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")) + }) + + 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 +3425,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 +3611,17 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) + + afterEach(() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) describe("webviewMessageHandler - collapseCommandOutputByDefault", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4adb29264b..e4320b9df5 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 { @@ -80,6 +81,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" @@ -780,6 +782,18 @@ export const webviewMessageHandler = async ( continue } + // The registered viewStates map is host-owned. ClineProvider.setValues drops a caller-supplied + // map because the provider rewrites that map from a full read of it; this loop writes through + // ContextProxy directly, so the same key has to be dropped here too - a webview-supplied map + // would replace every view's durable selection, not just this view's, and an unset would erase + // the map. The key is dropped before any side effect is run for it. + if (key === "viewStates") { + provider.log( + "[updateSettings] Ignored a webview-supplied viewStates map; the registered map is written by the extension host.", + ) + continue + } + let newValue = value if (key === "language") { @@ -2513,18 +2527,160 @@ 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) { + 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) { + 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) { + 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,18 +2728,24 @@ 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( + (c) => c.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 }) + await provider.deleteProviderProfile(profileToDelete) } catch (error) { provider.log( `Error delete api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -2754,9 +2916,37 @@ 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. + try { + await provider.setValue("mode", defaultModeSlug) + } catch (error) { + // provider.setValue rolled the shared store back to the value it captured before + // the write, and here that value is the slug deleteCustomMode just removed: a failed + // per-view write leaves the shared mode naming a mode that no longer exists. Rethrow + // from here and the post and the sibling fan-out below are both skipped, so the web + // keeps showing a deleted mode. Put the shared store back on a slug that does exist + // - the shared layer only, because the per-view record is what failed - and continue. + provider.log( + `[deleteCustomMode] Failed to write the default mode after deleting ${message.slug}: ${error instanceof Error ? error.message : String(error)}`, + ) + try { + await provider.contextProxy.setValue("mode", defaultModeSlug) + } catch (compensationError) { + provider.log( + `[deleteCustomMode] Compensation to the default mode ${defaultModeSlug} failed too: ${compensationError instanceof Error ? compensationError.message : String(compensationError)}`, + ) + } + } 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 42be14463c..d5191f5802 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": 26 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index a10bfe60ac..0e323b8692 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -68,6 +68,74 @@ describe("API - configuration", () => { expect(postStateToWebview).toHaveBeenCalledOnce() }) + it("drops a caller-supplied viewStates map so the registered map stays host-written", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const log = vi.fn() + const provider = { + context: {}, + on: vi.fn(), + log, + contextProxy: { setValues }, + providerSettingsManager: { saveConfig, setModeConfig: vi.fn().mockResolvedValue(undefined) }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + // The registered map is written by the extension host from a full read of storage, so a map handed in by + // an API caller replaces every other view's durable entry in globalState and in the profile file written + // from the same object. An explicit unset is the same hazard, since ContextProxy reads undefined as a + // delete. The rest of the settings object is the caller's to set. + await api.setConfiguration({ + viewStates: { "crafted-view": { mode: "ask", currentApiConfigName: "crafted" } }, + soundEnabled: true, + } as unknown as RooCodeSettings) + + expect(setValues).toHaveBeenCalledWith({ soundEnabled: true }) + expect(saveConfig).toHaveBeenCalledWith("default", { soundEnabled: true }) + expect(log).toHaveBeenCalledWith( + "[setConfiguration] Ignored a caller-supplied viewStates map; the registered map is written by the extension host.", + ) + }) + + // An explicit unset is the same hazard as a crafted map, because ContextProxy reads an undefined value as a + // delete (the repo relies on update(key, undefined) at ContextProxy.ts:173, :280-282, :305 and :346), and + // withoutInvalidDisplaySettings cannot catch it either - it filters by Object.entries, so a key holding + // undefined survives it. toHaveBeenCalledWith compares the way toEqual does and ignores undefined-valued + // keys, so the loose assertions alone could not tell a dropped key from a forwarded undefined; the strict + // assertions on the captured arguments are what carry the teeth here. + it("drops an explicitly unset viewStates value before either write", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const log = vi.fn() + const provider = { + context: {}, + on: vi.fn(), + log, + contextProxy: { setValues }, + providerSettingsManager: { saveConfig, setModeConfig: vi.fn().mockResolvedValue(undefined) }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + await api.setConfiguration({ viewStates: undefined, soundEnabled: true } as unknown as RooCodeSettings) + + const [valuesArgument] = setValues.mock.calls[0] as [RooCodeSettings] + const [savedName, savedArgument] = saveConfig.mock.calls[0] as [string, RooCodeSettings] + expect(savedName).toBe("default") + expect(valuesArgument).toStrictEqual({ soundEnabled: true }) + expect(savedArgument).toStrictEqual({ soundEnabled: true }) + expect(setValues).toHaveBeenCalledWith({ soundEnabled: true }) + expect(saveConfig).toHaveBeenCalledWith("default", { soundEnabled: true }) + expect(log).toHaveBeenCalledWith( + "[setConfiguration] Ignored a caller-supplied viewStates map; the registered map is written by the extension host.", + ) + }) + describe("the display settings", () => { const setup = () => { const setValues = vi.fn().mockResolvedValue(undefined) diff --git a/src/extension/api.ts b/src/extension/api.ts index 606bb7725d..0cd09c76ea 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -646,12 +646,26 @@ export class API extends EventEmitter implements RooCodeAPI { * `ContextProxy.setValues`, which is not a transaction (see there): if a setting cannot be stored, this rejects * with a `SettingsWriteError` that lists the keys that were stored and the keys that were not (the cache of the * failed keys has been read back from storage), the stored settings stay, and nothing after the settings is - * written. Invalid values of the display settings are dropped and logged first. + * written. Invalid values of the display settings are dropped and logged first, and a caller-supplied + * `viewStates` map is dropped too: the registered map is host-owned, rewritten by the extension host from + * a full read of storage. */ public async setConfiguration(allValues: RooCodeSettings) { const values = withoutInvalidDisplaySettings(allValues, (key) => this.sidebarProvider.log(`[setConfiguration] Ignored an invalid value for ${key}`), ) + // The registered viewStates map is host-owned: ClineProvider rewrites it from a full read of storage + // (savePersistedViewState, clearPersistedViewState, prunePersistedViewStates), so a map handed in here + // would replace every other view's durable entry - in globalState, and below in the profile file written + // from the same object - and an explicit unset would erase it, since ContextProxy reads undefined as a + // delete. The key is dropped rather than sanitised; the rest of the settings object is the caller's to + // set. + if ("viewStates" in values) { + this.sidebarProvider.log( + "[setConfiguration] Ignored a caller-supplied viewStates map; the registered map is written by the extension host.", + ) + delete values.viewStates + } await this.sidebarProvider.contextProxy.setValues(values) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) if (values.modeApiConfigs) { diff --git a/src/package.json b/src/package.json index dde9915cb2..04dd894148 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": [