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 2a59d0c5fd..39fb46468e 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -111,6 +111,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -119,6 +128,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 51671d895c..2761ec4705 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -668,6 +668,7 @@ export interface WebviewMessage { | "openRulesDirectory" | "themeFixtureProbeResponse" text?: string + viewStateId?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..6a1a08b821 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -35,6 +35,14 @@ export const commandIds = [ "popoutButtonClicked", "settingsButtonClicked", + // Editor-tab (popped-out) surface variants of the title-bar buttons. The + // shared ids above target the sidebar click origin, so the tab surface + // needs its own ids (see registerCommands.ts getTabProvider). + "plusButtonClickedInTab", + "settingsButtonClickedInTab", + "marketplaceButtonClickedInTab", + "historyButtonClickedInTab", + "openInNewTab", "newTask", diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index d041dada2e..3fb12f7d1d 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,11 +1,13 @@ 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 { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { getPanel, getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -137,7 +139,12 @@ describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext let mockVisibleProvider: { postMessageToWebview: Mock } - let mockProvider: { postMessageToWebview: Mock; webviewFocusTracker: WebviewFocusTracker } + let mockProvider: { + postMessageToWebview: Mock + evictCurrentTask: Mock + refreshWorkspace: Mock + webviewFocusTracker: WebviewFocusTracker + } let handlers: Record unknown> beforeEach(() => { @@ -163,6 +170,8 @@ describe("registerCommands handlers", () => { mockProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), webviewFocusTracker: new WebviewFocusTracker(), } ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) @@ -242,44 +251,114 @@ describe("registerCommands handlers", () => { }, ) - it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => { + // The sidebar title-bar handlers target the registered provider (the + // sidebar click origin) directly, not the visible-instance heuristic. + it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions on the registered provider", () => { handlers["zoo-code.settingsButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("settings") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "settingsButtonClicked", }) - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "didBecomeVisible", }) - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledTimes(2) - }) - - it("settingsButtonClicked is a no-op when no visible provider", () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - handlers["zoo-code.settingsButtonClicked"]() - + expect(mockProvider.postMessageToWebview).toHaveBeenCalledTimes(2) expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("historyButtonClicked posts historyButtonClicked action", () => { + it("historyButtonClicked posts historyButtonClicked action on the registered provider", () => { handlers["zoo-code.historyButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("history") + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "historyButtonClicked", }) + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("marketplaceButtonClicked posts marketplaceButtonClicked action", () => { + it("marketplaceButtonClicked posts marketplaceButtonClicked action on the registered provider", () => { handlers["zoo-code.marketplaceButtonClicked"]() - expect(mockVisibleProvider.postMessageToWebview).toHaveBeenCalledWith({ + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "marketplaceButtonClicked", }) + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + // The `*InTab` handlers serve the `editor/title` menu: they target the + // instance that owns the tracked tab panel, resolved via + // ClineProvider.getInstanceForView. + const tabHandlerCases: { command: string; actions: string[]; telemetry?: string }[] = [ + { + command: "zoo-code.settingsButtonClickedInTab", + actions: ["settingsButtonClicked", "didBecomeVisible"], + telemetry: "settings", + }, + { command: "zoo-code.historyButtonClickedInTab", actions: ["historyButtonClicked"], telemetry: "history" }, + { command: "zoo-code.marketplaceButtonClickedInTab", actions: ["marketplaceButtonClicked"] }, + ] + it.each(tabHandlerCases)( + "$command targets the tab instance for the tracked tab panel", + ({ command, actions, telemetry }) => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + // Retain the tracked tab panel and pin the instance lookup + // against its identity: a handler that resolved the sidebar view + // or any other view must fail instead of passing on the stubbed + // provider result alone. + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + // Identity pin: the lookup must receive the exact tracked panel + // object, not a different object that merely compares equal. + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + for (const action of actions) { + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action }) + } + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledTimes(actions.length) + if (telemetry) { + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith(telemetry) + } + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }, + ) + + // The `*InTab` handlers must no-op when there is no live tab instance: a + // missing or disposed tab must not crash the handler or fall back to + // another instance. Every handler is awaited, so an async handler that + // slipped past its guard (rejecting on the missing instance) fails the + // test instead of settling as an unhandled rejection. + const inTabNoOpCommands = [ + "zoo-code.plusButtonClickedInTab", + "zoo-code.settingsButtonClickedInTab", + "zoo-code.historyButtonClickedInTab", + "zoo-code.marketplaceButtonClickedInTab", + ] + it.each(inTabNoOpCommands)("%s is a no-op when no tab panel is tracked", async (command) => { + await handlers[command]() + + expect(ClineProvider.getInstanceForView as Mock).not.toHaveBeenCalled() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it.each(inTabNoOpCommands)("%s is a no-op when the tab instance is disposed", async (command) => { + const disposedPanel = {} as vscode.WebviewPanel + setPanel(disposedPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await handlers[command]() + + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(disposedPanel) + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) it("acceptInput posts acceptInput message", () => { @@ -352,44 +431,158 @@ describe("registerCommands handlers", () => { }) }) - it("focusInput does not post when no sidebar panel is active", async () => { + it("focusInput does not post when no sidebar panel is tracked", async () => { + await handlers["zoo-code.focusInput"]() + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("focusInput does not post when a tab panel is tracked without a live tab instance", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + await handlers["zoo-code.focusInput"]() + + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance there is no other target. + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("focusInput posts the focus message on the tab instance when a tab panel is tracked", async () => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + await handlers["zoo-code.focusInput"]() + // The tab takes selection priority: assert it was selected by identity + // (reference, not structural equality). + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + // No error was logged on the success path. + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - // 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("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 @@ -407,22 +600,41 @@ describe("registerCommands handlers", () => { ) }) - it("plusButtonClicked calls evictCurrentTask on the visible provider", async () => { - const evictCurrentTask = vi.fn().mockResolvedValue(undefined) - const refreshWorkspace = vi.fn().mockResolvedValue(undefined) - ;(mockVisibleProvider as any).evictCurrentTask = evictCurrentTask - ;(mockVisibleProvider as any).refreshWorkspace = refreshWorkspace - + it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { await handlers["zoo-code.plusButtonClicked"]() - expect(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") + expect(mockProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProvider.refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) - it("plusButtonClicked is a no-op when no visible provider", async () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) - - // Should not throw even with no visible provider - await handlers["zoo-code.plusButtonClicked"]() + it("plusButtonClickedInTab evicts and posts on the tab instance for the tracked tab panel", async () => { + const mockTabProvider = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + // Same identity pin as the other InTab cases: the eviction must run + // against the provider resolved from the exact tracked tab panel. + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + await handlers["zoo-code.plusButtonClickedInTab"]() + + // Identity pin: the eviction must run against the provider resolved + // from the exact tracked panel object, not a merely-equal stub. + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") + expect(mockTabProvider.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockTabProvider.refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "chatButtonClicked", + }) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) }) @@ -473,6 +685,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", @@ -483,4 +698,457 @@ 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("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 9ee3443f9a..91388e488e 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import delay from "delay" -import type { CommandId } from "@roo-code/types" +import type { CommandId, ExtensionMessage } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { getCommand } from "../utils/commands" @@ -30,6 +30,12 @@ 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 * @returns WebviewPanel或WebviewView @@ -39,7 +45,12 @@ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined } /** - * Set panel references + * Set panel references. + * + * The two refs are independent: each surface keeps its own ref for its whole + * lifetime, so resolving the sidebar view never wipes a live tab panel (and + * vice versa). Callers pass `undefined` only when the surface itself is + * disposed (see the `onDidDispose` wiring in `openClineInNewTab`). */ export function setPanel( newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, @@ -47,13 +58,22 @@ export function setPanel( ): void { if (type === "sidebar") { sidebarPanel = newPanel as vscode.WebviewView - tabPanel = undefined } else { tabPanel = newPanel as vscode.WebviewPanel - sidebarPanel = undefined } } +/** + * The instance that owns the tracked tab panel, if it is still alive. + * + * Title-bar commands on the editor-tab surface use this instead of the + * visible-instance heuristic, so a click on the tab's title bar always + * targets that tab even when the sidebar is visible side-by-side. + */ +function getTabProvider(): ClineProvider | undefined { + return tabPanel ? ClineProvider.getInstanceForView(tabPanel) : undefined +} + export type RegisterCommandOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel @@ -83,27 +103,58 @@ export const registerCommands = (options: RegisterCommandOptions) => { // `filePath?: string`, others take none) and VS Code dispatches positional // args dynamically. type CommandCallback = (...args: any[]) => unknown + +// Posts each action in order to the target instance. Failures are logged +// (not thrown) with the handler-specific prefix so a failed post stays +// attributable in the output channel. +const postActions = ( + outputChannel: vscode.OutputChannel, + target: ClineProvider, + actions: readonly NonNullable[], + logPrefix: string, +) => { + for (const action of actions) { + void target + .postMessageToWebview({ type: "action", action }) + .catch((error) => outputChannel.appendLine(`[${logPrefix}] postMessageToWebview failed: ${error}`)) + } +} + const getCommandsMap = ({ context, outputChannel, provider, }: RegisterCommandOptions): Record, CommandCallback> => ({ activationCompleted: () => {}, + // The `view/title` menu is scoped to the sidebar view, so the click + // origin of these handlers is the sidebar provider wired in at + // activation (`provider`). Target it directly instead of the + // visible-instance heuristic, which would follow the user's focus to a + // tab instance when both surfaces are open side-by-side. The `*InTab` + // variants serve the `editor/title` menu and target the tab instance + // through `getTabProvider()` instead. plusButtonClicked: async () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("plus") - if (!visibleProvider) { + await provider.evictCurrentTask() + await provider.refreshWorkspace() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + // Send focusInput action immediately after chatButtonClicked + // This ensures the focus happens after the view has switched + await provider.postMessageToWebview({ type: "action", action: "focusInput" }) + }, + plusButtonClickedInTab: async () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("plus") - await visibleProvider.evictCurrentTask() - await visibleProvider.refreshWorkspace() - await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - // Send focusInput action immediately after chatButtonClicked - // This ensures the focus happens after the view has switched - await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + await tabProvider.evictCurrentTask() + await tabProvider.refreshWorkspace() + await tabProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) }, popoutButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("popout") @@ -113,43 +164,50 @@ const getCommandsMap = ({ openInNewTab: () => openClineInNewTab({ context, outputChannel, webviewFocusTracker: provider.webviewFocusTracker }), settingsButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("settings") - if (!visibleProvider) { + // Also explicitly post the visibility message to trigger scroll reliably. + postActions(outputChannel, provider, ["settingsButtonClicked", "didBecomeVisible"], "settingsButtonClicked") + }, + settingsButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("settings") - void visibleProvider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) - // Also explicitly post the visibility message to trigger scroll reliably - void visibleProvider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + postActions( + outputChannel, + tabProvider, + ["settingsButtonClicked", "didBecomeVisible"], + "settingsButtonClickedInTab", + ) }, historyButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("history") - if (!visibleProvider) { + postActions(outputChannel, provider, ["historyButtonClicked"], "historyButtonClicked") + }, + historyButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("history") - void visibleProvider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + postActions(outputChannel, tabProvider, ["historyButtonClicked"], "historyButtonClickedInTab") }, marketplaceButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) - if (!visibleProvider) return - void visibleProvider - .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, provider, ["marketplaceButtonClicked"], "marketplaceButtonClicked") + }, + marketplaceButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { + return + } + postActions(outputChannel, tabProvider, ["marketplaceButtonClicked"], "marketplaceButtonClickedInTab") }, newTask: (params: { prompt?: string } | null | undefined) => handleNewTask(params, provider.webviewFocusTracker), setCustomStoragePath: async () => { @@ -176,8 +234,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) { @@ -226,7 +291,53 @@ type OpenClineInNewTabOptions = { webviewFocusTracker: WebviewFocusTracker } -export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTracker }: OpenClineInNewTabOptions) => { +export const openClineInNewTab = async (options: OpenClineInNewTabOptions) => { + // Serialize overlapping "Open in editor" calls: a double-click starts + // before the first call tracks its new panel, so without a shared + // in-flight creation both calls would race to create two tab panels. + // The shared promise is stored before the creation body awaits + // ContextProxy.getInstance, so every caller started while the creation + // is in flight — openInNewTab and popoutButtonClicked both dispatch + // through here — awaits it instead of creating a second panel: exactly + // one panel is created and every caller receives the same provider. + if (pendingTabPanelCreation) { + return pendingTabPanelCreation + } + + const creation = createTabPanelUnlocked(options) + pendingTabPanelCreation = creation + + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path in createTabPanelUnlocked then takes over + // for the tracked panel. Guard the clear so this settlement cannot + // clobber a replacement already stored in the slot. That clobber is + // unreachable in single-threaded settlement order: while the slot + // holds this in-flight creation, every other caller receives that + // same promise (guard above), so no replacement can be stored before + // this finally block runs — the equality check pins the invariant. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined + } + } +} + +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTracker }: OpenClineInNewTabOptions) => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. + if (tabPanel) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + } + // (This example uses webviewProvider activation event which is necessary to // deserialize cached webview, but since we use retainContextWhenHidden, we // don't need to use that event). @@ -237,8 +348,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 } @@ -284,6 +396,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 } @@ -292,10 +411,14 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr context.subscriptions, // Register listener for disposal ) - // Handle panel closing events. + // Handle panel closing events: clear the tracked ref only if this panel + // is still the tracked one, so a late disposal of an already-replaced + // panel cannot clobber the replacement's ref. newPanel.onDidDispose( () => { - setPanel(undefined, "tab") + if (tabPanel === newPanel) { + setPanel(undefined, "tab") + } }, null, context.subscriptions, // Also register dispose listener diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1250a7baca..4e20305f48 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // Per-view selection state is machine-local: it keeps flowing through the + // normal runtime and pruning paths but must not transfer between settings. + viewStates: true, }) export class ContextProxy { diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 01695d7994..38c7c3e86d 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() @@ -482,6 +495,22 @@ export class ProviderSettingsManager { } } + /** + * 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 +540,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 +551,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 b4d3b1b8aa..f9fa9cdb15 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -740,4 +740,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..3b93b7cd22 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,7 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -973,6 +973,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index f50fb29d11..01b36e9041 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 0db0e9c9ea..c34f4081d0 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 18c255afe4..313c537bd9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -12,6 +12,7 @@ import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { + PROVIDER_SETTINGS_KEYS, type TaskProviderLike, type TaskProviderEvents, type GlobalState, @@ -35,6 +36,7 @@ import { type CloudUserInfo, type CloudOrganizationMembership, type CreateTaskOptions, + type ProviderSettingsWithId, type TokenUsage, type ToolUsage, type ExtensionMessage, @@ -111,7 +113,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -138,6 +140,14 @@ import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +type PersistedViewState = NonNullable[string] + +/** + * Values that can be held in a view-local state buffer (in-memory) and, for the + * non-secret subset, persisted durably per stable view id. + */ +type ViewLocalStateValues = Partial & Partial + /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -212,6 +222,60 @@ 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 + previousCurrentProfileName: string | undefined + claimedCreate: boolean +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -222,6 +286,18 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 + private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() + + /** + * View ids whose durable entry a queued write intentionally removed + * (unpin, clear, or re-key move). Activation rollback treats these as + * tombstones and never resurrects a tracked eviction the clear has + * since deleted; a write that lands an entry lifts its tombstone. + */ + private static clearedViewStateIds = new Set() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -285,13 +361,21 @@ export class ClineProvider options: { runToCompletion?: boolean } = {}, ): Promise { const controller = new AbortController() + // 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 start = () => { + if (callerTimedOut) { + return Promise.reject(new Error("Provider profile mutation timed out while queued")) + } + return fn(controller.signal) + } + const run = this.providerProfileMutationQueue.then(start, start) const callerResult = this.withProviderProfileMutationTimeout(run, () => { if (options.runToCompletion) { + callerTimedOut = true this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") return } @@ -354,6 +438,48 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Whether this instance wrote an entry under its own session-local temporary id. + * Temporary ids restart from 0 on every extension host start, so an entry under the + * same name can belong to a different session. Only re-key entries this instance wrote. + */ + private wroteUnderTemporaryViewStateId = false + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + + /** + * View ids evicted from the durable viewStates map by this instance's writes + * since the last activation snapshot: the rollback restores exactly these + * entries, so a sibling's intentional pin clear is never resurrected. + */ + private prunedViewIdsDuringActivation: string[] = [] + + /** + * This view's pinned profile name from the view-local buffer. Exposed for + * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` + * is private and must not be reached through bracket access. + */ + get pinnedProfileName(): string | undefined { + return this.viewLocalState.currentApiConfigName + } + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.90.0-providers-tools-reasoning" // v3.90.0 providers and models, safer tool execution, reasoning and streaming @@ -369,14 +495,17 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -401,6 +530,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) => { @@ -523,6 +655,469 @@ 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. Writes captured + * while the provider still holds its temporary (pre-launch) id persist under that id + * and are re-keyed to the stable view id when the webview registers one, so a change + * that lands before the launch message stays durable instead of being lost. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + // The first write to an unowned temporary id claims the slot instead of merging + // into it: temporary names restart from 0 on every host start, so an entry under + // this name can be a leftover from an earlier session, and merging would adopt its + // fields and later re-key them under the stable id. Entries under a stable id, or + // under the temporary id this instance already authored, keep the merge semantics. + const ownsTargetEntry = viewStateId !== this.viewId || this.wroteUnderTemporaryViewStateId + const current = ownsTargetEntry ? (states[viewStateId] ?? {}) : {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + let wroteEntry = false + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + ClineProvider.clearedViewStateIds.add(viewStateId) + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + ClineProvider.clearedViewStateIds.delete(viewStateId) + wroteEntry = true + } + + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + + try { + await this.contextProxy.setValue("viewStates", pruned) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected write leaves the cache claiming an + // entry that never reached disk. Restore the cache to the value durable + // storage actually holds (undefined when the key was never written); the + // cache update lands even if this compensating write also fails, so + // readers cannot observe the lost entry either way. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + // Authorship is recorded only after the durable write lands: a failed persistence + // must not mark the temporary id as owned, or a later load would adopt an entry + // that was never actually stored. + if (wroteEntry && viewStateId === this.viewId) { + this.wroteUnderTemporaryViewStateId = true + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * 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] + ClineProvider.clearedViewStateIds.add(viewStateId) + try { + await this.contextProxy.setValue("viewStates", states) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected write leaves the cache missing an + // entry that durable storage still holds. Restore the cache to the + // value storage actually holds (undefined when the key was never + // written); the cache update lands even if this compensating write + // also fails, so readers cannot observe the loss either way. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Re-points persisted view pins that reference a removed profile so views do not + * rehydrate a missing profile name after a reload. Runs through the serialized + * write queue like every other viewStates mutation. + */ + private async repointPersistedViewStates( + removedProfileName: string, + replacementProfileName: string, + ): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + let changed = false + + for (const [viewId, entry] of Object.entries(states)) { + if (entry?.currentApiConfigName !== removedProfileName) { + continue + } + + changed = true + const { currentApiConfigName: _removed, ...rest } = entry + + if (rest.mode) { + states[viewId] = { ...rest, currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } else { + states[viewId] = { currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } + } + + if (changed) { + 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. + */ + private prunePersistedViewStates(states: Record): Record { + return Object.fromEntries( + Object.entries(states) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + + /** + * Re-keys this provider's temporary pre-launch viewStates entry to the newly + * registered stable id so pre-launch writes become durable under the stable key + * instead of orphaning under a session-local temporary id. Only the provider's own + * temporary id is eligible: an entry under a previously registered stable id belongs + * to that webview's storage and is left alone. When the stable entry already exists + * it wins and the temporary entry is dropped, because temporary ids are session + * counters that can collide across window reloads. Runs through the serialized write + * queue like every other viewStates mutation. + */ + private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { + const previousViewStateId = this.viewId + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + // An entry under this name can belong to a different session, because temporary + // ids restart from 0 on every extension host start. Only re-key entries this + // instance wrote, otherwise a stale entry from another session is adopted. + if (!this.wroteUnderTemporaryViewStateId) { + return + } + + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + ClineProvider.clearedViewStateIds.add(previousViewStateId) + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + ClineProvider.clearedViewStateIds.delete(nextViewStateId) + } + + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + + try { + await this.contextProxy.setValue("viewStates", pruned) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected re-key would leave the cache serving + // the stable entry while durable storage still holds the temporary one. + // Restore the cache to the value durable storage actually holds (the same + // rollback savePersistedViewState applies), so a later load or a retry of + // the registration sees the pre-rekey entry. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + + // Only a successful write clears authorship: a failed attempt leaves the entry + // under the temporary name, and a retry must still be able to re-key it. + this.wroteUnderTemporaryViewStateId = false + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * 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 { + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) + + await this.loadViewState() + } catch (error) { + // A persistence failure must not leave the provider holding an id that was + // never registered: restore the previous id so a later launch retries the + // registration and the load instead of the guard above early-returning for + // the failed id. + this.viewStateId = previousViewStateId + throw error + } + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + * Fields mutated while the async profile lookup is in flight are reapplied on top of the + * loaded state, field by field, so in-flight user selections are not clobbered by the load. + */ + private async loadViewState(): Promise { + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + + // While the stable id has not been registered yet, the key is this instance's + // session-local name. Temporary names restart from 0 on every host start, so an + // entry under the same name can belong to a different session. Load only entries + // this instance authored. + if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { + return + } + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + // 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 } + + 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 view + * id active when the change was made, re-keyed to the stable id on registration. + */ + public async saveViewState( + key: K, + value: ViewLocalStateValues[K] | undefined, + ): Promise { + await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -934,6 +1529,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() @@ -1268,7 +1873,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, @@ -1478,11 +2086,25 @@ export class ClineProvider return } - try { - await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) - } catch { - // View disposed, drop message silently + const webview = this.view?.webview + if (!webview) { + return } + + // Dispatch without awaiting the renderer ack: VS Code settles postMessage only when the + // webview page acknowledges the message, and a page reload or view dispose in flight + // orphans that promise forever. Awaiting it could wedge every caller on the task critical + // path (e.g. the trailing postStateToWebview in handleModeSwitchUnlocked gates the next + // turn after a mode switch). Message ordering is enforced by the message seq, not the ack. + // Promise.resolve() normalizes non-promise returns (e.g. test doubles) before the catch. + void Promise.resolve(webview.postMessage(omitOriginalContentFromExtensionMessage(message))).catch((error) => { + // Swallow: postMessage rejects when the webview is disposed in flight. + // Log the dropped message type so a wedged webview channel is diagnosable + // instead of silently losing state updates. + this.log( + `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -1724,8 +2346,12 @@ 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), + return this.enqueueProviderProfileMutation( + (signal) => this.handleModeSwitchUnlocked(newMode, targetTask, signal), + // The switch and its nested activation must own the queue until every + // write - including compensation - settles: a timeout must not let the + // next queued mutation overlap writes that are still in flight. + { runToCompletion: true }, ) } @@ -1736,9 +2362,17 @@ export class ClineProvider ): Promise { const task = targetTask + // A cancelled or timed-out switch must not be partially applied: bail out + // before the task history / _taskMode writes as well as the durable mode + // write below. Aborts that land while the history write is in flight are + // handled in flight (the landed write is rolled back); the pre-write check + // further down still covers the remaining gap before the durable write. + if (signal?.aborted) { + return + } + if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) - task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) try { // Update the task history with the new mode first. @@ -1746,8 +2380,31 @@ export class ClineProvider if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) + + // An abort that lands while the history write is in flight has + // already persisted the new mode: restore the pre-switch item and + // bail before the in-memory task write and the emit, so task + // history, task state, and provider mode cannot diverge. A failed + // rollback must not surface as the persistence error of the + // cancelled switch: log it and keep the cancellation result. + if (signal?.aborted) { + try { + // Restore only the field this switch changed: re-read the item so + // fields the running task persisted during the pending window + // (tokens, cost, status, apiConfigName) survive the rollback. + const latest = this.getTaskHistoryItem(task.taskId) ?? taskHistoryItem + await this.updateTaskHistory({ ...latest, mode: taskHistoryItem.mode }) + } catch (rollbackError) { + this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) + } + return + } + } else if (signal?.aborted) { + return } + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode } catch (error) { @@ -1762,16 +2419,45 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) - - this.emit(RooCodeEventName.ModeChanged, newMode) - - // If workspace lock is on, keep the current API config — don't load mode-specific config - const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) - if (lockApiConfigAcrossModes) { - if (targetTask !== null) { - await this.postStateToWebview() - } + // A cancelled or timed-out switch must not write the mode or emit + // ModeChanged: check the mutation signal right before the durable write. + if (signal?.aborted) { + return + } + + // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState + // buffer stays in sync with the durable global write: getValues() merges + // viewLocalState on top of the ContextProxy values, so an unsynced stale + // restored mode would otherwise shadow the fresh switch for consumers. + // If the durable write fails, roll the shared write back so getValues() + // cannot mix a fresh shared mode with the stale pre-switch buffer. + const previousMode = this.getValue("mode") + try { + await this.setValue("mode", newMode) + } catch (error) { + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + + this.emit(RooCodeEventName.ModeChanged, newMode) + + // If workspace lock is on, keep the current API config — don't load mode-specific config + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (lockApiConfigAcrossModes) { + if (targetTask !== null) { + await this.postStateToWebview() + } return } @@ -1883,6 +2569,207 @@ export class ClineProvider return !!this.getProviderProfileEntry(name) } + /** + * Prior state that the profile activation writes overwrite, captured before the + * concurrent write group so a failure can compensate the writes that landed. + */ + private _snapshotProfileActivationState(): ProfileActivationSnapshot { + // The rollback may only restore entries this activation's own writes evict. + this.prunedViewIdsDuringActivation = [] + // Capture only the provider-settings keys. getValues() returns the whole + // global state, and restoring that copy would write unrelated keys + // (viewStates, mode, taskHistory, ...) back over changes that legitimately + // landed while the activation group was in flight. + const allValues = this.contextProxy.getValues() + const providerSettings = Object.fromEntries( + PROVIDER_SETTINGS_KEYS.map((key) => [key, allValues[key]]), + ) as RooCodeSettings + return { + listApiConfigMeta: this.contextProxy.getValue("listApiConfigMeta"), + currentApiConfigName: this.contextProxy.getValue("currentApiConfigName"), + // This view's own pin: the rollback must restore it as-is instead of + // overwriting it with the shared selection. + viewCurrentApiConfigName: this.viewLocalState?.currentApiConfigName, + providerSettings, + viewApiConfiguration: this.viewLocalState?.apiConfiguration, + // The activation's pin write merges into and prunes the durable map: the + // rollback needs the exact pre-activation map to bring back any sibling the + // pruning evicted, entry metadata included. + viewStates: this.getPersistedViewStates({ fresh: true }), + } + } + + /** + * Compensate the profile activation writes after one of them failed. Every + * restore is awaited independently and logged: one failed restore must not + * strand the others. The caller rethrows the original failure. + */ + private async _restoreProfileActivationState(snapshot: ProfileActivationSnapshot): Promise { + try { + await this.updateGlobalState("listApiConfigMeta", snapshot.listApiConfigMeta) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the profile list: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + // Restore the shared selection through the ContextProxy directly: + // this.setValue would also write the shared value into this view's pin + // and its durable viewStates entry, replacing the view's own selection + // with another view's choice. + await this.contextProxy.setValue("currentApiConfigName", snapshot.currentApiConfigName) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the current profile selection: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + await this.contextProxy.setProviderSettings(snapshot.providerSettings) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the shared provider settings: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + // Restore this view's own pin and overlay together: an absent pin stays + // absent (undefined deletes the entry) instead of materializing a pin + // from the shared value. + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: snapshot.viewCurrentApiConfigName, + apiConfiguration: snapshot.viewApiConfiguration, + }) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the view-local overlay: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + // Restore the siblings the activation's pruning evicted, last, so it + // lands after the overlay restore's own pin write, with their original + // entry metadata. Only tracked evictions are restored: an entry a + // sibling deleted on purpose after the snapshot is newer than the + // snapshot too, and resurrecting it would undo the sibling's clear on + // reload. The mutation queue is per-instance, so sibling views keep + // writing to the durable map while an activation group is in flight; + // merge, never replace, to leave those newer writes alone. + const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const current = this.getPersistedViewStates({ fresh: true }) + const merged = { ...current } + for (const viewId of this.prunedViewIdsDuringActivation) { + const entry = snapshot.viewStates[viewId] + if (entry && !Object.hasOwn(merged, viewId) && !ClineProvider.clearedViewStateIds.has(viewId)) { + merged[viewId] = entry + } + } + await this.contextProxy.setValue("viewStates", merged) + }) + ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) + await restore + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the durable viewStates map: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Prior provider-manager state that the profile mutation group overwrites: the + * manager's record for the profile being saved (or its confirmed absence) and + * the manager-owned current selection. The manager store is the source of truth + * that the profile list and the shared settings are derived from, so + * compensation has to restore it and not only its ContextProxy copies. + */ + private async _snapshotProviderManagerState(name: string): Promise { + let previousRecord: ProviderSettingsWithId | undefined + let recordExisted: boolean | undefined + let claimedCreate = false + 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. + recordExisted = await this.providerSettingsManager.hasConfig(name) + if (recordExisted) { + 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 { + // Unreadable: the restore must not delete what it cannot see. + recordExisted = undefined + } + let previousCurrentProfileName: string | undefined + try { + previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() + } catch { + // Best-effort: the selection restore is skipped when unreadable. + } + return { previousRecord, recordExisted, 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, 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): Promise { + try { + if (snapshot.recordExisted === true && snapshot.previousRecord) { + 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)}`, + ) + } + } + + /** + * 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, @@ -2014,54 +2901,175 @@ export class ClineProvider // we rely on the `ContextProxy`'s data store and in other cases // we rely on the `ProviderSettingsManager`'s data store. It might // be simpler to unify these two. - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) - - 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) + const preWriteSnapshot = this._snapshotProfileActivationState() + let id: string + try { + id = await this.providerSettingsManager.saveConfig(name, providerSettings) + } catch (error) { + // saveConfig assigns the record before awaiting the store write, so a + // rejection may still have landed the record: route through the + // manager-record rollback (which deletes a landed record and spends the + // create claim) instead of escaping the group with the claim leaked. + await this._restoreProviderManagerRecord(name, managerSnapshot) + throw error + } + + if (signal.aborted) { + // The record landed; the create is complete, so the claim is spent and + // the outcome recorded: a failed sibling create must spare the record. + if (managerSnapshot.claimedCreate) { + markProfileCreateSucceeded(name) + if (releaseProfileCreate(name)) { + profileCreatesSucceeded.delete(name) + } + } + return id + } + + // saveConfig normalizes the record (schema parse, legacy downgrades): + // read the stored copy back and use it for every activation write, + // sibling refresh, overlay, and task-handler update below, so raw + // webview fields the profile schema strips cannot enter the shared + // store, sibling view state, or task configuration. Fail closed when + // the read-back fails: the raw caller payload must never stand in for + // the validated record, so the landed save is compensated and the + // mutation group fails instead of propagating unvalidated settings. + let normalizedSettings: ProviderSettings + try { + const { + name: _savedName, + id: _savedId, + ...storedRecord + } = await this.providerSettingsManager.getProfile({ name }) + normalizedSettings = storedRecord as ProviderSettings + } catch (error) { + // Fail closed: compensate the landed 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 + let activationMode: Mode | undefined + let priorModeConfigId: string | undefined + try { + if (activate) { + const { mode } = await this.getState() + activationMode = mode + + // These promises do the following: + // 1. Adds or updates the list of provider profiles. + // 2. Sets the current provider profile. + // 3. Sets the current mode's provider profile. + // 4. Copies the provider settings to the context. + // + // Note: 1, 2, and 4 can be done in one `ContextProxy` call: + // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) + // We should probably switch to that and verify that it works. + // I left the original implementation in just to be safe. + // Sibling views pinned to other profiles must keep their own + // settings: the shared write below replaces the shared store, so + // pin their profile's settings into their overlays first. + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) - if (signal.aborted) return id + try { + priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) + } catch { + // Best-effort snapshot: when the prior mapping cannot be read the + // compensation restores the mapping as absent. + } + // `allSettled` so the compensation below never races a write still in flight. + const activationResults = await Promise.allSettled([ + this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain global write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), + this.providerSettingsManager.setModeConfig(mode, id), + this.contextProxy.setProviderSettings(normalizedSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), + ]) + const failedActivation = activationResults.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ) - // Change the provider for the current task. - // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + if (failedActivation) throw failedActivation.reason - // 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()) - } + // 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) - if (signal.aborted) return id + // Change the provider for the current task. + // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) - await this.postStateToWebview() - 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() + // 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) + await this._restoreProviderManagerSelection(managerSnapshot) + if (activationMode !== undefined) { + try { + await this.providerSettingsManager.setModeConfig(activationMode, priorModeConfigId as string) + } catch (restoreError) { + this.log( + `[ClineProvider] profile-activation rollback failed for the mode mapping: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + } + await this._restoreProfileActivationState(preWriteSnapshot) + throw error + } } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { @@ -2076,15 +3084,208 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // Snapshot the manager record and every persistence surface this + // deletion rewrites BEFORE the manager delete commits, so a failure + // inside the write group below can be compensated instead of leaving + // the secrets-backed record gone while metadata and view pins still + // reference it. + let previousRecord: Awaited> | undefined + try { + previousRecord = await this.providerSettingsManager.getProfile({ name: profileToDelete.name }) + } catch { + // Absent record: the idempotent not-found path below still prunes the + // stale list entry, and compensation skips the record restore. + } + const previousEntries = this.getProviderProfileEntries() + const previousSelection = globalSettings.currentApiConfigName + const previousSharedSettings = this.contextProxy.getProviderSettings() + const previousViewStates = this.getPersistedViewStates({ fresh: true }) + + // Remove the profile from the settings store (context.secrets) so it cannot be + // resurrected by a later listApiConfigMeta sync. A not-found rejection means + // the secret was already gone (e.g. pruned by an earlier run): branch on the + // typed ProviderSettingsNotFoundError so the stale list entry below is still + // pruned as an idempotent success, while any other failure (e.g. refusing to + // delete the last remaining configuration) propagates. Matching message text + // instead would let a profile whose name contains "not found" swallow an + // unrelated failure. + try { + await this.providerSettingsManager.deleteConfig(profileToDelete.name) + } catch (error) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + throw error + } + this.log( + `deleteProviderProfile: settings for '${profileToDelete.name}' were not found; pruning the stale list entry only`, + ) + } - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) + 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) + + // 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. + let survivingSettings: ProviderSettings | undefined + try { + const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } - await this.postStateToWebview() + // 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 + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName + + // 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) { + if (!survivingSettings) { + // No settings could be resolved for the survivor. The pin now names a + // profile whose configuration is unknown, so drop the overlay instead of + // serving the deleted profile's settings under the new name. + if (viewWasPinnedToDeleted) { + await this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }) + } + } else { + // 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 }) + } + } + } + + // 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. + if (previousRecord) { + try { + const { name: _previousName, id: _previousId, ...previousSettings } = previousRecord + await this.providerSettingsManager.saveConfig(profileToDelete.name, previousSettings) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the manager record '${profileToDelete.name}': ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + } + try { + // Merge rather than clobber: another instance may have added + // profiles between the snapshot and the compensation. + const currentEntries = this.getProviderProfileEntries() + const deletedEntry = previousEntries.find((entry) => entry.name === profileToDelete.name) + if (deletedEntry && !currentEntries.some((entry) => entry.name === profileToDelete.name)) { + await this.contextProxy.setValue("listApiConfigMeta", [...currentEntries, deletedEntry]) + } + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the profile list: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + await this.contextProxy.setValue("currentApiConfigName", previousSelection) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the shared selection: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + await this.contextProxy.setProviderSettings(previousSharedSettings) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the shared provider settings: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const current = this.getPersistedViewStates({ fresh: true }) + const merged = { ...current } + let changed = false + for (const [viewId, entry] of Object.entries(previousViewStates)) { + if (entry?.currentApiConfigName !== profileToDelete.name) { + continue + } + merged[viewId] = entry + changed = true + } + if (changed) { + await this.contextProxy.setValue("viewStates", merged) + } + }) + ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) + await restore + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the persisted view pins: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + throw error + } } private async persistStickyProviderProfileToCurrentTask( @@ -2117,6 +3318,60 @@ 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 }, + ): Promise { + 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) { + // If the pinned profile cannot be resolved, leave the overlay as is: + // the deletion path re-points such views, and a wrong snapshot is + // worse than the pre-write shared values. + this.log( + `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }), + ) + } + async activateProviderProfile( args: { name: string } | { id: string }, options?: { @@ -2125,8 +3380,15 @@ export class ClineProvider skipCurrentTaskRebuild?: boolean }, ) { - return this.enqueueProviderProfileMutation((signal) => - this.activateProviderProfileUnlocked(args, options, signal), + 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. + runToCompletion: true, + }, ) } @@ -2139,6 +3401,10 @@ export class ClineProvider }, 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 @@ -2149,11 +3415,44 @@ export class ClineProvider 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. + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) + + const activationSnapshot = this._snapshotProfileActivationState() + try { + // `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. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } const { mode } = await this.getState() @@ -2180,6 +3479,147 @@ export class ClineProvider } } + /** + * Refresh the view-local apiConfiguration buffer of the other live views + * pinned to the given profile. An upsert/activation rewrites the profile's + * settings in the shared store and the store-backed manager, but a view + * whose buffer loaded the profile earlier keeps shadowing the stale + * settings in its getState() until its own next mutation. The originating + * view refreshes its buffer at the mutation site itself. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === name, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + + // The active task of a pinned view holds its own API handler built from + // the profile's settings: rebuild it with the saved settings or the + // running conversation keeps sending the old token even though the + // view's buffer has caught up. Only rebuild when the task actually runs + // under the saved profile: a task that follows a different sticky + // profile must keep its own settings. Direct private access across + // sibling instances of the same class is compile-time safe. + if (instance.getCurrentTask()?.taskApiConfigName === name) { + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } + await instance.postStateToWebview() + }), + ) + } + + /** + * Re-pin the other live views whose buffer still names a deleted profile: + * without this their in-memory buffer and durable viewStates entry keep + * serving the deleted profile's name and configuration on top of the + * surviving shared state. Each affected view's durable entry is re-pinned + * through the serialized write queue, so the rename survives reloads. + */ + private async rePinViewLocalStateForDeletedProfile( + deletedProfileName: string, + replacementName: string, + replacementSettings: ProviderSettings | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + // 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) @@ -2315,8 +3755,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) } } } @@ -3056,12 +4498,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. @@ -3122,122 +4570,137 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // A view-local profile is a complete snapshot, not a patch over the shared store. + // Spreading it on top of providerSettings would let a key the profile never defined + // leak in from another profile, which ContextProxy.setProviderSettings deliberately + // prevents by clearing omitted keys. Use the view's own settings in place and take + // only apiProvider from the shared state when the profile does not carry one. + // The overlay is read from the buffer itself: the raw apiConfiguration state key + // is a partial patch from shared-store paths, not a view snapshot. + const viewApiConfiguration = this.viewLocalState.apiConfiguration + const resolvedApiConfiguration = viewApiConfiguration + ? { + ...viewApiConfiguration, + apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, + } + : providerSettings + return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - allowedReadFiles: stateValues.allowedReadFiles ?? [], - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - allowedWriteFiles: stateValues.allowedWriteFiles ?? [], - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + apiConfiguration: resolvedApiConfiguration, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysDenyUnapprovedCommands: - stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + mergedStateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } @@ -3301,7 +4764,31 @@ 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) + throw error + } + } + + /** + * Restore shared values captured before a setter wrote them, used when the paired + * per-view persistence failed so the shared store cannot move ahead of the per-view + * record. A failed rollback is logged; the original failure is what callers see. + */ + private async _restoreSharedValuesAfterFailedViewWrite(previous: RooCodeSettings): Promise { + try { + await this.contextProxy.setValues(previous) + } catch (rollbackError) { + this.log( + `[ClineProvider] Shared-state rollback after failed per-view write failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } } public getValue(key: K) { @@ -3309,11 +4796,116 @@ export class ClineProvider } public getValues() { - return this.contextProxy.getValues() + return { ...this.contextProxy.getValues(), ...this.viewLocalState } } public async setValues(values: RooCodeSettings) { - await this.contextProxy.setValues(values) + const sanitizedValues = { ...values } + + if (sanitizedValues.mode !== undefined) { + // An unknown or non-string mode (e.g. from an API payload) must not be persisted: + // a new Task would read it from getState() and persist it into task history. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } + } + + const previousSharedValues = Object.fromEntries( + Object.keys(sanitizedValues).map((key) => [key, this.contextProxy.getValue(key as keyof RooCodeSettings)]), + ) as RooCodeSettings + await this.contextProxy.setValues(sanitizedValues) + try { + await this._saveViewLocalStateFromMutation(sanitizedValues) + } catch (error) { + // The per-view record did not land: restore the prior shared values so the two + // persistence layers cannot diverge, then surface the original failure. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues) + throw error + } + } + + /** + * Persists the view-local subset of a ContextProxy mutation, then updates the in-memory + * viewLocalState buffer. Persistence is awaited first so a failed durable write cannot + * leave the local cache ahead of the persisted state. + */ + private async _saveViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + await this._persistViewLocalStateFromMutation(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial & Partial): void { + if ("mode" in values) { + 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 = {} } // dev @@ -3342,6 +4934,14 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + + // Clear this view's persisted entry too, so the reset selections are not + // re-applied from the durable viewStates pin after a reload. + await this.clearPersistedViewState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 3121fe1046..01701e662a 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -241,9 +241,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", @@ -852,8 +858,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 @@ -881,7 +889,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") @@ -913,20 +972,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 113fb0f66d..fa91108ba8 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -14,6 +14,7 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, + type RooCodeSettings, type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -26,14 +27,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" @@ -419,6 +424,45 @@ afterAll(() => { vi.restoreAllMocks() }) +/** + * Minimal profile shape the stalled getProfile double resolves to. The + * settings fields are optional so a lookup that resolves name-only (a + * profile with no configured provider) also type-checks. + */ +type StalledProfile = { + name: string + apiProvider?: string + openRouterModelId?: string +} + +/** + * Swap the provider's ProviderSettingsManager for a double whose getProfile + * stalls until the test resolves it, so tests can mutate view state while + * loadViewState is in flight. The double only backs getProfile, the member + * loadViewState awaits; the documented @ts-ignore replaces the per-test + * suppressions the inlined copies used. + */ +function stallProviderSettingsProfile(provider: ClineProvider) { + let resolveProfile: (value: StalledProfile) => void = () => {} + const getProfileSpy = vi.fn(() => { + // Only one lookup is resolvable: resolveProfile is bound to the first + // promise's resolver, so a second getProfile would strand its promise. + // Fail loudly instead of hanging. + if (getProfileSpy.mock.calls.length > 1) { + throw new Error( + "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", + ) + } + return new Promise((resolve) => (resolveProfile = resolve)) + }) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // Return a stable wrapper around the closure binding: resolveProfile is + // reassigned to the pending promise's resolver once getProfile is called, + // so returning the variable directly would hand the test the initial no-op. + return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } +} + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -456,6 +500,13 @@ describe("ClineProvider", () => { let mockPostMessage: any let updateGlobalStateSpy: any + afterEach(() => { + // Tests construct providers directly and many never dispose them; clear the + // static registry so a leaked instance cannot claim stable view state ids + // ahead of a later test. + ClineProvider["activeInstances"] = new Set() + }) + beforeEach(() => { vi.clearAllMocks() @@ -593,6 +644,18 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) + }) + + it("returns undefined when no live instance owns the view", () => { + expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined() + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -1013,7 +1076,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: { @@ -1260,6 +1323,26 @@ describe("ClineProvider", () => { await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() }) + test("postMessageToWebview does not await the webview ack", async () => { + await provider.resolveWebviewView(mockWebviewView) + + let releaseAck!: () => void + const ack = new Promise((resolve) => { + releaseAck = resolve + }) + mockPostMessage.mockImplementationOnce(() => ack) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // The caller must not wait for the renderer ack: a webview page remounted or disposed + // while the post is in flight never acknowledges it, and awaiting that promise would + // wedge every caller on the task critical path. + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledWith(message) + releaseAck() + }) + test("postMessageToWebview leaves originalContent of file-edit tool messages out of the state it posts", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1379,10 +1462,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, @@ -1393,129 +1479,3402 @@ describe("ClineProvider", () => { mdmService, ) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider as any, "getStateToPostToWebview").mockResolvedValue({ version: "1.0.0" }) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + version: "1.0.0", + }), + ) + + await provider.postStateToWebview() + + expect(postMessageSpy).toHaveBeenCalledTimes(1) + expect(postMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "action" })) + }) + + test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve + }) + let statePostSettled = false + + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + taskHistory: [], + }), + ) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) + + const statePost = provider.postStateToWebviewWithoutTaskHistory() + void statePost.then(() => { + statePostSettled = true + }) + await Promise.resolve() + + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(statePostSettled).toBe(false) + + releasePost() + await statePost + expect(statePostSettled).toBe(true) + }) + + test.each([ + ["postStateToWebview", (currentProvider: ClineProvider) => currentProvider.postStateToWebview()], + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { + let releaseOlderSnapshot!: (state: ExtensionState) => void + const olderSnapshot = new Promise((resolve) => { + releaseOlderSnapshot = resolve + }) + const baseState = await provider.getStateToPostToWebview({ includeTaskHistory: false }) + const emptyState: ExtensionState = { ...baseState, taskHistory: [], clineMessages: [] } + const readyState: ExtensionState = { + ...baseState, + taskHistory: [], + clineMessages: [{ ts: 1, type: "say", say: "text", text: "child ready" }], + } + + vi.spyOn(provider, "getStateToPostToWebview") + .mockReturnValueOnce(olderSnapshot) + .mockResolvedValueOnce(readyState) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + const olderPost = postState(provider) + await Promise.resolve() + const newerPost = postState(provider) + await newerPost + releaseOlderSnapshot(emptyState) + await olderPost + + expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ + readyState.clineMessages, + emptyState.clineMessages, + ]) + expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) + }) + + test.each([ + [ + "postStateToWebviewWithoutTaskHistory", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), + ], + [ + "postStateToWebviewWithoutClineMessages", + (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), + ], + ])("%s skips task history computation", async (_methodName, postState) => { + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await postState(provider) + + expect(getAllSpy).not.toHaveBeenCalled() + expect(postMessageSpy).toHaveBeenCalledOnce() + expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") + }) + + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { + const historyItem = { + id: "history-task", + number: 1, + ts: 1, + task: "History task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const originalGetState = provider.getState.bind(provider) + let baseStateResolved = false + const getStateSpy = vi.spyOn(provider, "getState").mockImplementation(async (options) => { + const state = await originalGetState(options) + baseStateResolved = true + return state + }) + const historyReadPhases: boolean[] = [] + const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll").mockImplementation(() => { + historyReadPhases.push(baseStateResolved) + return [historyItem] + }) + + const state = await provider.getStateToPostToWebview() + + expect(getStateSpy).toHaveBeenCalledOnce() + expect(getStateSpy).toHaveBeenCalledWith({ includeTaskHistory: false }) + expect(getAllSpy).toHaveBeenCalledOnce() + expect(historyReadPhases).toEqual([true]) + expect(state.taskHistory).toEqual([historyItem]) + }) + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await provider["setViewStateId"]("stable-sidebar-view") + + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "my-profile") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const testApiConfig = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("apiConfiguration", testApiConfig) + + expect(provider["viewLocalState"].apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear the currentApiConfigName override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("currentApiConfigName", "my-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + + await provider.saveViewState("currentApiConfigName", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "currentApiConfigName")).toBe(false) + + await provider.dispose() + }) + + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await provider["setViewStateId"]("stable-sidebar-view") + + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("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("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Author the temporary entry through the write path so this instance owns it, + // then add a stable entry that already exists in storage. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + await mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("a b/c") + expect(provider["viewStateId"]).toBe("a_b_c") + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + expect(provider["viewStateId"]).toBe("a_b_c") + logSpy.mockClear() + await provider["setViewStateId"]("a_b_c") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should reapply fields mutated while the load is in flight and keep persisted values for untouched fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutations and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // Selections made while the profile lookup is in flight must survive the load. + await provider.saveViewState("mode", "architect") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // Dirty fields win over the loaded state; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-y" }, + }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted mode authoritative when the pre-load buffer is untouched", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "code") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should not restore a persisted mode whose custom mode no longer exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // Persist a mode too: without it the load has no mode to resurrect, so the + // cleared-field assertion below would pass even if the loaded value clobbered + // the in-flight clear. + await provider.saveViewState("currentApiConfigName", "cfg-a") + await provider.saveViewState("mode", "code") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight clear and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // The user clears the mode while the load is in flight. + await provider.saveViewState("mode", undefined) + resolveProfile({ name: "cfg-a" }) + await load + + // The cleared field must stay absent rather than keeping the persisted or + // pre-load value; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the mode is mutated while the profile lookup is in flight. + await provider.saveViewState("mode", "architect") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should reapply an independently mutated profile name when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // The in-flight selection wins over the loaded state. + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-b") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.dispose() + }) + + it("should reapply an independently mutated apiConfiguration when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the apiConfiguration is mutated while the profile lookup is in flight. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should keep every persisted field authoritative when the load is untouched", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + await provider.saveViewState("mode", "code") + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // No mutation while the lookup is in flight: the persisted values must win. + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "code", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should persist known modes and reject unknown or non-string modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the slugs under test. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "refactor" ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValues({ mode: "refactor" }) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + await provider.setValues({ mode: "bogus-mode" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode must be rejected before persistence; Object.assign + // keeps this a single structural assertion, so it must not reach global + // state or the buffer. + await provider.setValues(Object.assign({} as RooCodeSettings, { mode: 42 })) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should 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 roll back the shared write when setValue per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValue("mode", "code")).rejects.toThrow("storage unavailable") + // The shared store must not move ahead of the per-view record that failed. + expect(provider.getValue("mode")).toBe("architect") + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + + it("should roll back the shared writes when setValues per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + await provider.contextProxy.setValue("currentApiConfigName", "current-config") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValues({ mode: "code", currentApiConfigName: "other-config" })).rejects.toThrow( + "storage unavailable", + ) + expect(provider.getValue("mode")).toBe("architect") + expect(provider.getValue("currentApiConfigName")).toBe("current-config") + expect(provider["viewLocalState"].mode).toBeUndefined() + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + }) + + describe("provider profile mutations", () => { + it("should sync the view-local buffer when activating a profile over a loaded view state", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("currentApiConfigName", "old-profile") + + const profile: ProviderSettingsEntry = { + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openrouter, + } + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(profile), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + getProfile: vi.fn().mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider.getValues().currentApiConfigName).toBe("old-profile") + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The buffer must track the activated profile so getValues() agrees with the proxy. + expect(provider.getValues().currentApiConfigName).toBe("new-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") + await provider.dispose() + }) + + it("should 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("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") + 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, + new AbortController().signal, + ), + ).rejects.toThrow("could not be read back") + + // The landed save was compensated with the prior record, and no + // activation write carried the raw payload into the shared store. + expect(saveSpy).toHaveBeenLastCalledWith("p", expect.objectContaining({ apiKey: "prior-key" })) + expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "p") + + await provider.dispose() + }) + + it("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. + expect(saveSpy).toHaveBeenCalledWith("old-profile", expect.objectContaining({ apiKey: "old-key" })) + + await provider.dispose() + }) + + it("keeps the mutation queue owned by a mode switch past its timeout", async () => { + 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("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("drops the view overlay 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 still happens. + 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 = { + getProfile: vi.fn().mockRejectedValue(new Error("profile unreadable")), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + + await provider.deleteProviderProfile(oldProfile) + + // The pin now names a profile whose settings are unknown, so the deleted + // profile's configuration must not survive under the new name. + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + await provider.dispose() + }) + + it("does not re-point a pinned sibling's task that follows another profile when its profile is deleted", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // The sibling pins the profile being deleted, but its active task + // follows a different sticky profile: the deletion must re-point the + // sibling's buffer to the surviving profile without switching that + // conversation's handler or rewriting its persisted profile name. + sibling["viewLocalState"].currentApiConfigName = "old-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + taskId: "sibling-task", + setTaskApiConfigName: vi.fn(), + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The sibling's view buffer is still re-pointed to the surviving + // profile... + expect(sibling["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + // ...but the task follows another profile: neither the handler rebuild + // nor the sticky-name rewrite may touch it. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(siblingTask.setTaskApiConfigName).not.toHaveBeenCalled() + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) + // The global selection and this view's pin both name surviving profiles: + // the deletion must not reconfigure this view's settings, and the + // pinned view's nested overlay must survive byte-for-byte. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // No reconfiguration: the guard must stay false when neither the global + // selection nor the view pin names the deleted profile, so the view + // keeps its own pin and its nested overlay while the shared store + // reports the global selection. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + }) + await provider.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider2.saveViewState("mode", "debugger") + await provider1.saveViewState("mode", "architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const saveViewState1 = provider1.saveViewState.bind(provider1) + const saveViewState2 = provider2.saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await provider.saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + 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("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("keeps this view's own profile pin when an activation rollback restores the shared selection", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The view pins profile-a while another view's choice sits in the shared + // selection. + await provider.saveViewState("currentApiConfigName", "profile-a") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + + const snapshot = provider["_snapshotProfileActivationState"]() + // The activation moved the shared selection; a later write failed. + await provider.setValue("currentApiConfigName", "profile-c") + await provider["_restoreProfileActivationState"](snapshot) + + // The shared selection returns to its prior value... + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + // ...but this view's own pin is restored to its own choice, not the + // shared one: the rollback must not replace the pin with profile-b. + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + const viewStates = mockContext.globalState.get("viewStates") as Record> + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-a") + await provider.dispose() + }) + + it("does not rewrite unrelated global keys when an activation rollback restores provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const snapshot = provider["_snapshotProfileActivationState"]() + // An unrelated key legitimately changes while the activation group is in + // flight. + await provider.contextProxy.setValue("customInstructions", "written during activation") + await provider["_restoreProfileActivationState"](snapshot) + // The rollback restores provider settings only; it must not replay the + // whole global-state copy over keys that moved in the meantime. + expect(provider.contextProxy.getValue("customInstructions")).toBe("written during activation") + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted view-state write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.contextProxy.setValue("viewStates", { + "other-view": { mode: "code", updatedAt: 1 }, + }) + // The next durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["savePersistedViewState"]({ mode: "architect", updatedAt: 2 })).rejects.toThrow( + "durable write failed", + ) + + // The in-memory cache must match durable storage: an entry that never + // reached disk cannot stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "other-view": { mode: "code", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a re-key write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id through the real write + // path, so the re-key has a real entry to move and this instance authored it. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The re-key's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["rekeyPersistedViewStateEntry"]("stable-sidebar-view")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the stable entry never reached disk, + // and the retry path depends on the temporary entry still being visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + }) + // Authorship survives the failure so a later registration can re-key again. + expect(provider["wroteUnderTemporaryViewStateId"]).toBe(true) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a clear write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The clear's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["clearPersistedViewState"]()).rejects.toThrow("durable write failed") + + // The cache must match durable storage: the entry was never removed on + // disk, so it must stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted re-point write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + + // The re-point's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["repointPersistedViewStates"]("profile-old", "profile-new")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the re-pointed pins never reached disk, + // so the pre-re-point map must stay visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the complete durable viewStates map when an activation rollback follows a pruning pin write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-activation-view") + // A full map: the activation's own pin write prunes the oldest entry. + const seeded: Record = {} + for (let i = 1; i <= 50; i++) { + seeded["view-" + i] = { mode: "code", updatedAt: i } + } + await provider.contextProxy.setValue("viewStates", seeded) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + // The rollback restores the exact pre-activation map: the sibling the + // pruning evicted is back with its original metadata. + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["view-1"]).toEqual({ mode: "code", updatedAt: 1 }) + await provider.dispose() + }) + it("keeps sibling viewStates writes that land during an activation rollback", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-merge-view") + await provider.contextProxy.setValue("viewStates", { "old-view": { mode: "code", updatedAt: 1 } }) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // A sibling instance persists its own entry while the activation group is + // in flight; the rollback must not replay the stale snapshot over it. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + const current = (provider.contextProxy.getValue("viewStates") ?? {}) as Record + await provider.contextProxy.setValue("viewStates", { + ...current, + "sibling-view": { mode: "architect", updatedAt: 99 }, + }) + return [] + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + // The rollback restores what the activation pruned without erasing the + // sibling's newer entry. + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["sibling-view"]).toEqual({ mode: "architect", updatedAt: 99 }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + await provider.dispose() + }) + + it("does not resurrect a sibling pin cleared after the activation snapshot", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-clear-view") + await provider.contextProxy.setValue("viewStates", { + "sibling-view": { currentApiConfigName: "sibling-profile", updatedAt: 5 }, + "old-view": { mode: "code", updatedAt: 1 }, + }) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // Mid-group, after the rollback snapshot, the sibling intentionally clears + // its only persisted selection: the deletion is newer than the snapshot and + // must survive the rollback. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + const current = (provider.contextProxy.getValue("viewStates") ?? {}) as Record< + string, + { mode?: string; currentApiConfigName?: string; updatedAt?: number } + > + delete current["sibling-view"] + await provider.contextProxy.setValue("viewStates", { ...current }) + return [] + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + const restored = provider.contextProxy.getValue("viewStates") as Record + // The rollback must not treat the sibling's clear as activation pruning. + expect(restored["sibling-view"]).toBeUndefined() + expect(restored["old-view"]).toEqual({ mode: "code", updatedAt: 1 }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + await provider.dispose() + }) + + it("re-points live views pinned to a renamed profile onto the new name", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "profile-old") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "old-key", + }) + + await provider["repointViewLocalStateAfterProfileRename"]("profile-old", "profile-new", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "renamed-key", + }) + + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-new") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "renamed-key", + }) + const viewStates = mockContext.globalState.get("viewStates") as Record> + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-new") + await provider.dispose() + }) + it("re-points persisted pins of closed views onto the renamed profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A closed view's durable pin names the profile about to be renamed; no + // live instance carries it, so only the persisted map can repair it. + await provider.contextProxy.setValue("viewStates", { + "closed-view": { currentApiConfigName: "profile-old", mode: "code", updatedAt: 1 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "profile-old") + + await provider["repointViewLocalStateAfterProfileRename"]("profile-old", "profile-new", { + apiProvider: providerIdentifiers.openrouter, + }) + + const viewStates = mockContext.globalState.get("viewStates") as Record> + // The closed view must launch onto the renamed profile instead of dangling at + // the deleted name and being repaired to whatever the shared selection became + // in the meantime. + expect(viewStates["closed-view"].currentApiConfigName).toBe("profile-new") + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-new") + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when directly activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + 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("should re-key durable viewStates entries from the temporary pre-launch view id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A change made before the stable id is registered persists under the + // temporary id so it is not lost; registration re-keys it to the stable id. + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + [provider.viewId]: { mode: "architect" }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "debugger") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop the temporary viewStates entry when a stable entry already exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A stable entry already exists (e.g. persisted by an earlier registration); + // it must win over this instance's temporary entry. + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + // Author the temporary entry through the real write path so this instance + // owns it: unowned entries are never re-keyed. + await provider.saveViewState("mode", "architect") + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("debugger") + + await provider.dispose() + }) + + it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Seed persisted entries under both ids through the proxy so the loads + // observe them via the cached read path: the temporary entry holds a + // pre-registration selection, the stable entry the post-registration one. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, + }) + + // Hang the temporary entry's profile lookup so that load is still in flight + // when the stable id is registered. + let releaseGhost!: () => void + const ghostLoad = new Promise((resolve) => { + releaseGhost = resolve + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( + ghostLoad.then(() => + Object.assign({} as Awaited>, { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }), + ), + ) + + const staleLoad = provider["loadViewState"]() - await provider.postStateToWebview() + // Register the stable id without awaiting its load: the re-key drops the + // temporary entry (the stable one already exists) and the registration's own + // load settles on the stable entry immediately. + const register = provider["setViewStateId"]("stable-sidebar-view") + await register - expect(postMessageSpy).toHaveBeenCalledTimes(1) - expect(postMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "action" })) - }) + releaseGhost() + await staleLoad - test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { - let releasePost!: () => void - const pendingPost = new Promise((resolve) => { - releasePost = resolve + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) + + await provider.dispose() }) - let statePostSettled = false + }) - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - taskHistory: [], - } as unknown as ExtensionState) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) + 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 statePost = provider.postStateToWebviewWithoutTaskHistory() - void statePost.then(() => { - statePostSettled = true + 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", () => { @@ -2673,13 +6032,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 @@ -3136,8 +6504,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'.", ) @@ -3209,8 +6579,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 @@ -3257,8 +6628,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") @@ -3447,9 +6819,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([ @@ -3492,9 +6873,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([ @@ -3535,9 +6925,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([ @@ -4323,6 +7722,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 @@ -4341,6 +7745,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 @@ -4382,6 +7791,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 @@ -5956,12 +9370,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, ) }) @@ -6058,4 +9475,283 @@ 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(), + 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 22aa4d7e97..564ea4bc3e 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(), } }, }, @@ -467,6 +469,299 @@ describe("ClineProvider - Sticky Mode", () => { }), ) }) + + it("should sync the view-local mode buffer when switching modes after a restored view state", async () => { + // Simulate the history-restore path: saveViewState is what + // createTaskWithHistoryItem uses to pin a saved mode into the + // view-local buffer, leaving a stale mode there until the next mutation. + await provider.saveViewState("mode", "code") + + // Global-only mode switch with no active task. + await provider.handleModeSwitch("architect") + + // The durable global write still happens... + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + + // ...and the in-memory buffer must not keep serving the stale restored + // mode: getValues() merges viewLocalState on top of the ContextProxy + // values, so an unsynced buffer would hide the fresh mode from consumers. + expect(provider["viewLocalState"].mode).toBe("architect") + + // The durable per-view write must land too: a regression that left the + // persisted entry on the stale restored mode would reload it on restart. + // setValue awaits the serialized write queue, so the entry is settled here. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] + expect(persisted.mode).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + }) + + it("bails out before any task write when the mutation signal is already aborted", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + + const abortedController = new AbortController() + abortedController.abort() + await provider["handleModeSwitchUnlocked"]("architect", mockTask, abortedController.signal) + + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalled() + expect(mockTask["_taskMode"]).toBeUndefined() + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + + it("rolls back the landed history write and leaves no partial mode state when the signal aborts in flight", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The history write settles only after the abort lands: the first call is + // controlled, the rollback call resolves immediately. + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await switchPromise + + // The persisted new mode is rolled back to the pre-switch item. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: "test-task-id", mode: "architect" }), + ) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith(2, historyItem) + // No partial mode state: the task keeps its previous mode, nothing was + // emitted, and the durable provider mode write never happened. + expect(mockTask["_taskMode"]).toBe("code") + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + + it("keeps the cancellation result when the abort rollback write itself fails", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The first write settles after the abort lands; the rollback write rejects: + // the failure is logged with its own message and the cancelled switch still + // resolves (it must not surface as the switch's persistence error). + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockRejectedValueOnce(new Error("rollback write failed")) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("Failed to roll back mode switch"), + ) + }) + + it("preserves fields persisted during the pending window when rolling back the mode", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const preSwitchItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + // While the history write is in flight the running task persists other fields + // of the same item; the store now returns that updated item. + const updatedItem: HistoryItem = { + ...preSwitchItem, + mode: "architect", + tokensIn: 42, + totalCost: 0.5, + } + const storeGet = vi + .spyOn(provider.taskHistoryStore, "get") + .mockReturnValueOnce(preSwitchItem) + .mockReturnValue(updatedItem) + + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + // The rollback restores only the mode: the fields persisted during the + // pending window (tokensIn, totalCost) survive the rollback write. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(storeGet).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: "test-task-id", mode: "code", tokensIn: 42, totalCost: 0.5 }), + ) + }) + + it("proceeds normally when no mutation signal is provided", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, undefined) + + expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + }) }) describe("createTaskWithHistoryItem", () => { @@ -487,14 +782,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 () => { @@ -771,7 +1067,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") @@ -1276,4 +1574,148 @@ describe("ClineProvider - Sticky Mode", () => { }) }) }) + + describe("persisted view state keys", () => { + it("does not resolve a view id that names an Object.prototype key through the chain", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed through the durable write path so later writes land in the same + // backing map the assertions read: a frozen get() literal would make every + // post-write assertion read the seed no matter what the provider wrote. + await provider.contextProxy.setValue("viewStates", { "stable-test-view": { mode: "architect" } }) + + const states = provider["getPersistedViewStates"]({ fresh: true }) + expect(Object.getPrototypeOf(states)).toBeNull() + // A view id such as "constructor" must read as an absent entry, not as the + // inherited Object function, otherwise the rekey deletes the temporary entry + // and never writes it under the stable key. + expect(states["constructor"]).toBeUndefined() + expect(states["stable-test-view"]).toEqual({ mode: "architect" }) + + // Registering such an id must not make an inherited member look like a + // persisted entry: the lookup still reports an absent entry, so the rekey keeps the + // temporary entry instead of deleting it. + await provider["setViewStateId"]("constructor") + expect(provider["getPersistedViewStates"]({ fresh: true })["constructor"]).toBeUndefined() + }) + + it("does not load a temporary-id entry that an earlier session wrote", async () => { + // Temporary names restart from 0 on every host start, so storage can still hold + // an entry under this instance's name from a previous session. Seed the entry + // through the ContextProxy before the provider is constructed so the ordinary + // loadViewState read (which goes through the proxy cache) actually sees it. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + await proxy.setValue("viewStates", { + [tempId]: { mode: "architect", currentApiConfigName: "stale-profile" }, + }) + + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + // Await the load itself instead of a fixed timer: the negative + // assertion must run after the load has definitely finished. + await fresh["loadViewState"]() + + // The unowned temporary-id entry must not be adopted... + expect(fresh["viewLocalState"]).toStrictEqual({}) + + // ...and the shared values still win, so the stale entry cannot reach getState(). + const state = await fresh.getState() + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).not.toBe("stale-profile") + }) + + it("does not merge a stale temporary-id entry into this instance's first durable write", async () => { + // A first write under an unowned temporary id claims the slot: fields left by an + // earlier session under the same name must not be adopted (and later re-keyed to + // the stable id) by merging into the colliding entry. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + await proxy.setValue("viewStates", { [tempId]: { currentApiConfigName: "old-profile", updatedAt: 1 } }) + + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + await fresh.saveViewState("mode", "code") + + const states = fresh["getPersistedViewStates"]({ fresh: true }) + expect(states[tempId]).toEqual({ mode: "code", updatedAt: expect.any(Number) }) + expect(fresh["viewLocalState"].currentApiConfigName).toBeUndefined() + }) + + it("does not mark the temporary id as authored when the durable write fails", async () => { + // Authorship must be recorded only after the write lands: a failed persistence + // that still marked the id owned would let a later load adopt an entry that was + // never actually stored. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string, _value: unknown) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(fresh.saveViewState("mode", "code")).rejects.toThrow("storage unavailable") + expect(fresh["wroteUnderTemporaryViewStateId"]).toBe(false) + + mockContext.globalState.update = originalUpdate + }) + + it("does not adopt an entry under the temporary name that this instance never wrote", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Temporary ids restart from 0 on every host start, so the same name can + // belong to a different session. An entry this instance never wrote must not + // be adopted as this view's state. + // Seed through the durable write path so the assertions below read the + // same backing map a re-key adoption would write to: a frozen get() + // literal made the negative assertion unable to fail. + await provider.contextProxy.setValue("viewStates", { [provider.viewId]: { mode: "architect" } }) + + await provider["setViewStateId"]("stable-test-view") + + const states = provider["getPersistedViewStates"]({ fresh: true }) + expect(states[provider.viewId]).toEqual({ mode: "architect" }) + expect(states["stable-test-view"]).toBeUndefined() + }) + }) + + describe("setViewStateId input validation", () => { + it("ignores a non-string viewStateId from a crafted message", async () => { + const before = provider["viewStateId"] + // A crafted postMessage can carry any JSON value despite the declared type. + await provider.setViewStateId(42 as unknown as string) + expect(provider["viewStateId"]).toBe(before) + }) + + it("ignores an oversized viewStateId", async () => { + const before = provider["viewStateId"] + await provider.setViewStateId("x".repeat(200)) + expect(provider["viewStateId"]).toBe(before) + }) + + it("refuses a viewStateId already claimed by another live view", async () => { + const other = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await other.setViewStateId("claimed-by-other-view") + await provider.setViewStateId("claimed-by-other-view") + expect(provider["viewStateId"]).not.toBe("claimed-by-other-view") + expect(other["viewStateId"]).toBe("claimed-by-other-view") + } finally { + await other.dispose() + } + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index ccf243f1e1..dae89bc954 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1010,4 +1010,126 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) }) + + describe("deleteProviderProfile", () => { + it("removes the stored profile so a dangling mode mapping can no longer re-activate it", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed the stored profile settings: the default profile plus a child + // profile (mirroring the cross-profile subtasks e2e scenario), with the + // "ask" mode mapped to the child profile. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.providerSettingsManager.setModeConfig("ask", childId) + + // The UI-facing list mirrors the store, as maintained by upsert/activate. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The deleted profile's settings are gone from the manager store. + const remaining = await provider.providerSettingsManager.listConfig() + expect(remaining.some((config) => config.name === "subtask-child-profile")).toBe(false) + await expect( + provider.providerSettingsManager.getProfile({ name: "subtask-child-profile" }), + ).rejects.toThrow(/subtask-child-profile.*not found/) + + // The mode mapping still points at the deleted id, but it no longer + // resolves to a stored profile, so handleModeSwitch falls through to the + // current configuration instead of re-activating the deleted profile. + const savedConfigId = await provider.providerSettingsManager.getModeConfigId("ask") + expect(savedConfigId).toBe(childId) + expect(remaining.find(({ id }) => id === savedConfigId)).toBeUndefined() + + // The context was repointed at the surviving profile. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("treats an already-gone secret as success so the stale list entry is still pruned", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Only the default profile exists in the store: the ghost profile's secret + // was already gone (e.g. pruned by an earlier run) but its list entry + // survived. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "ghost-profile", + }) + + // The manager's "not found" rejection must not surface to the caller ... + await expect( + provider.deleteProviderProfile({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.not.toThrow() + + // ... it prunes the stale list entry and repoints the selection. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("still refuses to delete the last stored profile when a stale list entry remains", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + // A ghost list entry survives next to the only real profile, so the + // provider-level "last profile" guard does not fire: the refusal must come + // from the settings store itself. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + await expect( + provider.deleteProviderProfile({ + name: "default", + id: defaultId, + apiProvider: providerIdentifiers.openrouter, + }), + ).rejects.toThrow("Cannot delete the last remaining configuration") + + // Nothing was repointed or pruned. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "ghost-profile"]) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 0037f2dcca..9f73092cd7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2565,6 +2565,291 @@ 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("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")) + }) +}) + describe("webviewMessageHandler - kimiCodeSignIn", () => { beforeEach(() => { vi.clearAllMocks() @@ -2802,6 +3087,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 @@ -2970,6 +3274,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("updateProfileModel", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5596b90e67..57f55a4c7c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2448,14 +2448,90 @@ 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 // 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 + } catch (error) { + try { + await provider.providerSettingsManager.deleteConfig(newName) + } catch (cleanupError) { + provider.log( + `Rename rollback failed to remove '${newName}': ${JSON.stringify(cleanupError, Object.getOwnPropertyNames(cleanupError), 2)}`, + ) + } + throw error + } + + // Re-point live views pinned to the old name before it stops + // resolving: the rename keeps the id, so their pins should follow the + // record instead of dangling, and their overlays must serve the + // renamed settings rather than the pre-rename copy. A failed re-point can + // leave views already following newName, so undo this rename's own + // durable write before reporting: the reverse re-point returns the views + // that moved (the others never left oldName) and the new record goes + // away, leaving the pre-rename state intact. + try { + await provider.repointViewLocalStateAfterProfileRename(oldName, newName, normalizedRenamed) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await provider.providerSettingsManager.deleteConfig(newName) + } catch (compensationError) { + provider.log( + `Rename compensation failed after re-point rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) + } + throw error + } + + // Delete the old configuration. A failure here would leave both names + // resolving and every live view pinned to the new name: undo the re-point + // with the previously stored record - not the attempted payload, which was + // never persisted under the old name - and drop the new record so the + // pre-rename state survives intact. A failed compensation is logged: both + // names can then remain stored, and the log is the only trace of that + // inconsistency. + try { + await provider.providerSettingsManager.deleteConfig(oldName) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await provider.providerSettingsManager.deleteConfig(newName) + } catch (compensationError) { + provider.log( + `Rename compensation failed after delete rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) + } + throw error + } // Re-activate to update the global settings related to the // currently activated provider profile. diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index f1b2590f7f..72b8fc2f04 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -64,11 +64,6 @@ "count": 14 } }, - "activate/__tests__/registerCommands.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, "activate/registerCodeActions.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -1026,12 +1021,12 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 196 + "count": 194 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 27 + "count": 26 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { diff --git a/src/package.json b/src/package.json index 0d6933f025..2cbb1b58a2 100644 --- a/src/package.json +++ b/src/package.json @@ -95,6 +95,26 @@ "title": "%command.settings.title%", "icon": "$(settings-gear)" }, + { + "command": "zoo-code.plusButtonClickedInTab", + "title": "%command.newTask.title%", + "icon": "$(edit)" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "title": "%command.settings.title%", + "icon": "$(settings-gear)" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "title": "%command.marketplace.title%", + "icon": "$(extensions)" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "title": "%command.history.title%", + "icon": "$(history)" + }, { "command": "zoo-code.openInNewTab", "title": "%command.openInNewTab.title%", @@ -241,22 +261,22 @@ ], "editor/title": [ { - "command": "zoo-code.plusButtonClicked", + "command": "zoo-code.plusButtonClickedInTab", "group": "navigation@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.settingsButtonClicked", + "command": "zoo-code.settingsButtonClickedInTab", "group": "navigation@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.marketplaceButtonClicked", + "command": "zoo-code.marketplaceButtonClickedInTab", "group": "navigation@3", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.historyButtonClicked", + "command": "zoo-code.historyButtonClickedInTab", "group": "overflow@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, @@ -265,6 +285,24 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "false" + } ] }, "keybindings": [