diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index c2040383e8..826ddd50dc 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, GLOBAL_SETTINGS_KEYS, globalSettingsSchema, + viewStateSchema, } from "../global-settings.js" describe("destructive command guard global setting", () => { @@ -38,3 +39,69 @@ describe("alwaysDenyUnapprovedCommands global setting", () => { expect(() => globalSettingsSchema.parse({ alwaysDenyUnapprovedCommands: "true" })).toThrow() }) }) + +describe("viewStates global setting", () => { + it("preserves each webview instance selection through the persisted schema", () => { + const parsed = globalSettingsSchema.parse({ + viewStates: { + "session-1": { mode: "code", currentApiConfigName: "default", updatedAt: 1700000000000 }, + "session-2": { mode: "architect" }, + }, + }) + // Every field has to survive the round trip: a selection the schema drops is + // written back as absent, so the next window opens in the default mode. + expect(parsed.viewStates).toEqual({ + "session-1": { mode: "code", currentApiConfigName: "default", updatedAt: 1700000000000 }, + "session-2": { mode: "architect" }, + }) + }) + + it("parses a single view state on its own", () => { + expect(viewStateSchema.parse({ mode: "ask", currentApiConfigName: "x", updatedAt: 5 })).toEqual({ + mode: "ask", + currentApiConfigName: "x", + updatedAt: 5, + }) + }) + + it("rejects a numeric mode and names the offending path", () => { + // The control first: the same record without the bad field must parse, so a + // failure here can only mean the schema rejected that value - not that the record + // shape is unsupported or the assertion never ran. + expect(globalSettingsSchema.safeParse({ viewStates: { a: { mode: "code" } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: { mode: 42 } } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "mode"]) + }) + + it("rejects a non-string currentApiConfigName and names the offending path", () => { + expect(globalSettingsSchema.safeParse({ viewStates: { a: { currentApiConfigName: "x" } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: { currentApiConfigName: 7 } } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "currentApiConfigName"]) + }) + + it("rejects a non-numeric updatedAt and names the offending path", () => { + expect(globalSettingsSchema.safeParse({ viewStates: { a: { updatedAt: 1 } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: { updatedAt: "1" } } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a", "updatedAt"]) + }) + + it("rejects a view state that is not an object", () => { + // A bare string under a session id is what a half-migrated store looks like; + // accepting it defers the crash to whoever reads the selection back. + expect(globalSettingsSchema.safeParse({ viewStates: { a: { mode: "code" } } }).success).toBe(true) + + const result = globalSettingsSchema.safeParse({ viewStates: { a: "code" } }) + + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(["viewStates", "a"]) + }) +}) diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 16e9751974..3bf98e39bf 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -111,6 +111,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -119,6 +128,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c0e8509105..a301cbd1fd 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -653,6 +653,7 @@ export interface WebviewMessage { | "openRulesDirectory" | "themeFixtureProbeResponse" text?: string + viewStateId?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd4e31116d..6a1a08b821 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -35,6 +35,14 @@ export const commandIds = [ "popoutButtonClicked", "settingsButtonClicked", + // Editor-tab (popped-out) surface variants of the title-bar buttons. The + // shared ids above target the sidebar click origin, so the tab surface + // needs its own ids (see registerCommands.ts getTabProvider). + "plusButtonClickedInTab", + "settingsButtonClickedInTab", + "marketplaceButtonClickedInTab", + "historyButtonClickedInTab", + "openInNewTab", "newTask", diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index d041dada2e..50f6afef84 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,11 +1,14 @@ import type { Mock } from "vitest" import * as vscode from "vscode" -import { ClineProvider } from "../../core/webview/ClineProvider" +import { TelemetryService } from "@roo-code/telemetry" import { WebviewFocusTracker } from "../../core/webview/WebviewFocusTracker" -import { ContextProxy } from "../../core/config/ContextProxy" import { makeExtensionContext } from "../../test-utils/vscode" -import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { ContextProxy } from "../../core/config/ContextProxy" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { MdmService } from "../../services/mdm/MdmService" + +import { getPanel, getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -137,7 +140,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 +171,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 +252,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 +432,158 @@ describe("registerCommands handlers", () => { }) }) - it("focusInput does not post when no sidebar panel is active", async () => { + it("focusInput does not post when no sidebar panel is tracked", async () => { + await handlers["zoo-code.focusInput"]() + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("focusInput does not post when a tab panel is tracked without a live tab instance", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + await handlers["zoo-code.focusInput"]() + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance there is no other target. expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - // Representative coverage for the .catch arm on all five void-prefixed - // postMessageToWebview sites in registerCommands.ts (settingsButtonClicked - // posts twice, plus historyButtonClicked, marketplaceButtonClicked, and - // acceptInput). Each handler is synchronous, so the .catch arm runs on a - // microtask; setImmediate ensures all microtasks are flushed before we assert. The + it("focusInput posts the focus message on the tab instance when a tab panel is tracked", async () => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + await handlers["zoo-code.focusInput"]() + + // The tab takes selection priority: assert it was selected by identity + // (reference, not structural equality). + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + // No error was logged on the success path. + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("setPanel keeps independent refs: clearing only the tab ref re-enables the sidebar post", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + // The tab ref does not wipe the sidebar ref... + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + + // ...and clearing only the tab ref re-enables the sidebar post. + setPanel(undefined, "tab") + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + // Coverage for the .catch arm on the sidebar title-bar post sites + // (settingsButtonClicked posts twice, plus historyButtonClicked and + // marketplaceButtonClicked) and acceptInput (the visible-provider path). + // Each handler is synchronous, so the .catch arm runs on a microtask; + // setImmediate ensures all microtasks are flushed before we assert. The // log messages carry a `[]` prefix so multi-failure logs // remain unambiguous; the prefix is per-handler, not per-call (both of - // settingsButtonClicked's posts share the same prefix). + // settingsButtonClicked's posts share the same prefix). Each post rejects + // with its own error and call N is pinned to post N, so a mutant that + // alters one catch's message cannot hide behind the other post's + // identical log. it.each([ - { command: "zoo-code.settingsButtonClicked", prefix: "settingsButtonClicked", expectedCalls: 2 }, - { command: "zoo-code.historyButtonClicked", prefix: "historyButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.marketplaceButtonClicked", prefix: "marketplaceButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.acceptInput", prefix: "acceptInput", expectedCalls: 1 }, + { + command: "zoo-code.settingsButtonClicked", + prefix: "settingsButtonClicked", + errorLabels: ["first post", "second post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.historyButtonClicked", + prefix: "historyButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.marketplaceButtonClicked", + prefix: "marketplaceButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { command: "zoo-code.acceptInput", prefix: "acceptInput", errorLabels: ["post"], target: "visible" as const }, ])( "$command logs to outputChannel when postMessageToWebview rejects", - async ({ command, prefix, expectedCalls }) => { - const boom = new Error("boom") - mockVisibleProvider.postMessageToWebview.mockReset() - mockVisibleProvider.postMessageToWebview.mockRejectedValue(boom) + async ({ command, prefix, errorLabels, target }) => { + const post = + target === "sidebar" ? mockProvider.postMessageToWebview : mockVisibleProvider.postMessageToWebview + post.mockReset() + const booms = errorLabels.map((label) => new Error(label)) + booms.forEach((boom) => post.mockRejectedValueOnce(boom)) handlers[command]() - // Flush microtasks so the chained .catch arm runs. + // Flush microtasks so the chained .catch arms run. await new Promise((resolve) => setImmediate(resolve)) - expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(expectedCalls) - expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( - `[${prefix}] postMessageToWebview failed: ${boom}`, - ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(booms.length) + booms.forEach((boom, index) => { + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + index + 1, + `[${prefix}] postMessageToWebview failed: ${boom}`, + ) + }) }, ) + // The two posts reject with distinct errors and the nth-call assertions + // pin each catch's message, so neither template literal can survive + // behind the other post's identical log. + it("settingsButtonClickedInTab logs to outputChannel when postMessageToWebview rejects", async () => { + const booms = [new Error("first post"), new Error("second post")] + const mockTabProvider = { + postMessageToWebview: vi.fn().mockRejectedValueOnce(booms[0]).mockRejectedValueOnce(booms[1]), + } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers["zoo-code.settingsButtonClickedInTab"]() + + // Flush microtasks so the chained .catch arms run. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 1, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[0]}`, + ) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 2, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[1]}`, + ) + }) + + // The history and marketplace InTab catch sites share the identical + // single-post pattern (their sidebar equivalents are covered by the + // it.each above); pin their exact messages too. + it.each([ + { command: "zoo-code.historyButtonClickedInTab", prefix: "historyButtonClickedInTab" }, + { command: "zoo-code.marketplaceButtonClickedInTab", prefix: "marketplaceButtonClickedInTab" }, + ])("$command logs to outputChannel when the tab postMessageToWebview rejects", async ({ command, prefix }) => { + const boom = new Error("post") + const mockTabProvider = { postMessageToWebview: vi.fn().mockRejectedValue(boom) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + // Flush microtasks so the chained .catch arm runs. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(`[${prefix}] postMessageToWebview failed: ${boom}`) + }) + it("toggleAutoApprove logs to outputChannel when postMessageToWebview rejects", async () => { // toggleAutoApprove is `async` and awaits postMessageToWebview inside a // try/catch (rather than relying on a `.catch` microtask like the @@ -407,22 +601,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 +686,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 +699,660 @@ describe("openClineInNewTab", () => { }), ) }) + + it("reveals the existing tab instead of creating a second panel", async () => { + const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + }) + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockExistingProvider) + + const result = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(result).toBe(mockExistingProvider) + expect(mockPanel.reveal).toHaveBeenCalledTimes(1) + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + expect(mockExistingProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + }) + + it("creates a new tab panel when the tracked tab's provider has been disposed", async () => { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + }) + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The reuse path must resolve the tracked panel (not skip the lookup): + // without this assertion the test would also pass if the handler + // stopped consulting getInstanceForView at all. + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(mockPanel) + expect(mockPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("disposes the provider and the panel when a creation step rejects", async () => { + const panel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panel) + + // Fail the LAST creation step: the provider is constructed, the panel is created and + // tracked, and the view resolved - exactly the state a later "Open in editor" would + // otherwise inherit as a dead tab. + const executeCommandMock = vscode.commands.executeCommand as Mock + const previousExecute = executeCommandMock.getMockImplementation() + executeCommandMock.mockImplementation(async (command: string) => { + if (command === "workbench.action.lockEditorGroup") { + throw new Error("lock failed") + } + return previousExecute?.(command) + }) + + try { + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("lock failed") + + // Nothing half-built may stay behind: the provider is disposed (it registers in + // ClineProvider.activeInstances with its listeners), the panel is disposed, and the + // tracked tab ref no longer points at the orphan panel. + const created = vi.mocked(ClineProvider).mock.results[0].value + expect(created.dispose).toHaveBeenCalledTimes(1) + expect(panel.dispose).toHaveBeenCalledTimes(1) + expect(getPanel()).toBeUndefined() + } finally { + // Restore the saved implementation rather than mockRestore(): this file's beforeEach + // only clears call history, and mockRestore() on a bare vi.fn() leaves executeCommand + // with no implementation at all, so every later tab creation in this file breaks. + // In a finally so a failing assertion cannot leak the override. + // The saved default can be undefined (a bare vi.fn()), which is behaviourally + // the same as a no-op implementation - mockImplementation needs a function. + executeCommandMock.mockImplementation(previousExecute ?? (async () => undefined)) + } + }) + + it("reports incomplete cleanup when the tab-creation rollback itself fails", async () => { + const panel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + dispose: vi.fn().mockImplementation(() => { + throw new Error("panel gone") + }), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panel) + const executeCommandMock = vscode.commands.executeCommand as Mock + const previousExecute = executeCommandMock.getMockImplementation() + executeCommandMock.mockImplementation(async (command: string) => { + if (command === "workbench.action.lockEditorGroup") { + // Arm the failing provider cleanup here: the instance exists by now, and this is + // the last await before the rollback runs, so the ordering is deterministic. + vi.mocked(ClineProvider).mock.results[0].value.dispose = vi + .fn() + .mockRejectedValue(new Error("dispose hung")) + throw new Error("lock failed") + } + return previousExecute?.(command) + }) + + // The original failure is still what the caller sees... + try { + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("lock failed") + + // ...both cleanup steps were attempted, and the incomplete result is surfaced + // rather than swallowed behind the original error. + const created = vi.mocked(ClineProvider).mock.results[0].value + expect(created.dispose).toHaveBeenCalledTimes(1) + expect(panel.dispose).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("cleanup was incomplete (provider: dispose hung; panel: panel gone)"), + ) + } finally { + // Restore the saved implementation rather than mockRestore(): this file's beforeEach + // only clears call history, and mockRestore() on a bare vi.fn() leaves executeCommand + // with no implementation at all, so every later tab creation in this file breaks. + // In a finally so a failing assertion cannot leak the override. + // The saved default can be undefined (a bare vi.fn()), which is behaviourally + // the same as a no-op implementation - mockImplementation needs a function. + executeCommandMock.mockImplementation(previousExecute ?? (async () => undefined)) + } + }) + + it("re-points the tracked tab ref at the panel that becomes active", async () => { + // Panel A is created first and tracked... + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // ...then panel B is created, which re-points the tracked tab ref. + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Activating A must reassign the tracked tab ref to A's panel... + const stateChange = (panelA.onDidChangeViewState as Mock).mock.calls[0]![0] as (e: { + webviewPanel: vscode.WebviewPanel + }) => void + // Activate panelA in place and pass it through: the production handler + // tracks e.webviewPanel directly, so a clone would let a handler that + // copies the event panel still pass the identity check below. + Object.assign(panelA, { active: true, visible: true }) + stateChange({ webviewPanel: panelA }) + + // ...so plusButtonClickedInTab targets A's provider, not B's. + const mockProviderA = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + // Require the tracked panel by identity: a handler that clones the + // state-change panel can no longer resolve the provider. + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + view === panelA ? mockProviderA : undefined, + ) + const handlers = new Map unknown>() + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + handlers.set(id, cb) + return { dispose: vi.fn() } + }, + ) + const mockSidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: mockSidebarProvider, + }) + + await handlers.get("zoo-code.plusButtonClickedInTab")!() + + expect(mockProviderA.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { + ;(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("clears the in-flight creation when it rejects so a later open retries", async () => { + // A creation that fails must not stay in the slot: every later "Open in editor" + // would receive the settled rejected promise and the tab could never be opened + // again without reloading the window. + const failure = new Error("context proxy unavailable") + ;(ContextProxy.getInstance as Mock).mockRejectedValueOnce(failure) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow(failure) + + // The retry starts a fresh creation instead of replaying the stored rejection. + // Capture how many providers have been constructed so the retry's own + // instance can be identified below. + const ctor = vi.mocked(ClineProvider) + const createdBeforeRetry = ctor.mock.instances.length + const retried = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + // Identity, not just definedness: the retry must return the provider it + // constructed in this call, not a leftover from the rejected one. + const expected = ctor.mock.instances[createdBeforeRetry] + expect(expected).toBeDefined() + expect(retried).toBe(expected) + expect(ContextProxy.getInstance).toHaveBeenCalledTimes(2) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("shares a rejected creation with overlapping callers and still clears the slot", async () => { + // Both callers join the same in-flight creation, so both must observe its + // rejection: dropping it for the joined caller would leave that command + // handler hanging on a promise that never yields a usable result. + let rejectCreation!: (error: Error) => void + // Only the in-flight creation is deferred; the retry after it settles gets the + // normal (immediate) context proxy. + ;(ContextProxy.getInstance as Mock).mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectCreation = reject + }), + ) + + const first = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + const second = openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Attach the observers before the rejection is triggered. + const firstSettled = first.then( + () => "resolved", + (error: unknown) => error, + ) + const secondSettled = second.then( + () => "resolved", + (error: unknown) => error, + ) + + const failure = new Error("panel creation failed") + rejectCreation(failure) + + expect(await firstSettled).toBe(failure) + expect(await secondSettled).toBe(failure) + expect(ContextProxy.getInstance).toHaveBeenCalledTimes(1) + + // The slot is cleared even on the shared-rejection path: the next open runs a + // fresh creation instead of replaying the stored rejection. + const ctor = vi.mocked(ClineProvider) + const createdBeforeRetry = ctor.mock.instances.length + const third = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + // Identity, not just definedness: the next open returns the provider this + // call constructed, proving the slot was cleared rather than replayed. + const expected = ctor.mock.instances[createdBeforeRetry] + expect(expected).toBeDefined() + expect(third).toBe(expected) + expect(ContextProxy.getInstance).toHaveBeenCalledTimes(2) + }) + + it("keeps the replacement panel tracked when a stale panel's disposal fires late", async () => { + // Capture each created panel so the first panel's (stale) dispose + // handler can fire after the replacement is already tracked. + const createdPanels: { onDidDispose: Mock }[] = [] + ;(vscode.window.createWebviewPanel as Mock).mockImplementation(() => { + const panel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + createdPanels.push(panel) + return panel + }) + + // First open creates and tracks panel A. + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(getPanel()).toBe(createdPanels[0]) + + // Panel A's provider is disposed before the second open, so the + // second open creates the replacement panel B. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(getPanel()).toBe(createdPanels[1]) + + // Panel A's stale dispose handler fires after the replacement is + // tracked; it must not clobber the replacement's ref. + createdPanels[0].onDidDispose.mock.calls[0][0]() + + expect(getPanel()).toBe(createdPanels[1]) + + // Tab-surface commands still reach the provider that owns the + // replacement panel after the stale disposal. + const replacementProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(replacementProvider) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + await commandHandlers["zoo-code.historyButtonClickedInTab"]() + expect(replacementProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "historyButtonClicked", + }) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 79e629f479..9c7bcc4a63 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 { Package } from "../shared/package" @@ -32,6 +32,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 @@ -41,7 +47,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, @@ -49,13 +60,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 @@ -85,27 +105,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") @@ -115,43 +166,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 () => { @@ -178,8 +236,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) { @@ -229,6 +294,91 @@ type OpenClineInNewTabOptions = { } export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTracker }: OpenClineInNewTabOptions) => { + if (pendingTabPanelCreation) { + return pendingTabPanelCreation + } + + const creation = createTabPanelUnlocked({ context, outputChannel, webviewFocusTracker }) + 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. + // Stryker disable next-line ConditionalExpression: defensive clobber guard, unreachable per the ordering argument above. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined + } + } +} + +/** + * Undo a tab creation that failed part way through. A ClineProvider that was constructed + * but never resolved stays registered in ClineProvider.activeInstances with its listeners + * attached, and setPanel may already have pointed the tracked tab ref at a panel nobody + * owns - so every later "Open in editor" either reuses a dead panel or builds a + * second provider for the same view. Each step is awaited in its own try/catch so one + * failing cleanup cannot strand the other, and the tracked ref is cleared only while it + * still names this panel, so a replacement created in the meantime survives. + */ +async function disposeFailedTabCreation( + provider: ClineProvider, + panel: vscode.WebviewPanel | undefined, + outputChannel: vscode.OutputChannel, +): Promise { + const describe = (e: unknown) => (e instanceof Error ? e.message : String(e)) + const cleanupFailures: string[] = [] + try { + await provider.dispose() + } catch (cleanupError: unknown) { + cleanupFailures.push(`provider: ${describe(cleanupError)}`) + } + + if (panel) { + if (tabPanel === panel) { + setPanel(undefined, "tab") + } + try { + panel.dispose() + } catch (cleanupError: unknown) { + cleanupFailures.push(`panel: ${describe(cleanupError)}`) + } + } + + if (cleanupFailures.length > 0) { + outputChannel.appendLine( + `[openClineInNewTab] Tab creation failed and cleanup was incomplete (${cleanupFailures.join("; ")}); a leaked provider or panel may remain.`, + ) + } +} + +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTracker }: 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 + } + + // The tracked panel has no live provider (closed or disposed out from under us). + // Drop the stale reference before creating a replacement: an await below that + // rejects would otherwise leave tabPanel pointing at the dead panel, and focusInput + // would take the tab branch and post nothing even though a sidebar exists. + tabPanel = undefined + } + // (This example uses webviewProvider activation event which is necessary to // deserialize cached webview, but since we use retainContextWhenHidden, we // don't need to use that event). @@ -240,7 +390,8 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr try { mdmService = MdmService.getInstance() } catch (error) { - // MDM service not initialized, which is fine - extension can work without it + // MDM service unavailable: log the fallback and continue without it. + outputChannel.appendLine(`[openClineInNewTab] MDM service unavailable, continuing without it: ${error}`) mdmService = undefined } @@ -252,60 +403,84 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr webviewFocusTracker, mdmService, ) - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - - // Check if there are any visible text editors, otherwise open a new group - // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + // Track the panel outside the try so the cleanup can reach it even when the creation + // failed before or while the panel was being built. + let newPanel: vscode.WebviewPanel | undefined + try { + const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } + // Check if there are any visible text editors, otherwise open a new group + // to the right. + const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + if (!hasVisibleEditors) { + await vscode.commands.executeCommand("workbench.action.newGroupRight") + } - const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - // Save as tab type panel. - setPanel(newPanel, "tab") + newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }) - // TODO: Use better svg icon with light and dark variants (see - // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). - newPanel.iconPath = { - light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), - dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), - } + // Save as tab type panel. + // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment + setPanel(newPanel, "tab") - await tabProvider.resolveWebviewView(newPanel) + // TODO: Use better svg icon with light and dark variants (see + // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). + newPanel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), + } - // Add listener for visibility changes to notify webview - newPanel.onDidChangeViewState( - (e) => { - const panel = e.webviewPanel - if (panel.visible) { - panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx - } - }, - null, // First null is for `thisArgs` - context.subscriptions, // Register listener for disposal - ) + await tabProvider.resolveWebviewView(newPanel) + + // Add listener for visibility changes to notify webview + newPanel.onDidChangeViewState( + (e) => { + const panel = e.webviewPanel + // Re-point the tracked tab ref at the panel the user is actually + // looking at: several tab panels can stay visible at once, but + // only the active one is the current tab, and the title-bar + // commands must resolve that instance, not the last created one. + if (panel.active) { + // Stryker disable next-line StringLiteral: setPanel only distinguishes "sidebar"; any other value routes to the tab-ref assignment + setPanel(panel, "tab") + } + if (panel.visible) { + panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx + } + }, + null, // First null is for `thisArgs` + context.subscriptions, // Register listener for disposal + ) - // Handle panel closing events. - newPanel.onDidDispose( - () => { - setPanel(undefined, "tab") - }, - null, - context.subscriptions, // Also register dispose listener - ) + // 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( + () => { + if (tabPanel === newPanel) { + // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment + setPanel(undefined, "tab") + } + }, + null, + context.subscriptions, // Also register dispose listener + ) - // Lock the editor group so clicking on files doesn't open them over the panel. - await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + // Lock the editor group so clicking on files doesn't open them over the panel. + await delay(100) + await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - return tabProvider + return tabProvider + } catch (error: unknown) { + // Nothing that half-built this tab is safe to leave behind: see + // disposeFailedTabCreation. The original failure is what the caller must see. + await disposeFailedTabCreation(tabProvider, newPanel, outputChannel) + throw error + } } diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..38c2c9ce99 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 3fcc0e6e43..39dfc96de8 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -52,6 +52,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() @@ -419,7 +432,7 @@ export class ProviderSettingsManager { name = params.name if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config with name '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config with name '${name}' not found`) } providerSettings = providerProfiles.apiConfigs[name] @@ -431,7 +444,7 @@ export class ProviderSettingsManager { ) if (!entry) { - throw new Error(`Config with ID '${id}' not found`) + throw new ProviderSettingsNotFoundError(`Config with ID '${id}' not found`) } name = entry[0] @@ -441,6 +454,13 @@ export class ProviderSettingsManager { return { name, ...providerSettings } }) } catch (error) { + // A missing profile is an expected, actionable condition, not an I/O failure, and + // callers branch on the typed error (deleteProviderProfile prunes a stale list + // entry only for that type). Wrapping it here would erase the distinction, so the + // typed error is rethrown as-is; every other failure keeps the wrapped context. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to get profile: ${error instanceof Error ? error.message : error}`) } } @@ -474,7 +494,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) { @@ -485,6 +505,11 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + // A missing config is a caller-meaningful signal, not a failure: rethrow it + // unwrapped so callers can branch on the type instead of message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2319a6b1a5..d389d31337 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -721,4 +721,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 13e1aeeb2d..bbed6322c6 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,12 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +import { + ProviderSettingsManager, + ProviderSettingsNotFoundError, + ProviderProfiles, + SyncCloudProfilesResult, +} 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 @@ -745,6 +750,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", ) @@ -821,6 +831,44 @@ describe("ProviderSettingsManager", () => { ) }) + it("rejects a missing profile lookup with the typed not-found error", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: { config: {}, id: "default" } }, + }), + ) + + // Callers branch on the type, not the message: deleteProviderProfile treats a missing + // profile as an expected condition to prune from the list, and wraps every OTHER + // failure as a real error. A generic wrapper here would erase that distinction. + await expect(providerSettingsManager.getProfile({ name: "nonexistent" })).rejects.toThrow( + ProviderSettingsNotFoundError, + ) + }) + + it("rejects a missing id lookup with the typed error naming that id", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: { config: {}, id: "default" }, + named: { config: {}, id: "known-id" }, + }, + }), + ) + + // The id branch is the one the mode mapping and deleteProviderProfile hit: a stale + // mode -> id mapping must surface as a not-found the caller can prune, not as a + // generic failure that gets re-wrapped as unexpected. + await expect(providerSettingsManager.getProfile({ id: "missing-id" })).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) + await expect(providerSettingsManager.getProfile({ id: "missing-id" })).rejects.toThrow( + "Config with ID 'missing-id' not found", + ) + }) + it("should throw error if secrets storage fails", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 0287b26511..286c7cae47 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -332,6 +332,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 3c213fedf4..3351a20d62 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 4b4139ccb9..c3b8a7367b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -110,13 +110,13 @@ 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" import { webviewMessageHandler } from "./webviewMessageHandler" import type { WebviewFocusTracker } from "./WebviewFocusTracker" -import type { ClineMessage, TodoItem } from "@roo-code/types" +import type { ClineMessage, ProviderSettingsWithId, TodoItem } from "@roo-code/types" import { type ApiMessage, readApiMessages, @@ -137,6 +137,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 @@ -195,6 +203,46 @@ type GetStateOptions = { includeTaskHistory?: boolean } +/** + * Raised when a profile activation or upsert failed after durable writes landed and at least one + * compensating write failed too. The persisted profile stores may now disagree, which is not the + * ordinary "the save failed and nothing changed" outcome the caller's `undefined` would suggest. + */ +class ProfileActivationInconsistentError extends Error { + constructor(message: string) { + super(message) + this.name = "ProfileActivationInconsistentError" + } +} + +/** + * The durable stores a profile activation or upsert rewrites, captured before the first write so a + * rejected mutation can put every landed write back. `mode` is undefined when the caller never + * touches the per-mode mapping, and `previousProfileSettings` is undefined when the profile did not + * exist yet - the creation case the compensation must delete rather than restore. + */ +interface ProfileActivationSnapshot { + profileName: string + mode: Mode | undefined + previousProfileSettings: ProviderSettingsWithId | undefined + entries: ProviderSettingsEntry[] + modeConfigId: string | undefined + selection: string | undefined + viewSelection: string | undefined + sharedProviderSettings: ProviderSettings + viewOverlay: ProviderSettings | undefined +} + +/** Which of the snapshotted stores this mutation actually committed. */ +interface ProfileActivationLanded { + profileRecord: boolean + profileList: boolean + modeMapping: boolean + selection: boolean + sharedProviderSettings: boolean + viewOverlay: boolean +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -205,6 +253,9 @@ 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 persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -287,9 +338,12 @@ export class ClineProvider }, ) - // Advance from the timeout-bounded result. Each fn checks its AbortSignal before - // writing state, so advancing the queue on timeout cannot produce stale overwrites. - this.providerProfileMutationQueue = callerResult.then( + // Chain the queue to the underlying run, not to the timeout-bounded result: the timeout + // exists to release the caller, not to release the queue. A mutation that is still + // writing durable state must keep later mutations out, otherwise the next profile + // mutation interleaves its own writes with the one still in flight - the abort signal is + // advisory, so a fn that ignores it would run concurrently with its successor. + this.providerProfileMutationQueue = run.then( () => undefined, () => undefined, ) @@ -324,6 +378,34 @@ 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 + + /** + * 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 = {} + + /** + * 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.86.0-models-aborts-tool-streaming" // v3.86.0 models, aborts, and tool/UI streaming fixes @@ -339,14 +421,17 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -371,6 +456,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -493,6 +581,277 @@ 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 {} + } + + return { ...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 }) + const current = states[viewStateId] ?? {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * 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 () => { + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + 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 { + 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 + } + + 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 + 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 */ @@ -840,6 +1199,13 @@ export class ClineProvider } this._disposed = true + // Unregister at the START of disposal, synchronously and before the first await below. + // The teardown awaits task eviction and several managers, and while it runs a provider that + // is already _disposed but still in activeInstances is enumerated by getAllInstances(), + // getVisibleInstance() and getInstanceForView() - so a sibling profile mutation could persist + // durable view state and post to a view that is on its way out. Unregistering here makes that + // window zero-length; no teardown step below looks this instance up in the registry. + ClineProvider.activeInstances.delete(this) this._postStateToWebviewThrottled.cancel() this.log("Disposing ClineProvider...") @@ -887,17 +1253,36 @@ export class ClineProvider } } - this._workspaceTracker?.dispose() - this._workspaceTracker = undefined - await this.mcpHub?.unregisterClient() + // Each teardown step gets its own guard: a rejecting step must not strand the steps after + // it. The registry unregistration already happened at the top of dispose(), so a step + // that throws can no longer leave this provider enumerable; the remaining steps still run, + // because _disposed is set and this provider never gets another chance to finish here. + const cleanupFailures: string[] = [] + const attemptCleanup = async (label: string, step: () => unknown) => { + try { + await step() + } catch (error: unknown) { + cleanupFailures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + await attemptCleanup("workspace tracker", () => { + this._workspaceTracker?.dispose() + this._workspaceTracker = undefined + }) + await attemptCleanup("mcpHub", () => this.mcpHub?.unregisterClient()) this.mcpHub = undefined - await this.skillsManager?.dispose() + await attemptCleanup("skills manager", () => this.skillsManager?.dispose()) this.skillsManager = undefined - await this.marketplaceManager?.cleanup() - this.customModesManager?.dispose() - this.taskHistoryStore.dispose() - this.log("Disposed all disposables") - ClineProvider.activeInstances.delete(this) + await attemptCleanup("marketplace manager", () => this.marketplaceManager?.cleanup()) + await attemptCleanup("custom modes manager", () => this.customModesManager?.dispose()) + await attemptCleanup("task history store", () => this.taskHistoryStore.dispose()) + + if (cleanupFailures.length > 0) { + this.log(`Disposal was incomplete (${cleanupFailures.join("; ")}); the provider was unregistered anyway.`) + } else { + this.log("Disposed all disposables") + } // Clean up any event listeners attached to this provider this.removeAllListeners() @@ -917,6 +1302,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() @@ -1251,7 +1646,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, @@ -1467,8 +1865,28 @@ export class ClineProvider return } + 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. try { - await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) + 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)}`, + ) + }, + ) } catch { // View disposed, drop message silently } @@ -1725,9 +2143,21 @@ 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 + } + + // Set once the task-history write has landed and cleared once the durable mode + // write settles, so the durable-write failure path can undo the task-side write. + let undoHistoryWrite: (() => Promise) | null = null + 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. @@ -1735,10 +2165,50 @@ 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 } - // Only update the task's mode after successful persistence. - ;(task as any)._taskMode = newMode + // TaskModeSwitched and the in-memory mode are applied only after the durable + // mode write below succeeds, so a failed switch cannot leave the task on the + // new mode while the provider state is still on the old one. Until then this + // is the compensation for the history write that just landed. + undoHistoryWrite = async () => { + if (!taskHistoryItem) { + // No history item existed before the switch, so no history write landed: + // there is nothing to undo. + return + } + const previousMode = taskHistoryItem.mode + // 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 + if (!latest) { + // The item was removed while the write was in flight; nothing to restore. + return + } + await this.updateTaskHistory({ ...latest, mode: previousMode }) + } } catch (error) { // If persistence fails, log the error but don't update the in-memory state. this.log( @@ -1751,7 +2221,157 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + // A cancelled or timed-out switch must not write the mode or emit + // ModeChanged: check the mutation signal right before the durable write. + if (signal?.aborted) { + return + } + + // 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. + // Two independent values are at stake: the shared ContextProxy mode and this + // view's own pin (viewLocalState.mode). setValue writes BOTH, so each must be + // restored from its own pre-switch value - replaying the shared value through + // setValue would overwrite a pin that held a different mode (a view pinned to + // architect while the shared mode was code would end the rollback pinned to + // code). previousViewMode is undefined when the view has no pin. + const previousSharedMode = this.getValue("mode") + const previousViewMode = this.viewLocalState.mode + try { + await this.setValue("mode", newMode) + } catch (error) { + try { + // Shared value only: the failed write never reached the view-local + // mutation step, so the pin still holds its own value and must not be + // touched here. + await this.contextProxy.setValue("mode", previousSharedMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + // The task-history write landed before the durable mode write, so undo it as + // well: leaving the history on the new mode while the shared/per-view mode is + // restored is exactly the inconsistency this switch must not produce. + if (undoHistoryWrite) { + try { + await undoHistoryWrite() + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back task-history 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 + } + + // The durable write can settle AFTER the mutation timeout has already aborted this + // run and handed the caller a timeout rejection. Nothing below may then run: + // emitting TaskModeSwitched/ModeChanged or loading the mode profile would publish a + // switch the caller was told had been cancelled, and the next queued mutation could + // start against a half-applied mode. Undo everything that landed and keep the + // cancellation result; each restore is awaited on its own so one failing write + // cannot skip the next. + if (signal?.aborted) { + try { + await this.contextProxy.setValue("mode", previousSharedMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after cancellation: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + try { + await this._saveViewLocalStateFromMutation({ mode: previousViewMode }) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back the view mode pin after cancellation: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + if (undoHistoryWrite) { + try { + await undoHistoryWrite() + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back task-history mode after cancellation: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + return + } + + // Both durable writes succeeded: this is the point of no return for the task + // side. Emitting and updating _taskMode here (rather than before the durable + // write) keeps listeners and in-memory task state from observing a switch that + // the persisted provider state contradicts. + if (task) { + try { + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + ;(task as any)._taskMode = newMode + } catch (error) { + // A listener that threw (or the in-memory write failing) must not leave the + // durable mode on the new value while the task is still on the old one: undo + // the task-history write and the durable mode write, then surface the failure. + if (undoHistoryWrite) { + try { + await undoHistoryWrite() + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back task-history mode after the emit failed: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + // Each restore is awaited on its own: a failed shared-mode write must not skip the + // pin restore (or the reverse), because one store left on the new mode while the + // other is back on the old one is exactly the split state this rollback exists to + // prevent. A rollback that itself failed is reported, not just logged. + const rollbackFailures: string[] = [] + const describeModeRollbackFailure = (e: unknown) => (e instanceof Error ? e.message : String(e)) + try { + // Restore the shared value through the proxy only. + await this.contextProxy.setValue("mode", previousSharedMode) + } catch (rollbackError) { + rollbackFailures.push(`shared mode: ${describeModeRollbackFailure(rollbackError)}`) + } + try { + // Then the pin from its own pre-switch value. When the view had no pin, the forward + // setValue created one; clearing it (mode: undefined) is what returns this view to + // its pre-switch state instead of leaving the shared mode pinned. + await this._saveViewLocalStateFromMutation({ mode: previousViewMode }) + } catch (rollbackError) { + rollbackFailures.push(`view mode pin: ${describeModeRollbackFailure(rollbackError)}`) + } + if (rollbackFailures.length > 0) { + // Unrepaired partial state must not be reported as a clean rollback: the caller + // needs to know the durable mode may still name the mode this switch abandoned. + this.log( + `[handleModeSwitch] Mode rollback left persisted state inconsistent (${rollbackFailures.join(", ")}).`, + ) + throw new Error( + `Mode switch left persisted mode state inconsistent: ${rollbackFailures.join(", ")}. Original failure: ${error instanceof Error ? error.message : String(error)}`, + ) + } + throw error + } + } this.emit(RooCodeEventName.ModeChanged, newMode) @@ -1872,51 +2492,244 @@ export class ClineProvider return !!this.getProviderProfileEntry(name) } - async upsertProviderProfile( - name: string, - providerSettings: ProviderSettings, - activate: boolean = true, - ): Promise { + /** + * Snapshot every durable store that profile activation and upsert are about to rewrite. The + * stores are independent - the profile-manager record, the profile list, the per-mode mapping, + * the shared current name, the shared provider settings blob, and this view's own buffer - so a + * partial commit is user visible: `getState()` merges the shared blob with the per-view buffer, + * which can pair the previous profile's name with the new profile's settings. + */ + private async snapshotProfileActivationStores( + profileName: string, + mode: Mode | undefined, + ): Promise { + let previousProfileSettings: ProviderSettingsWithId | undefined + try { - return await this.enqueueProviderProfileMutation(async (signal) => { - // TODO: Do we need to be calling `activateProfile`? It's not - // clear to me what the source of truth should be; in some cases - // we rely on the `ContextProxy`'s data store and in other cases - // we rely on the `ProviderSettingsManager`'s data store. It might - // be simpler to unify these two. - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) - - if (signal.aborted) return id - - if (activate) { - const { mode } = await this.getState() - - // 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. - await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), - this.providerSettingsManager.setModeConfig(mode, id), - this.contextProxy.setProviderSettings(providerSettings), - ]) - - // Change the provider for the current task. - // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) - - // Keep the current task's sticky provider profile in sync with the newly-activated profile. - await this.persistStickyProviderProfileToCurrentTask(name) - } else { - await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) - } + const { name: _name, ...profile } = await this.providerSettingsManager.getProfile({ name: profileName }) + previousProfileSettings = profile as ProviderSettingsWithId + } catch (error: unknown) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + // Without the prior record there is no way to put an updated profile back, so fail + // before the first write instead of discovering it in the compensation path. + throw error + } + // Not-found is the creation case: compensation deletes the new record again. + } + + const { currentApiConfigName } = this.contextProxy.getValues() + + return { + profileName, + mode, + previousProfileSettings, + entries: this.getProviderProfileEntries(), + modeConfigId: mode === undefined ? undefined : await this.providerSettingsManager.getModeConfigId(mode), + selection: currentApiConfigName, + viewSelection: this.viewLocalState.currentApiConfigName, + sharedProviderSettings: this.contextProxy.getProviderSettings(), + viewOverlay: this.viewLocalState.apiConfiguration, + } + } + + /** + * Roll a failed profile activation or upsert back store by store, newest write first. Only the + * stores whose write landed are touched: replaying one this call never wrote would clobber a + * value another mutation committed in the meantime. Restore failures are collected rather than + * thrown immediately so one broken store cannot strand the rest, and a partial rollback + * surfaces as an explicit inconsistent-state error - the silent `undefined` callers used to + * receive is exactly what hid a half-applied activation. + */ + private async compensateProfileActivation( + snapshot: ProfileActivationSnapshot, + landed: ProfileActivationLanded, + operation: string, + cause: unknown, + managerCurrentNameRewritten: boolean, + ): Promise { + const describeFailure = (e: unknown) => (e instanceof Error ? e.message : String(e)) + const failures: string[] = [] + + if (landed.viewOverlay) { + try { + await this._saveViewLocalStateFromMutation({ apiConfiguration: snapshot.viewOverlay }) + } catch (error: unknown) { + failures.push(`view-local buffer: ${describeFailure(error)}`) + } + } + + if (landed.sharedProviderSettings) { + try { + await this.contextProxy.setProviderSettings(snapshot.sharedProviderSettings) + } catch (error: unknown) { + failures.push(`shared provider settings: ${describeFailure(error)}`) + } + } + + if (landed.selection) { + // The shared selection and this view's pin are two stores. setValue moves both, so a + // rollback that only means to restore the shared name would also overwrite a pin that + // legitimately differed from it: a sibling activation leaves the shared selection on + // another profile while this view stays pinned to its own. Restore them separately, and + // clear the pin when this view had none before the call. + try { + await this.contextProxy.setValue("currentApiConfigName", snapshot.selection) + } catch (error: unknown) { + failures.push(`shared selection: ${describeFailure(error)}`) + } + try { + await this._saveViewLocalStateFromMutation({ currentApiConfigName: snapshot.viewSelection }) + } catch (error: unknown) { + failures.push(`view profile pin: ${describeFailure(error)}`) + } + } + + if (landed.modeMapping && snapshot.mode !== undefined) { + try { + // The manager serializes modeApiConfigs through JSON, which drops undefined + // values, so writing the previous undefined id back clears the mapping instead of + // storing a bogus id. + await this.providerSettingsManager.setModeConfig(snapshot.mode, snapshot.modeConfigId as string) + } catch (error: unknown) { + failures.push(`mode mapping: ${describeFailure(error)}`) + } + } + + if (landed.profileList) { + try { + // Only the list is replayed: writing back the whole settings snapshot would also + // rewrite unrelated keys (viewStates included) with this view's cached copy. + await this.contextProxy.setValue("listApiConfigMeta", snapshot.entries) + } catch (error: unknown) { + failures.push(`profile list: ${describeFailure(error)}`) + } + } + + if (landed.profileRecord) { + try { + if (snapshot.previousProfileSettings === undefined) { + await this.providerSettingsManager.deleteConfig(snapshot.profileName) + } else { + await this.providerSettingsManager.saveConfig( + snapshot.profileName, + snapshot.previousProfileSettings, + ) + } + } catch (error: unknown) { + failures.push(`profile-manager record: ${describeFailure(error)}`) + } + } + + if (managerCurrentNameRewritten && snapshot.selection && snapshot.selection !== snapshot.profileName) { + try { + // `activateProfile` rewrote the profile manager's own current-profile record; put + // the previous name back so a later load cannot resolve the aborted activation. + await this.providerSettingsManager.activateProfile({ name: snapshot.selection }) + } catch (error: unknown) { + failures.push(`profile-manager current profile: ${describeFailure(error)}`) + } + } + + if (failures.length > 0) { + throw new ProfileActivationInconsistentError( + `${operation}: the profile change failed after durable writes landed and the rollback was incomplete (${failures.join("; ")}); the persisted profile stores may now disagree. Original failure: ${describeFailure(cause)}`, + ) + } + + this.log(`${operation}: rolled back the profile writes that had landed after ${describeFailure(cause)}`) + } + + async upsertProviderProfile( + name: string, + providerSettings: ProviderSettings, + activate: boolean = true, + ): Promise { + try { + return await this.enqueueProviderProfileMutation(async (signal) => { + // TODO: Do we need to be calling `activateProfile`? It's not + // clear to me what the source of truth should be; in some cases + // we rely on the `ContextProxy`'s data store and in other cases + // we rely on the `ProviderSettingsManager`'s data store. It might + // be simpler to unify these two. + // The mode is read before the first write because the per-mode mapping is one of + // the durable stores the snapshot below captures. + const mode = activate ? (await this.getState()).mode : undefined + const snapshot = await this.snapshotProfileActivationStores(name, mode) + const landed: ProfileActivationLanded = { + profileRecord: false, + profileList: false, + modeMapping: false, + selection: false, + sharedProviderSettings: false, + viewOverlay: false, + } + let id: string + + try { + id = await this.providerSettingsManager.saveConfig(name, providerSettings) + landed.profileRecord = true + + if (signal.aborted) { + // The mutation timeout already released the caller with a timeout rejection, and this + // write has landed since. Returning here would exit the try without an exception, so + // the compensation below would never run and the record would stay saved under a + // caller that was told the save failed. Throwing routes this checkpoint through the + // same store-by-store rollback a rejected write gets - the landed flags still keep + // it to the stores this call owns, and an incomplete rollback still surfaces as the + // inconsistent-state error. The queue stays chained to this run, so no later + // mutation can have written these stores in the meantime. + throw new Error("Profile upsert was cancelled after the profile-record write") + } + + await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + landed.profileList = true + + if (mode !== undefined) { + // The durable writes are serialized instead of raced in one Promise.all so + // every step can record that it landed: a later rejection then puts back + // exactly the stores this call changed. Previously a rejected per-view write + // rolled back only the shared name inside setValue, while the profile record, + // the profile list, the mode mapping, and the shared provider settings stayed + // committed - leaving getState() reporting the previous profile's name next + // to the new profile's settings. + await this.providerSettingsManager.setModeConfig(mode, id) + landed.modeMapping = true + + // 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(). + await this.setValue("currentApiConfigName", name) + landed.selection = true + + // Marked before the await: setProviderSettings fans out to several globalState + // keys, so a rejection partway through the fan-out still has to be rolled back. + landed.sharedProviderSettings = true + await 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(). + await this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }) + landed.viewOverlay = true + + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the updated profile's + // name with stale settings. Sibling buffers this call already rewrote are + // restored by the refresh itself before it rejects. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + + // Change the provider for the current task. + // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + + // Keep the current task's sticky provider profile in sync with the newly-activated profile. + await this.persistStickyProviderProfileToCurrentTask(name) + } + } catch (error: unknown) { + await this.compensateProfileActivation(snapshot, landed, "upsertProviderProfile", error, false) + throw error + } await this.postStateToWebview() return id @@ -1927,11 +2740,32 @@ export class ClineProvider ) vscode.window.showErrorMessage(t("common:errors.create_api_config")) + + if (error instanceof ProfileActivationInconsistentError) { + // The rollback itself was incomplete. Returning `undefined` here would read as "the + // save failed and nothing changed" while the persisted profile stores disagree, so + // the caller gets the inconsistency instead. + throw error + } + return undefined } } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { + // Deletion snapshots and compensates the same durable stores that upsert, + // activation, and mode switch mutate, so it has to run serialized against them: + // an overlapping mutation could otherwise land between the snapshot and the + // rollback, and the compensation would restore a stale copy over the newer write. + return this.enqueueProviderProfileMutation((signal) => + this.deleteProviderProfileUnlocked(profileToDelete, signal), + ) + } + + private async deleteProviderProfileUnlocked( + profileToDelete: ProviderSettingsEntry, + signal: AbortSignal, + ): Promise { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName @@ -1943,14 +2777,252 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // 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. + // The settings-store commit and the profile-list write below are two separate + // durable writes. Capture the settings first so a failure after the commit can be + // compensated: without them the stored profile list would name a profile whose + // settings no longer exist, and a later selection or load could not recover them. + let deletedProfile: ProviderSettingsWithId | undefined + try { + const { name: _deletedName, ...profile } = await this.providerSettingsManager.getProfile({ + name: profileToDelete.name, + }) + deletedProfile = profile as ProviderSettingsWithId + } catch (error: unknown) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + // Abort BEFORE the destructive delete. Without the captured settings there is no + // way to put them back if a later durable write fails, so continuing here would + // turn a transient read error into permanent profile loss while the persisted list + // still named the profile. + this.log( + `deleteProviderProfile: could not read the settings for '${profileToDelete.name}'; aborting before the deletion because a later failure could not be compensated. ${ + error instanceof Error ? error.message : String(error) + }`, + ) + throw error + } + // A typed not-found is the idempotent case: the secret is already gone, so there is + // nothing to compensate and the stale list entry is still pruned below. + } + + if (signal.aborted) { + // Queue contract: check the signal before the first destructive write, so a + // deletion that timed out while queued behind another mutation does not destroy + // settings that no caller is waiting for any more. + throw new Error("Profile deletion was cancelled before the settings commit") + } - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) + 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`, + ) + } + + // Snapshot every durable store this method is about to change, so a failure after the + // settings commit can be rolled back store by store instead of only re-saving the + // settings: a later failed write would otherwise leave the persisted list, the shared + // selection, or this view's own pin pointing at a profile that no longer exists. + const previousEntries = this.getProviderProfileEntries() + const previousGlobalSelection = globalSettings.currentApiConfigName + const previousViewPin = this.viewLocalState.currentApiConfigName + const previousViewOverlay = this.viewLocalState.apiConfiguration + // The shared provider keys are a fifth durable store this method rewrites: without the + // same snapshot the compensation would restore the profile list and the selection while + // leaving the survivor's provider keys in place, i.e. the restored profile's name paired + // with another profile's configuration. + const previousSharedProviderSettings = this.contextProxy.getProviderSettings() + // Which writes have actually landed: only those need compensation. + let listWriteLanded = false + let selectionWriteLanded = false + let viewPinWriteLanded = false + let providerSettingsWriteLanded = false + const entries = previousEntries.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. + try { + if (signal.aborted) { + // Same contract after the settings commit: throwing here routes through the + // compensation below, so the settings are put back instead of leaving a + // half-applied deletion behind for the next mutation to read. + throw new Error("Profile deletion was cancelled before the profile-list write") + } + + await this.contextProxy.setValue("listApiConfigMeta", entries) + listWriteLanded = true + // 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) + }`, + ) + } + + // Stopping here is still deliberate, but for a different reason than before the queue + // was chained: throwing routes through the compensation below, which now always runs, + // so the deletion is rolled back rather than left half-applied. Continuing into the + // rewrites below would spend writes on a caller that has already been released. + if (signal.aborted) { + this.log( + `deleteProviderProfile: cancelled before the selection/settings rewrite; the deletion stopped with the profile list already updated.`, + ) + throw new Error("Profile deletion was cancelled before the selection and settings rewrite") + } + + // 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 + + 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) + selectionWriteLanded = true + viewPinWriteLanded = true + } 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) + selectionWriteLanded = true + } + + if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { + // The deleted profile was the active one (globally, or for this view), so + // the shared provider keys still carry its settings; replace them so + // getState() reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + providerSettingsWriteLanded = true + + 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 }) + viewPinWriteLanded = true + } + } + + // Re-pin other live views still buffered on the deleted profile: their + // buffer and durable viewStates entry would otherwise keep serving the + // deleted profile's name and configuration. + await this.rePinViewLocalStateForDeletedProfile(profileToDelete.name, profileToActivate, survivingSettings) + } catch (error: unknown) { + // A cancelled (timed-out) deletion still compensates. The queue is chained to this run, + // so no later mutation can have written these stores in the meantime: holding the queue + // until the run settles is exactly what makes the rollback safe again. Skipping it would + // leave a half-applied deletion behind - settings gone while the profile list or the + // selection still name the profile, or the list pruned while pins keep pointing at it. + + // Compensate every store that already landed, each awaited on its own so one + // failing restore cannot skip the next one. The deletion simply did not happen: + // the settings, the profile list, the shared selection, and this view's pin must + // all agree again before the original failure is surfaced. + const compensationFailures: string[] = [] + const describeFailure = (e: unknown) => (e instanceof Error ? e.message : String(e)) + + if (deletedProfile) { + try { + await this.providerSettingsManager.saveConfig(profileToDelete.name, deletedProfile) + } catch (compensationError: unknown) { + compensationFailures.push(`settings for the deleted profile: ${describeFailure(compensationError)}`) + } + } + + if (listWriteLanded) { + try { + // Restore only what this deletion owned. The mutation queue is per instance, so another + // provider can upsert a profile while this one waits on the survivor lookup; replaying + // the whole snapshot would silently drop that newer entry while its settings stayed + // saved. Put the deleted profile back where it was and keep everything that arrived + // since. + const currentEntries = this.getProviderProfileEntries() + if (!currentEntries.some(({ id }) => id === profileToDelete.id)) { + const restoreAt = previousEntries.findIndex(({ id }) => id === profileToDelete.id) + const restored = [...currentEntries] + restored.splice(Math.max(0, Math.min(restoreAt, restored.length)), 0, profileToDelete) + await this.contextProxy.setValue("listApiConfigMeta", restored) + } + } catch (compensationError: unknown) { + compensationFailures.push(`profile list: ${describeFailure(compensationError)}`) + } + } + + if (providerSettingsWriteLanded) { + try { + await this.contextProxy.setProviderSettings(previousSharedProviderSettings) + } catch (compensationError: unknown) { + compensationFailures.push(`shared provider settings: ${describeFailure(compensationError)}`) + } + } + + if (selectionWriteLanded) { + try { + await this.contextProxy.setValue("currentApiConfigName", previousGlobalSelection) + } catch (compensationError: unknown) { + compensationFailures.push(`shared selection: ${describeFailure(compensationError)}`) + } + } + + if (viewPinWriteLanded) { + try { + // Restoring the pin and its nested overlay together returns this view to its + // exact pre-deletion state; a view with no pre-deletion pin gets the pin the + // deletion created cleared again instead of left holding the survivor. + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: previousViewPin, + apiConfiguration: previousViewOverlay, + }) + } catch (compensationError: unknown) { + compensationFailures.push(`view pin: ${describeFailure(compensationError)}`) + } + } + + if (compensationFailures.length > 0) { + // A partially repaired deletion is worse than a reported one: surface it as its + // own inconsistent-state error so the caller cannot read it as a clean rollback. + this.log( + `deleteProviderProfile: the deletion failed AND the rollback was incomplete (${compensationFailures.join("; ")}); the persisted profile state may be inconsistent.`, + ) + throw new Error( + `Profile deletion left persisted state inconsistent: ${compensationFailures.join("; ")}. Original failure: ${describeFailure(error)}`, + ) + } + + this.log( + `deleteProviderProfile: the deletion failed after durable writes landed; the settings, profile list, shared selection, and view pin were all restored, so the profile was not deleted.`, + ) + throw error + } await this.postStateToWebview() } @@ -2014,19 +3086,63 @@ export class ClineProvider const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false - 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), - ]) + // The mode is read before the first write because the per-mode mapping is one of the + // durable stores the snapshot below captures. + const { mode } = await this.getState() + const snapshot = await this.snapshotProfileActivationStores(name, mode) + const landed: ProfileActivationLanded = { + profileRecord: false, + profileList: false, + modeMapping: false, + selection: false, + sharedProviderSettings: false, + viewOverlay: false, } - const { mode } = await this.getState() + try { + if (!skipCurrentTaskRebuild) { + // See `upsertProviderProfile` for a description of what this is doing. The writes + // are serialized so each step can record that it landed: a rejection later in the + // activation then rolls back the profile list, the shared name, the shared settings, + // and this view's buffer instead of leaving them committed under the name the + // previous profile still carries. + await this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + landed.profileList = true + + // 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(). + await this.setValue("currentApiConfigName", name) + landed.selection = true + + // Marked before the await: setProviderSettings fans out to several globalState keys, + // so a rejection partway through the fan-out still has to be rolled back. + landed.sharedProviderSettings = true + await 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(). + await this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }) + landed.viewOverlay = true + + // 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. Sibling buffers this call already rewrote are + // restored by the refresh itself before it rejects. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + } - if (id && persistModeConfig) { - await this.providerSettingsManager.setModeConfig(mode, id) + if (id && persistModeConfig) { + await this.providerSettingsManager.setModeConfig(mode, id) + landed.modeMapping = true + } + } catch (error: unknown) { + // `activateProfile` above already rewrote the profile manager's own current-profile + // record, so the compensation has to put the previous name back there too. + await this.compensateProfileActivation(snapshot, landed, "activateProviderProfile", error, true) + throw error } // Change the provider for the current task. @@ -2047,6 +3163,186 @@ export class ClineProvider } } + /** + * Refresh the view-local apiConfiguration buffer of the other live views + * pinned to the given profile. An upsert/activation rewrites the profile's + * settings in the shared store and the store-backed manager, but a view + * whose buffer loaded the profile earlier keeps shadowing the stale + * settings in its getState() until its own next mutation. The originating + * view refreshes its buffer at the mutation site itself. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + // Direct private access: compile-time safe across sibling instances. A sibling that has + // begun disposal must not be written to - see the note in the loop below. + (instance) => instance !== this && !instance._disposed && instance.pinnedProfileName === name, + ) + + if (affected.length === 0) { + return + } + + // Snapshot every affected view BEFORE any write starts: a failure in one sibling has to + // roll back the siblings that already landed too. Without this, one sibling's rejected + // refresh leaves the other views holding the new profile's settings while the caller's + // compensation rolls the shared stores back - a split durable state across views. + const snapshots = affected.map((instance) => ({ + instance, + previousOverlay: instance.viewLocalState.apiConfiguration, + })) + + const results = await Promise.allSettled( + snapshots.map(async (snapshot) => { + // A provider that has begun disposal owns an orphaned viewStates key: viewId comes from + // the monotonic nextViewId counter, so no future view ever reads that entry again and + // prunePersistedViewStates() bounds it. Skipping work on such an instance is therefore + // safe; performing it is the defect - writing durable state and posting to a webview that + // is already being torn down. + // Re-checked before every operation, not just at enumeration: this sibling can start + // disposing after the filter above ran. + if (snapshot.instance._disposed) { + return + } + // Direct private access: compile-time safe across sibling instances. + await snapshot.instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + if (snapshot.instance._disposed) { + return + } + await snapshot.instance.postStateToWebview() + }), + ) + + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (rejected.length > 0) { + const describeFailure = (e: unknown) => (e instanceof Error ? e.message : String(e)) + const restoreFailures: string[] = [] + // Undo every affected view, not just the failing one: the buffer is filled before the + // durable write settles, so even a rejected sibling keeps the new overlay. Each restore + // is awaited on its own so one failing restore cannot skip the next one. + for (const snapshot of snapshots) { + // Compensating through a provider that has begun disposal is the orphaned-key defect + // this guard exists to avoid; its entry is never read again, so leaving it is safe. + if (snapshot.instance._disposed) { + continue + } + try { + await snapshot.instance._saveViewLocalStateFromMutation({ + apiConfiguration: snapshot.previousOverlay, + }) + } catch (rollbackError: unknown) { + snapshot.instance.log( + `[refreshViewLocalStateForUpdatedProfile] Could not restore the buffer for view ${snapshot.instance.viewId} after a failed refresh: ${describeFailure(rollbackError)}`, + ) + restoreFailures.push(`view ${snapshot.instance.viewId} buffer: ${describeFailure(rollbackError)}`) + } + } + if (restoreFailures.length > 0) { + // A sibling buffer that stayed on the new settings while the mutation is rolled back + // is a split durable state, not an ordinary failure: surface the distinct + // inconsistent-state error instead of the plain rejection. + throw new ProfileActivationInconsistentError( + `refreshViewLocalStateForUpdatedProfile: the profile change failed after sibling view buffers were rewritten and the rollback was incomplete (${restoreFailures.join("; ")}); the per-view buffers may now disagree with the shared stores. Original failure: ${describeFailure(rejected[0].reason)}`, + ) + } + throw rejected[0].reason + } + } + + /** + * Re-pin the other live views whose buffer still names a deleted profile: + * without this their in-memory buffer and durable viewStates entry keep + * serving the deleted profile's name and configuration on top of the + * surviving shared state. Each affected view's durable entry is re-pinned + * through the serialized write queue, so the rename survives reloads. + */ + private async rePinViewLocalStateForDeletedProfile( + deletedProfileName: string, + replacementName: string, + replacementSettings: ProviderSettings | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + // Direct private access: compile-time safe across sibling instances. A sibling that has + // begun disposal is excluded from the deletion's re-pin set entirely. + (instance) => instance !== this && !instance._disposed && instance.pinnedProfileName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + // Snapshot every affected view BEFORE any write starts: a failure in one sibling + // has to roll back the siblings that already landed too, otherwise the deletion + // leaves some views pinned to a profile that no longer exists. + const snapshots = affected.map((instance) => ({ + instance, + previousPin: instance.viewLocalState.currentApiConfigName, + previousOverlay: instance.viewLocalState.apiConfiguration, + })) + + const results = await Promise.allSettled( + snapshots.map(async (snapshot) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + if (replacementSettings) { + values.apiConfiguration = replacementSettings + } + + // A provider that has begun disposal owns an orphaned viewStates key: viewId comes from + // the monotonic nextViewId counter, so no future view ever reads that entry again and + // prunePersistedViewStates() bounds it. Skipping work on such an instance is therefore + // safe; performing it is the defect - writing durable state and posting to a webview that + // is already being torn down. + // Re-checked before every operation, not just at enumeration: the sibling can start + // disposing while earlier siblings are already being written. + if (snapshot.instance._disposed) { + return + } + // Direct private access: compile-time safe across sibling instances. + await snapshot.instance._saveViewLocalStateFromMutation(values) + if (snapshot.instance._disposed) { + return + } + await snapshot.instance.postStateToWebview() + }), + ) + + const rejected = results.filter((result): result is PromiseRejectedResult => result.status === "rejected") + if (rejected.length > 0) { + // Undo every affected view, not just the failing one: _saveViewLocalStateFromMutation + // fills the in-memory buffer before the durable write settles, so even a rejected write + // leaves the view re-pointed. Each restore is awaited on its own so one failing restore + // cannot skip the next one. + for (const snapshot of snapshots) { + // A provider that has begun disposal owns an orphaned viewStates key: viewId comes from + // the monotonic nextViewId counter, so no future view ever reads that entry again and + // prunePersistedViewStates() bounds it. Re-checked here too: the sibling can start + // disposal while the re-pin is already in flight, and compensating through a provider + // that is tearing down is the defect this guard exists to avoid. + if (snapshot.instance._disposed) { + continue + } + try { + await snapshot.instance._saveViewLocalStateFromMutation({ + currentApiConfigName: snapshot.previousPin, + apiConfiguration: snapshot.previousOverlay, + }) + } catch (rollbackError: unknown) { + snapshot.instance.log( + `[rePinViewLocalStateForDeletedProfile] Could not restore the pin for view ${snapshot.instance.viewId} after a failed re-pin: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + throw rejected[0].reason + } + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) @@ -2924,12 +4220,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. @@ -2991,121 +4293,124 @@ export class ClineProvider // Return the same structure as before. 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: { + ...providerSettings, + ...mergedStateValues.apiConfiguration, + }, + 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, } } @@ -3169,7 +4474,17 @@ export class ClineProvider } public async setValue(key: K, value: RooCodeSettings[K]) { + // Snapshot first: the durable per-view pin and the in-memory buffer are written + // AFTER the shared store, so a failed view-local write must be able to undo the + // shared one (see restoreSharedValuesAfterViewLocalFailure). + const previousValues = { [key]: this.contextProxy.getValue(key) } as RooCodeSettings await this.contextProxy.setValue(key, value) + try { + await this._saveViewLocalStateFromMutation({ [key]: value }) + } catch (error: unknown) { + await this.restoreSharedValuesAfterViewLocalFailure(previousValues, error) + throw error + } } public getValue(key: K) { @@ -3177,11 +4492,144 @@ 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 previousValues = 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: unknown) { + await this.restoreSharedValuesAfterViewLocalFailure(previousValues, error) + throw error + } + } + + /** + * Undo the shared half of a setValue/setValues write whose view-local half failed. + * Without this the shared store holds the new value while getValues() keeps merging the + * stale view-local buffer over it, so the caller's write is durable but invisible - + * and the next read, task, or profile load disagrees with the store. A failed restore is + * surfaced as its own inconsistent-state log instead of hiding behind the original error. + */ + private async restoreSharedValuesAfterViewLocalFailure( + previousValues: RooCodeSettings, + cause: unknown, + ): Promise { + const describe = (error: unknown) => (error instanceof Error ? error.message : String(error)) + const restoreFailures: string[] = [] + for (const [key, previous] of Object.entries(previousValues)) { + try { + await this.contextProxy.setValue(key as keyof RooCodeSettings, previous) + } catch (error: unknown) { + restoreFailures.push(`${key}: ${describe(error)}`) + } + } + + if (restoreFailures.length > 0) { + this.log( + `The view-local write failed (${describe(cause)}) and the shared value(s) could not be restored (${restoreFailures.join("; ")}); the shared store and this view's buffer may disagree.`, + ) + } else { + this.log(`The view-local write failed (${describe(cause)}); the shared value(s) were restored.`) + } + } + + /** + * 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 @@ -3210,6 +4658,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.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7c85a6b372..1517b9694e 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) { @@ -593,6 +637,92 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) + }) + + it("returns undefined when no live instance owns the view", () => { + expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined() + }) + + it("unregisters a provider whose teardown step rejects", async () => { + const failingProvider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Own view object, so the lookup below cannot resolve to the shared test provider. + failingProvider["view"] = { dispose: vi.fn(), visible: false } as never + + // Two teardown failures with different shapes: a rejected await and a synchronous throw. + failingProvider["mcpHub"] = { + unregisterClient: vi.fn().mockRejectedValue(new Error("unregister hung")), + } as never + failingProvider["marketplaceManager"] = { + cleanup: vi.fn(() => { + throw new Error("cleanup threw") + }), + } as never + + // Disposal must not surface the teardown failure as a crash - callers dispose providers + // during activation and tab rollback, where a throw would strand the rest of the cleanup. + await failingProvider.dispose() + + // _disposed is already true, so nothing here runs again: a provider left in activeInstances + // would stay counted by getVisibleInstance/getAllInstances and resolvable by + // getInstanceForView forever. + expect(ClineProvider.getAllInstances()).not.toContain(failingProvider) + expect(ClineProvider.getInstanceForView(failingProvider["view"] as vscode.WebviewView)).toBeUndefined() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining( + "Disposal was incomplete (mcpHub: unregister hung; marketplace manager: cleanup threw)", + ), + ) + }) + + it("unregisters the provider before its awaited cleanup finishes", async () => { + // The unregistration used to sit after every awaited teardown step, so a provider that was + // already _disposed stayed enumerable for the whole cleanup window - long enough for a + // sibling profile mutation to persist durable view state and post to a view that is gone. + const disposing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Own view object, so the lookup below cannot resolve to the shared test provider. + disposing["view"] = { dispose: vi.fn(), visible: false } as never + // Hold the last teardown step open so the assertions run while dispose() is still awaiting. + let releaseCleanup: () => void = () => {} + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve + }) + // taskHistoryStore is readonly, so the double assertion is the narrowest way to swap in a + // gate; bracket access alone cannot reassign a readonly field. + const store = disposing as unknown as { taskHistoryStore: { dispose: () => Promise } } + store.taskHistoryStore = { dispose: () => cleanupGate } + + const disposal = disposing.dispose() + // Drain the microtask chain that carries dispose() to its pending await. + for (let round = 0; round < 3; round++) { + await new Promise((resolve) => setImmediate(resolve)) + } + + expect(ClineProvider.getAllInstances()).not.toContain(disposing) + expect(ClineProvider.getInstanceForView(disposing["view"] as vscode.WebviewView)).toBeUndefined() + + releaseCleanup() + await disposal + expect(ClineProvider.getAllInstances()).not.toContain(disposing) + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -964,7 +1094,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: { @@ -1211,6 +1341,47 @@ 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("does not reject or leak an unhandled rejection when the webview post rejects asynchronously", async () => { + await provider.resolveWebviewView(mockWebviewView) + // The non-await dispatch must still consume the rejection: postMessage resolves only when + // the renderer acks, and a disposed page rejects it. Dropping the .catch would leave this + // as an unhandled rejection instead of a logged drop. + mockPostMessage.mockReturnValue(Promise.reject(new Error("no webview"))) + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => unhandled.push(reason) + process.on("unhandledRejection", onUnhandled) + + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" } as never) + await Promise.resolve() + await Promise.resolve() + process.off("unhandledRejection", onUnhandled) + + expect(mockPostMessage).toHaveBeenCalled() + expect(unhandled).toEqual([]) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("dropped message type=action")) + }) + test("postMessageToWebview leaves originalContent of file-edit tool messages out of the state it posts", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1273,200 +1444,4394 @@ describe("ClineProvider", () => { } const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE - beforeEach(() => { - process.env.ROO_CODE_THEME_FIXTURE_PROBE = "1" + beforeEach(() => { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = "1" + }) + + afterEach(() => { + if (originalProbeSetting === undefined) { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + } else { + process.env.ROO_CODE_THEME_FIXTURE_PROBE = originalProbeSetting + } + vi.useRealTimers() + }) + + test("rejects requests when probing is disabled", async () => { + delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + + await expect(provider.requestWebviewThemeFixture()).rejects.toThrow("Theme fixture probing is disabled") + }) + + test("posts a request and resolves the matching response", async () => { + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const request = provider.requestWebviewThemeFixture() + await Promise.resolve() + const requestId = postMessageSpy.mock.calls[0]?.[0].requestId + const unknownFixture = { ...fixture, themeId: "Unexpected Theme" } + + expect(requestId).toBeTruthy() + expect(postMessageSpy).toHaveBeenCalledWith({ type: "themeFixtureProbeRequest", requestId }) + provider.resolveWebviewThemeFixtureProbe("unknown-request", unknownFixture) + provider.resolveWebviewThemeFixtureProbe(requestId!, fixture) + + await expect(request).resolves.toEqual(fixture) + }) + + test("rejects a request after its timeout", async () => { + vi.useFakeTimers() + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const request = provider.requestWebviewThemeFixture(100) + const rejection = expect(request).rejects.toThrow("Theme fixture probe timed out after 100ms") + + await vi.advanceTimersByTimeAsync(100) + await rejection + }) + + test("rejects pending requests when webview resources are cleared", async () => { + vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const request = provider.requestWebviewThemeFixture() + const rejection = expect(request).rejects.toThrow( + "Webview was disposed before the theme fixture probe completed", + ) + + provider["clearWebviewResources"]() + await rejection + }) + }) + + test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { + // 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" }), + }) + + provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + mdmService, + ) + + 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(), + ) + // Conditioned on the payload, never on call order: this flow writes the viewStates + // map more than once (the registration re-key, then the mode write), so a + // mockRejectedValueOnce would pin whichever call happened to come first - and if it + // pinned nothing at all the test would still be green. rejectedKeys makes the + // injection observable: if the write below is not the one that fails, the assertion + // at the end says so instead of the test silently passing. + const rejectedKeys: string[] = [] + const setValueOriginal = provider.contextProxy.setValue.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + const states = value as Record | undefined + if (String(key) === "viewStates" && states?.["stable-sidebar-view"]?.mode === "architect") { + rejectedKeys.push(key) + throw new Error("persist failed") + } + return setValueOriginal(key, value) + }) + + await provider["setViewStateId"]("stable-sidebar-view") + + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + expect(rejectedKeys).toContain("viewStates") + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("loadViewState", () => { + it("should keep viewLocalState empty when no stable per-view values exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await vi.waitFor(() => { + expect(provider["viewLocalState"]).toEqual({}) + }) + + const state = await provider.getState() + // No per-view entry exists and the proxy's global-state cache is empty + // (initialize() is never called in this fixture; only "taskHistory" passes + // through to the context store), so getState() falls back to the shared + // defaults: mode "code" (defaultModeSlug) and currentApiConfigName "default". + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + + 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"]).toBeDefined() + expect(pruned["view-5"]).toBeDefined() + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("__proto__") + + // "__proto__" is rejected before assignment so a per-view entry can never be + // keyed through the Object.prototype setter: the temporary id stays active and + // nothing is persisted under the reserved name. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("restores the previous view id when the registration write fails so a later launch retries", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id so the re-key has real work to do. + mockContext.globalState.update("viewStates", { [provider.viewId]: { 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. + mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + }) + await save("mode", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("mode") + await provider.saveViewState("mode", "architect") + await save("currentApiConfigName", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + mode: "architect", + updatedAt: expect.any(Number), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("restores the shared value when the view-local durable write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("atomic-write-view") + await provider.setValue("currentApiConfigName", "first-profile") + await provider.setValues({ mode: "architect" }) + + // Only the durable viewStates write fails, so the failure lands exactly between the + // shared write and the view-local write of the same call. + // getMockImplementation, not bind: spyOn reuses the same mock object, so a bound copy + // would call the spy's own implementation and recurse. + const originalUpdate = vi.mocked(mockContext.globalState.update).getMockImplementation() + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementation(async (key, value) => { + if (key === "viewStates") { + throw new Error("view states write failed") + } + return originalUpdate?.(key, value) + }) + const logSpy = vi.spyOn(provider, "log") + + await expect(provider.setValue("currentApiConfigName", "second-profile")).rejects.toThrow( + "view states write failed", + ) + await expect(provider.setValues({ mode: "code" })).rejects.toThrow("view states write failed") + + // The shared store must not keep a value that getValues() cannot report: the + // view-local buffer is only updated once the durable per-view write succeeds, so an + // un-rolled shared write would leave storage on the new profile while every reader + // that merges viewLocalState over it keeps serving the old one. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("first-profile") + expect(provider.getValues().currentApiConfigName).toBe("first-profile") + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("the shared value(s) were restored")) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("should rekey a pre-launch entry under the temporary id to the registered stable id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed storage directly (bypassing the ContextProxy cache) so only the fresh read sees it. + mockContext.globalState.update("viewStates", { [provider.viewId]: { 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(), + ) + mockContext.globalState.update("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" } }) + 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"]).toBeDefined() + expect(pruned["view-54"]).toBeDefined() + 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 } + 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 writer.dispose() + 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" } + 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 writer.dispose() + 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() + }) + }) + + 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, + }), + // The activation snapshot also reads the previous per-mode mapping. + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + 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 writer.dispose() + await provider.dispose() + }) + + it("keeps the mutation queue chained to the running mutation after the caller-facing timeout", async () => { + // The timeout exists to release the caller, not to release the queue. A mutation that is + // still writing durable state must keep later mutations out: the abort signal is advisory, + // so a fn that ignores it would otherwise interleave its writes with its successor's. + vi.useFakeTimers() + // PENDING_OPERATION_TIMEOUT_MS is a public static readonly, so TS forbids assigning it; + // the double assertion is the only way to shorten it for this test without widening the + // production API or making the constant mutable in production. + const originalTimeout = ClineProvider.PENDING_OPERATION_TIMEOUT_MS + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = 50 + const started: string[] = [] + let releaseFirst!: () => void + try { + const first = provider["enqueueProviderProfileMutation"](async () => { + started.push("first") + await new Promise((resolve) => { + releaseFirst = resolve + }) + }) + // Attach the rejection handler before the timer fires: the caller-facing timeout rejects + // inside the fake-timer tick, and a handler attached only afterwards is reported by + // Vitest as an unhandled rejection. + const firstRejected = expect(first).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(0) + + // The caller is released at the timeout while the mutation itself is still running. + await vi.advanceTimersByTimeAsync(60) + await firstRejected + + const second = provider["enqueueProviderProfileMutation"](async () => { + started.push("second") + }) + await vi.advanceTimersByTimeAsync(0) + expect(started).toEqual(["first"]) + + // Only once the running mutation settles does the queue hand over. + releaseFirst() + await expect(second).resolves.toBeUndefined() + expect(started).toEqual(["first", "second"]) + } finally { + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = + originalTimeout + vi.useRealTimers() + } + }) + + it("should report the activated profile's settings over a stale view-local buffer in getState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const freshSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ name: "new-profile", id: "new-id", ...freshSettings }), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue({ name: "new-profile", id: "new-id", ...freshSettings }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // A stale view-local apiConfiguration (as loaded from a view state) that keeps + // shadowing the activated profile's settings in getState() unless the mutation + // path clears this view's buffer overlay. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-key", + }) + expect(provider.getValues().apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-key", + }) + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The constructed state must serve the activated profile's fresh shared + // settings, not the stale view-local overlay. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.apiConfiguration.apiKey).toBe("fresh-key") + + // A later shared settings edit must not be masked by a buffer copy taken at + // mutation time: the e2e flow edits settings through the shared path after + // configuring the profile, and a wrapped buffer snapshot would keep serving + // the pre-edit values in getState(). + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "edited-key", + }) + const stateAfterEdit = await provider.getState({ includeTaskHistory: false }) + expect(stateAfterEdit.apiConfiguration.apiKey).toBe("edited-key") + await provider.dispose() + }) + + it("should sync the view-local buffer when creating and activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profile: ProviderSettingsEntry = { + name: "fresh-profile", + id: "fresh-id", + apiProvider: providerIdentifiers.openrouter, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("fresh-id"), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record (none - this call creates the + // profile) and the previous per-mode mapping. + getProfile: vi.fn().mockRejectedValue(new ProviderSettingsNotFoundError("fresh-profile")), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.setValue("currentApiConfigName", "stale-profile") + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + expect(provider.getValues().currentApiConfigName).toBe("fresh-profile") + await provider.dispose() + }) + + it("should report the fresh profile's settings over a stale view-local buffer in getState after upserting and activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const profile: ProviderSettingsEntry = { + name: "fresh-profile", + id: "fresh-id", + apiProvider: providerIdentifiers.openrouter, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("fresh-id"), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record (none - this call creates the + // profile) and the previous per-mode mapping. + getProfile: vi.fn().mockRejectedValue(new ProviderSettingsNotFoundError("fresh-profile")), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // A stale view-local apiConfiguration (as loaded from a view state) that keeps + // shadowing the fresh profile's settings in getState() unless the mutation + // path clears this view's buffer overlay. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + expect(provider.getValues().apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + // The constructed state must serve the fresh profile's shared settings, not + // the stale view-local overlay. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + // Same masking guard as the activation case: later shared edits stay visible. + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "edited-key", + }) + const stateAfterEdit = await provider.getState({ includeTaskHistory: false }) + expect(stateAfterEdit.apiConfiguration.apiKey).toBe("edited-key") + await provider.dispose() + }) + + it("should sync the view-local buffer when deleting the current profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + 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("should refresh the view-local apiConfiguration when deleting the active profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const keeperSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "keeper-key" } + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ name: "keeper-profile", ...keeperSettings }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + // A stale view-local apiConfiguration (the deleted profile's settings) that would + // keep shadowing the surviving profile's settings in getState() if the deletion + // path passed a flat ProviderSettings object (a no-op for the buffer updater). + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-key", + }) + expect(provider.getValues().apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-key", + }) + + await provider.deleteProviderProfile(oldProfile) + + // The surviving profile's settings must replace the deleted profile's stale buffer. + expect(provider.getValues().apiConfiguration).toEqual(keeperSettings) + await provider.dispose() + }) + + it("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("refreshes another live view's buffered settings when the profile it pins is reactivated", async () => { + // The affected view loads the profile first, so its nested overlay carries that + // profile's settings; the mutating view is a different ClineProvider instance. + const viewer = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await viewer["setViewStateId"]("shared-view") + await viewer.saveViewState("currentApiConfigName", "shared-profile") + await viewer.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "stale-viewer-key", + }) + const viewerPostSpy = vi.spyOn(viewer, "postStateToWebview").mockResolvedValue(undefined) + + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const reactivated = { + name: "shared-profile", + id: "shared-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + } + // @ts-ignore - Replace providerSettingsManager with a test double. + writer.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(reactivated), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(reactivated), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(writer, "postStateToWebview").mockResolvedValue(undefined) + + await writer.activateProviderProfile({ name: "shared-profile" }) + + // The other view's buffer must serve the reactivated profile's settings, and its + // webview must be re-posted; otherwise that view keeps showing (and running on) + // the key it buffered before the upsert. + expect(viewer["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + const viewerState = await viewer.getState({ includeTaskHistory: false }) + expect(viewerState.apiConfiguration.apiKey).toBe("fresh-key") + expect(viewerPostSpy).toHaveBeenCalled() + await viewer.dispose() + await writer.dispose() + }) + + it("re-pins another live view and persists the replacement in its durable view state when its profile is deleted", async () => { + const viewer = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await viewer["setViewStateId"]("shared-view") + await viewer.saveViewState("currentApiConfigName", "old-profile") + await viewer.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + apiKey: "deleted-profile-key", + }) + const viewerPostSpy = vi.spyOn(viewer, "postStateToWebview").mockResolvedValue(undefined) + + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await writer.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await writer.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(writer, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + writer.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }), + } + + await writer.deleteProviderProfile(oldProfile) + + // The other view's in-memory buffer must be re-pinned with a matching + // configuration instead of the deleted profile's name and secret. + expect(viewer["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(viewer["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }) + // The durable entry must carry the replacement too, or a reload restores the + // deleted profile's name on top of the surviving shared state. + const durableViewStates = mockContext.globalState.get("viewStates") + expect(durableViewStates?.["shared-view"]?.currentApiConfigName).toBe("keeper-profile") + expect(viewerPostSpy).toHaveBeenCalled() + await viewer.dispose() + await writer.dispose() + }) + + it("restores the deleted profile's settings when the profile-list write fails after the store commit", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const doomedSettings = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "doomed-secret", + } + const saveConfigSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue(doomedSettings), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: saveConfigSpy, + } + // The settings store commit succeeds; the profile-list write then fails. + const setValueSpy = vi + .spyOn(provider.contextProxy, "setValue") + .mockRejectedValue(new Error("storage write failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("storage write failed") + setValueSpy.mockRestore() + + // The settings were already removed, so the compensation must put them back with + // their original id: the durable list still names this profile, and without the + // settings a later selection or load could not recover them. + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", apiKey: "doomed-secret" }), + ) + await provider.dispose() + }) + + it("aborts the deletion before the settings store is touched when the pre-delete read fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const deleteConfigSpy = vi.fn().mockResolvedValue(undefined) + const saveConfigSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + // A transient read failure, not the typed not-found: the settings may still exist, + // so a later failure could not be compensated and the deletion must not start. + getProfile: vi.fn().mockRejectedValue(new Error("secret storage unavailable")), + deleteConfig: deleteConfigSpy, + saveConfig: saveConfigSpy, + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("secret storage unavailable") + + // Nothing destructive happened, so the same call can succeed once storage recovers. + // Continuing here would have removed the settings with no snapshot to restore, while + // the durable profile list still named the profile. + expect(deleteConfigSpy).not.toHaveBeenCalled() + expect(saveConfigSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + await provider.dispose() + }) + + it("restores the profile list, shared selection, and view pin when a later write fails after the list write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + // This view is pinned to the doomed profile and has its settings loaded, so the + // deletion also rewrites this view's pin and nested overlay. + provider["viewLocalState"].currentApiConfigName = "doomed-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const saveConfigSpy = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(({ name }: { name: string }) => + name === "doomed-profile" + ? Promise.resolve({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + : Promise.resolve({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + }), + ), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: saveConfigSpy, + } + + // The settings commit and the profile-list write both landed; this later shared write + // is what fails, so every store already changed has to be put back. + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockRejectedValue(new Error("provider settings write failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow( + "provider settings write failed", + ) + setProviderSettingsSpy.mockRestore() + + // The settings are back under their original id... + expect(saveConfigSpy).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ id: "doomed-id", openRouterApiKey: "doomed-secret" }), + ) + // ...and so are the profile list, the shared selection, and this view's own pin: + // restoring only the settings would leave the persisted metadata pointing at the + // surviving profile while the deleted profile's settings exist again. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("doomed-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + // The durable per-view entry must agree with the restored buffer, or a reload would + // re-pin this view to the profile that was never deleted. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + expect(persisted.currentApiConfigName).toBe("doomed-profile") + await provider.dispose() + }) + + it("surfaces an inconsistent-state error when the deletion rollback itself fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "doomed-secret", + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + // The settings are already gone and cannot be put back: the caller must not be able + // to read this as a clean rollback. + saveConfig: vi.fn().mockRejectedValue(new Error("settings restore failed")), + } + const setValueSpy = vi + .spyOn(provider.contextProxy, "setValue") + .mockRejectedValue(new Error("storage write failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow( + "Profile deletion left persisted state inconsistent", + ) + setValueSpy.mockRestore() + await provider.dispose() + }) + + it("serializes a deletion with a concurrent upsert so the rollback cannot restore a stale profile list", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + listConfig: vi.fn().mockResolvedValue(["doomed-profile", "keeper-profile", "new-profile"]), + } + + // The deletion parks in this write, so the concurrent upsert gets its chance + // while the deletion holds the profile stores half-updated. + let parked = false + let releaseFailingWrite!: () => void + const failingWrite = new Promise((resolve) => { + releaseFailingWrite = resolve + }) + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockImplementation(async () => { + parked = true + await failingWrite + throw new Error("provider settings write failed") + }) + + const deletion = provider.deleteProviderProfile(doomedProfile) + while (!parked) { + await new Promise((resolve) => setImmediate(resolve)) + } + // The upsert starts while the deletion is mid-flight: if the two are not + // serialized, its list write lands inside the deletion's snapshot/rollback window. + const upsert = provider.upsertProviderProfile( + "new-profile", + { apiProvider: providerIdentifiers.anthropic }, + false, + ) + releaseFailingWrite() + + await expect(deletion).rejects.toThrow("provider settings write failed") + await upsert + setProviderSettingsSpy.mockRestore() + + // The upsert's newer list write must survive. An unserialized deletion rolls back + // to the snapshot it took before the upsert ran and silently drops the new profile. + expect(JSON.stringify(provider.contextProxy.getValue("listApiConfigMeta"))).toContain("new-profile") + await provider.dispose() + }) + + it("refreshes another live view pinned to a profile that was just upserted", async () => { + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("sibling-refresh-view") + // The sibling is pinned to the profile with its settings loaded into the buffer. + await sibling.saveViewState("currentApiConfigName", "shared-profile") + await sibling.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + const siblingPostSpy = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const sharedProfile: ProviderSettingsEntry = { + name: "shared-profile", + id: "shared-id", + apiProvider: providerIdentifiers.anthropic, + } + // @ts-ignore - Replace providerSettingsManager with a test double. + updater.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("shared-id"), + listConfig: vi.fn().mockResolvedValue([sharedProfile]), + setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(sharedProfile), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(updater, "postStateToWebview").mockResolvedValue(undefined) + await updater.contextProxy.setValue("listApiConfigMeta", [sharedProfile]) + + await updater.upsertProviderProfile("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + + // The other live view must not keep serving the pre-update settings: its buffer + // overlay is what getState() merges over the shared store, so a stale overlay here + // means the sibling keeps sending the old key after the profile was edited. + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + expect(siblingPostSpy).toHaveBeenCalled() + await updater.dispose() + await sibling.dispose() + }) + + it("restores an affected sibling view's own pin when the deletion re-pin write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A context of its own for the sibling: same backing store, but this view's durable + // viewStates write is the one that fails. Failing ContextProxy#setValue instead would + // skip updateGlobalState entirely, so the phantom cache entry the rollback repairs + // would never exist and the test would prove nothing. + const siblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + // Reject exactly the re-pin write - the map that would name the survivor for this + // view - so the setup writes and the rollback write still land. + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["sibling-pin-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const sibling = new ClineProvider( + siblingContext, + mockOutputChannel, + "editor", + new ContextProxy(siblingContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("sibling-pin-view") + await sibling.saveViewState("currentApiConfigName", "doomed-profile") + await sibling.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling pin write failed") + + // The sibling keeps its own pin and overlay: a rolled-back deletion must not leave it + // pinned to the surviving profile, or a reload would re-pin that view to a profile the + // user never chose for it. + expect(sibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + }) + // The ContextProxy cache is where the phantom lives: updateGlobalState fills it before + // awaiting the durable write, so a failed re-pin leaves the cache naming the survivor + // unless the rollback writes the previous pin back. + const cached = sibling["getPersistedViewStates"]()["sibling-pin-view"] ?? {} + expect(cached.currentApiConfigName).toBe("doomed-profile") + const persisted = sibling["getPersistedViewStates"]({ fresh: true })["sibling-pin-view"] ?? {} + expect(persisted.currentApiConfigName).toBe("doomed-profile") + // The deleting view rolled its own stores back too. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + await provider.dispose() + await sibling.dispose() + }) + + it("rolls back the sibling views whose re-pin write succeeded when another sibling fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // healthySibling + // This sibling's writes all land: it is the one that has to be undone when the OTHER + // sibling's re-pin fails. + const healthySibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await healthySibling["setViewStateId"]("healthy-sibling-view") + await healthySibling.saveViewState("currentApiConfigName", "doomed-profile") + // failingSibling + const failingSiblingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["failing-sibling-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const failingSibling = new ClineProvider( + failingSiblingContext, + mockOutputChannel, + "editor", + new ContextProxy(failingSiblingContext), + new WebviewFocusTracker(), + ) + await failingSibling["setViewStateId"]("failing-sibling-view") + await failingSibling.saveViewState("currentApiConfigName", "doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling pin write failed") + + // The sibling whose write LANDED must be put back too: the deletion as a whole did not + // happen, so leaving this view pinned to the survivor would name a profile that is + // still in the list but was never chosen for this view. + expect(healthySibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + const cachedHealthy = healthySibling["getPersistedViewStates"]()["healthy-sibling-view"] ?? {} + expect(cachedHealthy.currentApiConfigName).toBe("doomed-profile") + expect(failingSibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + await provider.dispose() + await healthySibling.dispose() + await failingSibling.dispose() + }) + + it("refreshes a live sibling but leaves one that has started disposing", async () => { + // dispose() unregisters at its start, so a disposed sibling is normally not enumerated at all; + // this pins the second line of defence the row asks for. The live sibling is the positive + // control: it proves the refresh reaches siblings at all, so the disposing assertion cannot + // pass by accident. + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const live = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const disposing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await live["setViewStateId"]("live-sibling-view") + await live.saveViewState("currentApiConfigName", "shared-profile") + await live.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + await disposing["setViewStateId"]("disposing-refresh-view") + await disposing.saveViewState("currentApiConfigName", "shared-profile") + await disposing.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + const livePost = vi.spyOn(live, "postStateToWebview").mockResolvedValue(undefined) + const disposingPost = vi.spyOn(disposing, "postStateToWebview").mockResolvedValue(undefined) + disposing["_disposed"] = true + expect(live["pinnedProfileName"]).toBe("shared-profile") + expect(disposing["pinnedProfileName"]).toBe("shared-profile") + + await updater["refreshViewLocalStateForUpdatedProfile"]("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + + // Positive control: the live sibling's overlay is replaced and it is posted to. + expect(live["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "fresh-key", + }) + expect(livePost).toHaveBeenCalledTimes(1) + // The disposing sibling keeps its own overlay and receives no post. + expect(disposing["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + expect(disposingPost).not.toHaveBeenCalled() + + // Reset the seeded flag so dispose() can unregister this instance again. + disposing["_disposed"] = false + await updater.dispose() + await live.dispose() + await disposing.dispose() + }) + + it("stops before posting to a sibling that starts disposing mid-refresh", async () => { + // Reachable window: the affected set is enumerated before any await, so a sibling can begin + // disposal after the filter ran and before its post. The re-check between the persistence and + // the post is what keeps the post off a webview that is already gone. + const updater = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("midflight-refresh-view") + await sibling.saveViewState("currentApiConfigName", "shared-profile") + const siblingPostSpy = vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + // Double assertion: the method is private and vi.spyOn needs a property holder; house idiom in + // this spec (see the teardown spies elsewhere in this file). + const teardown = sibling as unknown as { + _saveViewLocalStateFromMutation: (values: never) => Promise + } + const persist = vi.spyOn(teardown, "_saveViewLocalStateFromMutation").mockImplementation(async () => { + // Disposal starts while this sibling's own write is in flight. + sibling["_disposed"] = true + }) + + await updater["refreshViewLocalStateForUpdatedProfile"]("shared-profile", { + apiProvider: providerIdentifiers.anthropic, + }) + + expect(persist).toHaveBeenCalledTimes(1) + expect(siblingPostSpy).not.toHaveBeenCalled() + sibling["_disposed"] = false + await updater.dispose() + await sibling.dispose() + }) + + it("re-pins a live sibling but leaves one that has started disposing", async () => { + // Same second line of defence on the deletion path, with the live sibling as the positive + // control: it proves the deletion really reaches the sibling re-pin set, so the disposing + // assertion is a guard and not an accident of setup. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const live = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const disposing = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await live["setViewStateId"]("live-repin-view") + await live.saveViewState("currentApiConfigName", "doomed-profile") + await disposing["setViewStateId"]("disposing-repin-view") + await disposing.saveViewState("currentApiConfigName", "doomed-profile") + const livePost = vi.spyOn(live, "postStateToWebview").mockResolvedValue(undefined) + const disposingPost = vi.spyOn(disposing, "postStateToWebview").mockResolvedValue(undefined) + disposing["_disposed"] = true + expect(live["pinnedProfileName"]).toBe("doomed-profile") + expect(disposing["pinnedProfileName"]).toBe("doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await provider.deleteProviderProfile(doomedProfile) + + // Positive control: the live sibling is re-pinned onto the survivor and posted to. + expect(live["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(livePost).toHaveBeenCalled() + // The disposing sibling is left on its own pin with no post. + expect(disposing["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + expect(disposingPost).not.toHaveBeenCalled() + + disposing["_disposed"] = false + await provider.dispose() + await live.dispose() + await disposing.dispose() + }) + + it("skips both the post and the compensating restore for a sibling that starts disposing mid-re-pin", async () => { + // Two siblings: the first one's re-pin write lands and then it begins disposing; the second + // one's write fails, so the deletion rolls back. Both remaining guards apply to the first - the + // post guard and the compensating-restore guard. Its viewStates key is orphaned once it is + // disposed (viewId comes from the monotonic counter), so leaving it alone is the right call. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const disposingSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await disposingSibling["setViewStateId"]("disposing-sibling-view") + await disposingSibling.saveViewState("currentApiConfigName", "doomed-profile") + const disposingPostSpy = vi.spyOn(disposingSibling, "postStateToWebview").mockResolvedValue(undefined) + // Double assertion: private method, vi.spyOn needs a property holder (house idiom in this spec). + const teardown = disposingSibling as unknown as { + _saveViewLocalStateFromMutation: (values: never) => Promise + } + const realPersist = teardown._saveViewLocalStateFromMutation.bind(disposingSibling) + vi.spyOn(teardown, "_saveViewLocalStateFromMutation").mockImplementation(async (values: never) => { + await realPersist(values) + disposingSibling["_disposed"] = true + }) + const failingContext = { + ...mockContext, + globalState: { + ...mockContext.globalState, + update: (key: string, value: unknown) => { + const states = value as Record | undefined + if ( + key === "viewStates" && + states?.["failing-repin-view"]?.currentApiConfigName === "keeper-profile" + ) { + return Promise.reject(new Error("sibling pin write failed")) + } + return mockContext.globalState.update(key, value) + }, + }, + } + const failingSibling = new ClineProvider( + failingContext, + mockOutputChannel, + "editor", + new ContextProxy(failingContext), + new WebviewFocusTracker(), + ) + await failingSibling["setViewStateId"]("failing-repin-view") + await failingSibling.saveViewState("currentApiConfigName", "doomed-profile") + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + } + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling pin write failed") + + // The disposing sibling gets no post after its write and no compensating restore: it keeps the + // survivor pin its own write put in place. + expect(disposingPostSpy).not.toHaveBeenCalled() + expect(disposingSibling["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + // The failing sibling is still rolled back to its own pin. + expect(failingSibling["viewLocalState"].currentApiConfigName).toBe("doomed-profile") + disposingSibling["_disposed"] = false + await provider.dispose() + await disposingSibling.dispose() + await failingSibling.dispose() + }) + + it("rolls back a deletion that the abort signal cancels after the profile-list write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + const controller = new AbortController() + const saveConfig = vi.fn().mockResolvedValue("doomed-id") + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig, + getProfile: vi.fn().mockImplementation(async ({ name }) => { + // The cancellation lands on the survivor lookup, i.e. after the settings commit + // and the profile-list write. The queue is still held by this run, so the rollback + // below cannot race a later mutation. + if (name === "keeper-profile") { + controller.abort() + } + return name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + } + : { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + } + }), + } + + await expect(provider["deleteProviderProfileUnlocked"](doomedProfile, controller.signal)).rejects.toThrow( + "Profile deletion was cancelled before the selection and settings rewrite", + ) + + // The list write had landed before the cancellation point, and the compensation replays + // the snapshot over it: the deletion did not happen, so the list names both profiles again. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + // The forward rewrites still stop at the cancellation point: the shared selection and the + // shared provider settings were never touched, so there is nothing to undo for them, and + // no write is spent on a caller that has already been released. + // ...but the shared selection, the shared provider settings and any sibling view's pin + // must not be rewritten afterwards, and the rollback must not replay either: the next + // queued mutation owns those stores now. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("doomed-profile") + expect(provider.contextProxy.getValue("apiProvider")).not.toBe(providerIdentifiers.anthropic) + expect(saveConfig).toHaveBeenCalledWith( + "doomed-profile", + expect.objectContaining({ openRouterApiKey: "doomed-secret" }), + ) + await provider.dispose() + }) + + it("restores the deleted profile without dropping one another instance upserted meanwhile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const lateProfile: ProviderSettingsEntry = { + name: "late-profile", + id: "late-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "log").mockImplementation(() => {}) + const controller = new AbortController() + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue(undefined), + getProfile: vi.fn().mockImplementation(async ({ name }: { name: string }) => { + // A second provider instance upserts a profile while this one is waiting on the + // survivor lookup. The mutation queue is per instance, so nothing stops that write. + if (name === "keeper-profile") { + const shared = + (provider.contextProxy.getValue("listApiConfigMeta") as ProviderSettingsEntry[]) ?? [] + await provider.contextProxy.setValue("listApiConfigMeta", [...shared, lateProfile]) + controller.abort() + } + return name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-secret", + } + : { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-secret", + } + }), + } + + await expect(provider["deleteProviderProfileUnlocked"](doomedProfile, controller.signal)).rejects.toThrow( + "Profile deletion was cancelled before the selection and settings rewrite", + ) + + // The deleted profile is back and the profile the other instance added survived: the + // compensation restores only the entries this deletion owned. + const restored = (provider.contextProxy.getValue("listApiConfigMeta") as ProviderSettingsEntry[]).map( + ({ name }) => name, + ) + expect(restored).toEqual(["doomed-profile", "keeper-profile", "late-profile"]) + await provider.dispose() + }) + + it("restores the shared provider settings when a step after the rewrite fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-key", + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("doomed-id"), + getProfile: vi.fn().mockImplementation(async ({ name }) => + name === "doomed-profile" + ? { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-key", + } + : { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + }, + ), + } + // The failure lands after the shared provider keys were replaced with the survivor's: + // the rollback has to put those keys back too, or the restored selection would be + // paired with another profile's provider and credentials. + provider["rePinViewLocalStateForDeletedProfile"] = vi + .fn() + .mockRejectedValue(new Error("sibling re-pin failed")) + + await expect(provider.deleteProviderProfile(doomedProfile)).rejects.toThrow("sibling re-pin failed") + + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("doomed-profile") + expect(provider.contextProxy.getProviderSettings()).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "doomed-key", + }) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + 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() + }) + }) + it("compensates a deletion that the mutation timeout cancelled after the settings commit", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const doomedProfile: ProviderSettingsEntry = { + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const doomedSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "doomed-key" } + const keeperSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "keeper-key" } + // The timeout fires while the deletion is between its two durable writes: the abort lands + // right after the settings commit, which is the point where a half-applied deletion would + // otherwise be left behind in storage. + const controller = new AbortController() + const saveConfig = vi.fn().mockResolvedValue(undefined) + const deleteConfig = vi.fn().mockImplementation(async () => { + controller.abort() + }) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig, + saveConfig, + getProfile: vi + .fn() + .mockResolvedValueOnce({ name: "doomed-profile", ...doomedSettings }) + .mockResolvedValueOnce({ name: "keeper-profile", ...keeperSettings }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The compensation path logs its outcome; silence it so the worker does not race a console + // flush against environment teardown. + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.contextProxy.setValue("listApiConfigMeta", [doomedProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + + await expect(provider["deleteProviderProfileUnlocked"](doomedProfile, controller.signal)).rejects.toThrow( + "Profile deletion was cancelled before the profile-list write", + ) + + // The queue holds every later mutation until this run settles, so a cancelled deletion is + // rolled back like a failed one: the settings and the profile list must agree again. + expect(saveConfig).toHaveBeenCalledWith("doomed-profile", expect.objectContaining({ apiKey: "doomed-key" })) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([doomedProfile, keeperProfile]) + await provider.dispose() + }) + + describe("profile activation and upsert compensate every durable write", () => { + it("rolls back every durable write when the settings fan-out fails during an activating upsert", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-upsert-rollback") + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + + const previousEntries = [ + { name: "profile-a", id: "id-a" }, + { name: "profile-b", id: "id-b" }, + ] + await provider.contextProxy.setValue("listApiConfigMeta", previousEntries) + const previousSettings = { apiProvider: providerIdentifiers.openrouter, apiKey: "shared-a" } + await provider.contextProxy.setProviderSettings(previousSettings) + + const manager = provider.providerSettingsManager + const previousProfile = { + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + openAiBaseUrl: "https://old-b", + } + vi.spyOn(manager, "getProfile").mockResolvedValue({ name: "profile-b", ...previousProfile }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-a") + const saveConfig = vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + const setModeConfig = vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([ + { name: "profile-a", id: "id-a" }, + { name: "profile-b", id: "id-b" }, + { name: "profile-c", id: "id-c" }, + ]) + + const settingsBeforeUpsert = provider.contextProxy.getProviderSettings() + const setProviderSettings = vi.spyOn(provider.contextProxy, "setProviderSettings") + + // The shared provider settings fan out to several storage keys (the secret ones go + // to secrets.store), and the first apiKey write rejects after the profile record, the + // profile list, the mode mapping, and the shared name had all landed. Later writes are + // let through so a transient storage failure can be rolled back cleanly. + let settingsWrites = 0 + vi.mocked(mockContext.secrets.store).mockImplementation(async (key: string) => { + if (key === "apiKey") { + settingsWrites += 1 + if (settingsWrites === 1) { + throw new Error("settings persist failed") + } + } + return Promise.resolve() + }) + + await expect( + provider.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b", openAiBaseUrl: "https://new-b" }, + true, + ), + ).resolves.toBeUndefined() + + // Count the restore calls by value instead of checking the last call: a rollback that + // ran twice, or restored the wrong value, must not pass. + expect( + saveConfig.mock.calls.filter((args) => args[0] === "profile-b" && args[1]?.apiKey === "old-b").length, + ).toBe(1) + // Count the restore call by value: forward wrote the new id, the rollback must put + // the previous mapping back exactly once. + expect( + setModeConfig.mock.calls.filter((args) => args[0] === "code" && args[1] === "mode-id-a").length, + ).toBe(1) + expect(provider.getValue("currentApiConfigName")).toBe("profile-a") + expect( + setProviderSettings.mock.calls.filter( + (args) => JSON.stringify(args[0]) === JSON.stringify(settingsBeforeUpsert), + ).length, + ).toBe(1) + expect(provider.contextProxy.getProviderSettings()).toEqual(settingsBeforeUpsert) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(previousEntries) + await provider.dispose() + }) + + it("restores the profile list and the manager's current profile when an activation is rejected", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-activate-rollback") + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + + const previousEntries = [ + { name: "profile-a", id: "id-a" }, + { name: "profile-b", id: "id-b" }, + ] + await provider.contextProxy.setValue("listApiConfigMeta", previousEntries) + + const manager = provider.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-a") + const setModeConfig = vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + const activateProfile = vi.spyOn(manager, "activateProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-b", + }) + vi.spyOn(manager, "listConfig").mockResolvedValue([ + { name: "profile-a", id: "id-a" }, + { name: "profile-b", id: "id-b" }, + { name: "profile-c", id: "id-c" }, + ]) + + // The first per-view persist fails; the compensation writes that follow it are + // allowed through so a transient storage failure can be rolled back cleanly. + let failActivationPinWrite = true + vi.mocked(mockContext.globalState.update).mockImplementation(async (key: string) => { + if (key === "viewStates" && failActivationPinWrite) { + failActivationPinWrite = false + throw new Error("pin persist failed") + } + return Promise.resolve() + }) + + await expect(provider.activateProviderProfile({ name: "profile-b" })).rejects.toThrow("pin persist failed") + + // The list write had landed with the third profile in it, and activateProfile had + // already rewritten the manager's own current-profile record: both are put back, and + // the mode mapping that never landed is left alone. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(previousEntries) + expect( + activateProfile.mock.calls.filter((args) => "name" in args[0] && args[0].name === "profile-a").length, + ).toBe(1) + expect(setModeConfig).not.toHaveBeenCalled() + await provider.dispose() + }) + + it("restores the shared selection and the acting view pin separately when an activating upsert is rejected", async () => { + // A sibling activation leaves the shared selection on one profile while this view stays + // pinned to another. Restoring the shared name with setValue moves the pin with it, so + // the view ends up reporting the shared profile next to its own settings - getState + // merges the view-local state over the shared state. + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-split-rollback") + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "profile-a", id: "id-a" }, + { name: "profile-b", id: "id-b" }, + ]) + + const manager = provider.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-a") + vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-b", id: "id-b" }]) + + // The shared provider settings fan out to several storage keys (secrets included) + // and the first apiKey write rejects, after the profile record, the profile list, the + // mode mapping and the shared name had all landed: the compensation runs with the + // selection already durable. + let settingsWrites = 0 + vi.mocked(mockContext.secrets.store).mockImplementation(async (key: string) => { + if (key === "apiKey") { + settingsWrites += 1 + if (settingsWrites === 1) { + throw new Error("settings persist failed") + } + } + return Promise.resolve() + }) + + await expect( + provider.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).resolves.toBeUndefined() + + // The shared selection returns to the shared pre-operation value and the view keeps + // the pin it had, rather than inheriting the shared one. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + await provider.dispose() + }) + + it("surfaces an explicit inconsistent-state error when the compensation itself fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-inconsistent") + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + + const manager = provider.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + + // The first save lands; the compensation that puts the previous record back fails, + // so the rollback is incomplete. + vi.spyOn(manager, "saveConfig") + .mockResolvedValueOnce("id-b") + .mockRejectedValueOnce(new Error("record restore failed")) + + // Storage stays broken for every per-view persist. + vi.mocked(mockContext.globalState.update).mockImplementation(async (key: string) => { + if (key === "viewStates") { + throw new Error("pin persist failed") + } + return Promise.resolve() + }) + + // Returning undefined here would read as "the save failed and nothing changed" while + // the persisted stores disagree, so the inconsistency is surfaced instead. + await expect( + provider.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).rejects.toThrow( + /rollback was incomplete[\s\S]*record restore failed[\s\S]*Original failure: pin persist failed/, + ) + await provider.dispose() + }) + + it("restores sibling view buffers already refreshed when one sibling refresh fails during an activating upsert", async () => { + const actor = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await actor["setViewStateId"]("tab-fanout-actor") + vi.spyOn(actor, "log").mockImplementation(() => {}) + const siblingOk = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await siblingOk["setViewStateId"]("tab-fanout-ok") + const siblingFails = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await siblingFails["setViewStateId"]("tab-fanout-fail") + + // Pin both siblings to the profile the actor is about to save, each with its own + // loaded overlay, so a missing per-view restore is observable per view. + const okOverlay = { apiProvider: providerIdentifiers.anthropic, apiKey: "old-ok" } + const failOverlay = { apiProvider: providerIdentifiers.anthropic, apiKey: "old-fail" } + await siblingOk.setValue("currentApiConfigName", "profile-b") + await siblingFails.setValue("currentApiConfigName", "profile-b") + siblingOk["viewLocalState"].apiConfiguration = okOverlay + siblingFails["viewLocalState"].apiConfiguration = failOverlay + + const previousEntries = [{ name: "profile-b", id: "id-b" }] + await actor.contextProxy.setValue("listApiConfigMeta", previousEntries) + + const manager = actor.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-b") + vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-b", id: "id-b" }]) + vi.spyOn(actor, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(siblingOk, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(siblingFails, "postStateToWebview").mockRejectedValue(new Error("sibling post failed")) + + await expect( + actor.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).resolves.toBeUndefined() + + // Both sibling buffers are back on their pre-refresh overlays: a refresh that landed + // for one sibling cannot survive the rollback triggered by another's failure. + expect(siblingOk["viewLocalState"].apiConfiguration).toEqual(okOverlay) + expect(siblingFails["viewLocalState"].apiConfiguration).toEqual(failOverlay) + // The actor's own stores are rolled back by the mutation's compensation. + expect(actor.contextProxy.getValue("listApiConfigMeta")).toEqual(previousEntries) + await actor.dispose() + await siblingOk.dispose() + await siblingFails.dispose() + }) + + it("surfaces an explicit inconsistent-state error when a sibling buffer cannot be restored", async () => { + const actor = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await actor["setViewStateId"]("tab-fanout-inconsistent-actor") + vi.spyOn(actor, "log").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await sibling["setViewStateId"]("tab-fanout-inconsistent-sibling") + vi.spyOn(sibling, "log").mockImplementation(() => {}) + await sibling.setValue("currentApiConfigName", "profile-b") + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-sib", + } + + await actor.contextProxy.setValue("listApiConfigMeta", [{ name: "profile-b", id: "id-b" }]) + + const manager = actor.providerSettingsManager + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "id-b", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-b", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-b") + vi.spyOn(manager, "saveConfig").mockResolvedValue("id-b") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-b", id: "id-b" }]) + vi.spyOn(actor, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(sibling, "postStateToWebview").mockRejectedValue(new Error("sibling post failed")) + + // The forward refresh lands; the compensating restore of the same buffer fails. + // Double assertion: _saveViewLocalStateFromMutation is private and has no typed + // handle for vi.spyOn. + const siblingInternals = sibling as unknown as { + _saveViewLocalStateFromMutation: (values: Record) => Promise + } + let bufferWrites = 0 + vi.spyOn(siblingInternals, "_saveViewLocalStateFromMutation").mockImplementation(async () => { + bufferWrites += 1 + if (bufferWrites > 1) { + throw new Error("sibling buffer restore failed") + } + }) + + await expect( + actor.upsertProviderProfile( + "profile-b", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ), + ).rejects.toThrow( + /rollback was incomplete[\s\S]*sibling buffer restore failed[\s\S]*Original failure: sibling post failed/, + ) + // Exactly the forward write and one restore attempt - the rollback must not retry blindly. + expect(bufferWrites).toBe(2) + await actor.dispose() + await sibling.dispose() + }) + + it("deletes the created record when the mutation timeout aborts after the record write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-upsert-abort-create") + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + const previousEntries = [{ name: "profile-a", id: "id-a" }] + await provider.contextProxy.setValue("listApiConfigMeta", previousEntries) + const settingsBeforeAbort = provider.contextProxy.getProviderSettings() + const postState = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + const manager = provider.providerSettingsManager + // Creation case: the profile did not exist before the call, so the only way to undo the + // landed record write is to delete it again. + vi.spyOn(manager, "getProfile").mockRejectedValue(new ProviderSettingsNotFoundError("profile-new")) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue(undefined) + const listConfig = vi.spyOn(manager, "listConfig").mockResolvedValue([ + { name: "profile-a", id: "id-a" }, + { name: "profile-new", id: "id-new" }, + ]) + const setModeConfig = vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + const deleteConfig = vi.spyOn(manager, "deleteConfig").mockResolvedValue(undefined) + // The record write is parked so the mutation timeout fires while it is in flight; the + // write then lands AFTER the abort, which is the post-commit window this checkpoint + // covers. Later restores resolve immediately so the rollback can complete. + let recordWrites = 0 + let parked = false + let releaseRecordWrite!: (id: string) => void + const saveConfig = vi.spyOn(manager, "saveConfig").mockImplementation(() => { + recordWrites += 1 + if (recordWrites === 1) { + return new Promise((resolve) => { + parked = true + releaseRecordWrite = () => resolve("id-new") + }) + } + return Promise.resolve("id-new") + }) + + const originalTimeout = ClineProvider.PENDING_OPERATION_TIMEOUT_MS + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = 20 + try { + const upsert = provider.upsertProviderProfile( + "profile-new", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ) + // The queue is chained to the run, so awaiting it also waits for the compensation. + const queued = provider["providerProfileMutationQueue"] + // The caller is released by the mutation timeout while the record write is still parked. + await expect(upsert).resolves.toBeUndefined() + for (let attempt = 0; attempt < 1000 && !parked; attempt++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(parked).toBe(true) + releaseRecordWrite("id-new") + await queued + + // Count the restore by value: the created record is deleted exactly once, and the + // forward write is not replayed as a second save. + expect(deleteConfig.mock.calls.filter((args) => args[0] === "profile-new").length).toBe(1) + expect(saveConfig.mock.calls.filter((args) => args[0] === "profile-new").length).toBe(1) + // The stores after the checkpoint never landed, so the landed-write gate must keep the + // rollback from replaying them: the next queued mutation owns them now. + expect(listConfig).not.toHaveBeenCalled() + expect(setModeConfig).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(previousEntries) + expect(provider.getValue("currentApiConfigName")).toBe("profile-a") + expect(provider.contextProxy.getProviderSettings()).toEqual(settingsBeforeAbort) + // No state post for a caller that has already been released. + expect(postState).not.toHaveBeenCalled() + // The cancellation is reported as a rolled-back write rather than a silent success. + expect( + logSpy.mock.calls.filter(([message]) => + /rolled back the profile writes that had landed[\s\S]*Profile upsert was cancelled after the profile-record write/.test( + message, + ), + ).length, + ).toBe(1) + } finally { + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = + originalTimeout + } + await provider.dispose() + }) + + it("restores the previous settings when the mutation timeout aborts after an updated record write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-upsert-abort-update") + vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + const previousEntries = [{ name: "profile-a", id: "id-a" }] + await provider.contextProxy.setValue("listApiConfigMeta", previousEntries) + const settingsBeforeAbort = provider.contextProxy.getProviderSettings() + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + const manager = provider.providerSettingsManager + // Update case: a previous record exists, so the compensation must put its settings back + // instead of deleting the profile the user already had. + vi.spyOn(manager, "getProfile").mockResolvedValue({ + name: "profile-a", + id: "id-a", + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-a", + }) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("mode-id-a") + const listConfig = vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-a", id: "id-a" }]) + const setModeConfig = vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + const deleteConfig = vi.spyOn(manager, "deleteConfig").mockResolvedValue(undefined) + let recordWrites = 0 + let parked = false + let releaseRecordWrite!: (id: string) => void + const saveConfig = vi.spyOn(manager, "saveConfig").mockImplementation((_name, settings) => { + recordWrites += 1 + if (recordWrites === 1) { + return new Promise((resolve) => { + parked = true + releaseRecordWrite = () => resolve("id-a") + }) + } + // The compensating write must carry the previous settings, not the aborted ones. + expect(settings?.apiKey).toBe("old-a") + return Promise.resolve("id-a") + }) + + const originalTimeout = ClineProvider.PENDING_OPERATION_TIMEOUT_MS + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = 20 + try { + const upsert = provider.upsertProviderProfile( + "profile-a", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-a" }, + true, + ) + const queued = provider["providerProfileMutationQueue"] + await expect(upsert).resolves.toBeUndefined() + for (let attempt = 0; attempt < 1000 && !parked; attempt++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(parked).toBe(true) + releaseRecordWrite("id-a") + await queued + + // One forward write and exactly one restore of the previous settings. + expect( + saveConfig.mock.calls.filter((args) => args[0] === "profile-a" && args[1]?.apiKey === "new-a") + .length, + ).toBe(1) + expect( + saveConfig.mock.calls.filter((args) => args[0] === "profile-a" && args[1]?.apiKey === "old-a") + .length, + ).toBe(1) + // An existing profile is restored, never deleted. + expect(deleteConfig.mock.calls.filter((args) => args[0] === "profile-a").length).toBe(0) + expect(listConfig).not.toHaveBeenCalled() + expect(setModeConfig).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual(previousEntries) + expect(provider.getValue("currentApiConfigName")).toBe("profile-a") + expect(provider.contextProxy.getProviderSettings()).toEqual(settingsBeforeAbort) + } finally { + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = + originalTimeout + } + await provider.dispose() + }) + + it("surfaces the inconsistent-state error when a cancelled record write cannot be rolled back", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("tab-upsert-abort-inconsistent") + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + await provider.setValue("currentApiConfigName", "profile-a") + await provider.contextProxy.setValue("listApiConfigMeta", [{ name: "profile-a", id: "id-a" }]) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + const manager = provider.providerSettingsManager + vi.spyOn(manager, "getProfile").mockRejectedValue(new ProviderSettingsNotFoundError("profile-new")) + vi.spyOn(manager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(manager, "listConfig").mockResolvedValue([{ name: "profile-new", id: "id-new" }]) + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + // The compensating delete fails, so the persisted stores are left disagreeing. + const deleteConfig = vi.spyOn(manager, "deleteConfig").mockRejectedValue(new Error("record delete failed")) + let recordWrites = 0 + let parked = false + let releaseRecordWrite!: (id: string) => void + const saveConfig = vi.spyOn(manager, "saveConfig").mockImplementation(() => { + recordWrites += 1 + if (recordWrites === 1) { + return new Promise((resolve) => { + parked = true + releaseRecordWrite = () => resolve("id-new") + }) + } + return Promise.resolve("id-new") + }) + + const originalTimeout = ClineProvider.PENDING_OPERATION_TIMEOUT_MS + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = 20 + try { + const upsert = provider.upsertProviderProfile( + "profile-new", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-b" }, + true, + ) + const queued = provider["providerProfileMutationQueue"] + await expect(upsert).resolves.toBeUndefined() + for (let attempt = 0; attempt < 1000 && !parked; attempt++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(parked).toBe(true) + releaseRecordWrite("id-new") + await queued + + expect(deleteConfig.mock.calls.filter((args) => args[0] === "profile-new").length).toBe(1) + // A half-repaired cancellation must not read as a clean rollback: the run rejects with + // the inconsistent-state error, which the queue reports as an errored cancellation. + expect( + logSpy.mock.calls.filter(([message]) => + /errored after cancellation[\s\S]*rollback was incomplete[\s\S]*record delete failed[\s\S]*Original failure: Profile upsert was cancelled after the profile-record write/.test( + message, + ), + ).length, + ).toBe(1) + } finally { + ;(ClineProvider as unknown as { PENDING_OPERATION_TIMEOUT_MS: number }).PENDING_OPERATION_TIMEOUT_MS = + originalTimeout + } + await provider.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider2.saveViewState("mode", "debugger") + await provider1.saveViewState("mode", "architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const saveViewState1 = provider1.saveViewState.bind(provider1) + const saveViewState2 = provider2.saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await provider.saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy: the fixture's + // vscode.env.language is "en" and no custom modes are persisted. + expect(state.language).toBe("en") + expect(state.customModes).toEqual([]) + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "local-key", + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("openrouter") + expect(state.apiConfiguration.openRouterApiKey).toBe("local-key") + + await provider.dispose() + }) + + it("reports the fresh global apiConfiguration after a profile activation followed by a global settings write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("activated-id"), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile( + "activated-profile", + { apiProvider: providerIdentifiers.anthropic }, + true, + ) + + // Simulate api.setConfiguration (src/extension/api.ts): a global-only + // write that does not refresh the view-local buffer. + await provider.contextProxy.setValues({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + }) + + const state = await provider.getState({ includeTaskHistory: false }) + + // The fresh global selection must win: the activation's buffer write must + // not mask the later shared update (regression guard for the e2e + // provider-probe suites, which start tasks after a profile activation). + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when activating a different profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // Typed spies on the real manager (instance-scoped): the upsert-activate path + // reads only these methods, so no manager replacement is needed. + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-b-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-b", { apiProvider: providerIdentifiers.anthropic }, true) + + const state = await provider.getState({ includeTaskHistory: false }) + + // Activating profile B must clear profile A's buffered overlay so the shared + // settings (written by setProviderSettings) win; otherwise getState would + // report profile B's name with profile A's provider. + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when directly activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // Typed spies on the real manager (instance-scoped): the direct-activation path + // reads only these methods, so no manager replacement is needed. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The direct-activation path (activateProviderProfileUnlocked) carries its own + // overlay-clearing call, distinct from upsertProviderProfile's. + await provider.activateProviderProfile({ name: "profile-b" }) + + const state = await provider.getState({ includeTaskHistory: false }) + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const contextProxyAccess = provider.contextProxy as { + setValues: (values: Partial) => Promise + } + await contextProxyAccess.setValues({ + mode: "debugger", + currentApiConfigName: "shared-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }, + customModePrompts: { code: { roleDefinition: "shared" } }, + }) + + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "view-profile") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + + const values = provider.getValues() + + expect(values.mode).toBe("architect") + expect(values.currentApiConfigName).toBe("view-profile") + expect(values.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + expect(values.customModePrompts).toEqual({ code: { roleDefinition: "shared" } }) + + await provider.dispose() + }) + + it("should keep flat provider settings out of the view-local buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + + await provider.setValues({ + apiProvider: providerIdentifiers.bedrock, + awsUseApiKey: true, + awsApiKey: "mock-key", + awsRegion: "us-east-1", + apiModelId: "anthropic.claude-opus-4-8-20261215-v1:0", + awsBedrockEndpoint: "http://127.0.0.1:4567", + awsBedrockEndpointEnabled: true, + }) + + // Flat provider-settings keys are shared settings: they must flow through + // the ContextProxy only and must not be merged into the view-local buffer, + // which would turn them into a per-view override masking later shared + // updates from other views. The explicit view-local override survives. + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.bedrock) + expect(provider.contextProxy.getValue("awsBedrockEndpoint")).toBe("http://127.0.0.1:4567") + + await provider.dispose() + }) + }) + + describe("persisted view state", () => { + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" }) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should drop an unknown mode from setValues while keeping valid modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab panel/with.dots and spaces") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + const firstSave = provider.saveViewState("mode", "architect") + await firstWriteStarted + 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() }) - afterEach(() => { - if (originalProbeSetting === undefined) { - delete process.env.ROO_CODE_THEME_FIXTURE_PROBE - } else { - process.env.ROO_CODE_THEME_FIXTURE_PROBE = originalProbeSetting - } - vi.useRealTimers() - }) + 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(), + ) - test("rejects requests when probing is disabled", async () => { - delete process.env.ROO_CODE_THEME_FIXTURE_PROBE + // 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") - await expect(provider.requestWebviewThemeFixture()).rejects.toThrow("Theme fixture probing is disabled") - }) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + [provider.viewId]: { mode: "architect" }, + }) - test("posts a request and resolves the matching response", async () => { - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) - const request = provider.requestWebviewThemeFixture() - await Promise.resolve() - const requestId = postMessageSpy.mock.calls[0]?.[0].requestId - const unknownFixture = { ...fixture, themeId: "Unexpected Theme" } + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "debugger") - expect(requestId).toBeTruthy() - expect(postMessageSpy).toHaveBeenCalledWith({ type: "themeFixtureProbeRequest", requestId }) - provider.resolveWebviewThemeFixtureProbe("unknown-request", unknownFixture) - provider.resolveWebviewThemeFixtureProbe(requestId!, fixture) + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() - await expect(request).resolves.toEqual(fixture) + await provider.dispose() }) - test("rejects a request after its timeout", async () => { - vi.useFakeTimers() - vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) - const request = provider.requestWebviewThemeFixture(100) - const rejection = expect(request).rejects.toThrow("Theme fixture probe timed out after 100ms") + 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(), + ) - await vi.advanceTimersByTimeAsync(100) - await rejection + // A stable entry already exists (e.g. a previous session persisted under a + // colliding temporary id); it must win over the temporary entry. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + 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() }) - test("rejects pending requests when webview resources are cleared", async () => { - vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) - const request = provider.requestWebviewThemeFixture() - const rejection = expect(request).rejects.toThrow( - "Webview was disposed before the theme fixture probe completed", + 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(), ) - provider["clearWebviewResources"]() - await rejection - }) - }) + // 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 }, + }) - test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { - const mdmService = { - requiresCloudAuth: vi.fn().mockReturnValue(true), - isCompliant: vi.fn().mockReturnValue({ compliant: false, reason: "auth required" }), - } as any + // 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, + }), + ), + ) - provider = new ClineProvider( - mockContext, - mockOutputChannel, - "sidebar", - new ContextProxy(mockContext), - new WebviewFocusTracker(), - mdmService, - ) + const staleLoad = provider["loadViewState"]() - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider as any, "getStateToPostToWebview").mockResolvedValue({ version: "1.0.0" }) + // 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 - await provider.postStateToWebview() + releaseGhost() + await staleLoad - expect(postMessageSpy).toHaveBeenCalledTimes(1) - expect(postMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "action" })) - }) + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) - test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { - let releasePost!: () => void - const pendingPost = new Promise((resolve) => { - releasePost = resolve + 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", () => { @@ -1799,6 +6164,47 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) + it("should re-pin the view to a valid global selection when the first listed profile has no name", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double: the view's pinned config no + // longer exists, the shared global selection is still valid, and the only listed profile is a + // legacy entry without a name. + provider.providerSettingsManager = { + hasConfig: vi.fn(async (name: string) => name === "global-valid"), + listConfig: vi.fn(async () => [{ id: "legacy-id", apiProvider: providerIdentifiers.openai }]), + saveConfig: vi.fn(async () => "legacy-id"), + dispose: vi.fn(), + } + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The view's own pin points at a deleted profile; the shared global selection is still valid. + await provider.saveViewState("currentApiConfigName", "gone-pin") + await provider.contextProxy.setValue("currentApiConfigName", "global-valid") + + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview") + const contextProxySetValueSpy = vi.spyOn(provider.contextProxy, "setValue") + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + // The launch flow's profile-list sync is fire-and-forget; wait for its final post. + await vi.waitFor(() => + expect(postMessageSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "listApiConfig" })), + ) + + // The valid shared selection is adopted by this view only: the view-local pin switches to it + // while the shared global selection is left untouched for the other views. + expect(provider["viewLocalState"].currentApiConfigName).toBe("global-valid") + expect(mockContext.globalState.get("currentApiConfigName")).toBe("global-valid") + // A nameless first listed profile must never clear the shared selection with undefined. + expect(contextProxySetValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", undefined) + + await provider.dispose() + }) + test("logs detached workspace initialization failures", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -2425,6 +6831,8 @@ describe("ClineProvider", () => { activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(profile), getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any @@ -2452,6 +6860,8 @@ describe("ClineProvider", () => { activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), + // The activation snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue(profile), getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any @@ -2632,6 +7042,13 @@ describe("ClineProvider", () => { ]), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), + // The upsert snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue({ + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any // Update API configuration @@ -3087,8 +7504,13 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("non-existent-mode", expect.any(Array)) - // Verify fallback to default mode - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "code") + // Verify fallback to default mode, view-locally: history restore no longer + // writes the shared global mode. Reject any write to the shared "mode" key, + // whatever value it carries - a write of "ask" would satisfy a value-specific check. + expect(provider["viewLocalState"].mode).toBe("code") + expect( + vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode"), + ).toHaveLength(0) expect(logSpy).toHaveBeenCalledWith( "Mode 'non-existent-mode' from history no longer exists. Falling back to default mode 'code'.", ) @@ -3160,8 +7582,11 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("custom-mode", expect.any(Array)) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "custom-mode") + // Verify mode was preserved view-locally: no write to the shared "mode" key at all. + expect(provider["viewLocalState"].mode).toBe("custom-mode") + expect( + vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode"), + ).toHaveLength(0) expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("no longer exists")) // Verify history item mode was not changed @@ -3208,8 +7633,11 @@ describe("ClineProvider", () => { // Initialize with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was preserved view-locally: no write to the shared "mode" key at all. + expect(provider["viewLocalState"].mode).toBe("architect") + expect( + vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode"), + ).toHaveLength(0) // Verify history item mode was not changed expect(historyItem.mode).toBe("architect") @@ -3406,6 +7834,13 @@ describe("ClineProvider", () => { .mockResolvedValue([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), + // The upsert snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue({ + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any const testApiConfig = { @@ -3451,6 +7886,13 @@ describe("ClineProvider", () => { .mockResolvedValue([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), + // The upsert snapshot reads the prior record and the previous per-mode mapping. + getProfile: vi.fn().mockResolvedValue({ + name: "test-config", + id: "test-id", + apiProvider: providerIdentifiers.anthropic, + }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), } as any // Setup Task instance with auto-mock from the top of the file diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index b60ec498fb..3694f7c7a0 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -467,6 +467,573 @@ 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("restores the view's own mode pin when a task listener throws after the durable write", async () => { + // A listener that throws after the durable write is the failure under test. + // Only the TaskModeSwitched emit may throw: addClineToStack emits other events + // during setup, and a listener failure is what the rollback covers. Kept as an + // untyped Mock so the object literal still overlaps Partial. + const emit = vi.fn() + emit.mockImplementation((event: string) => { + if (event === "taskModeSwitched") { + throw new Error("listener failed") + } + }) + + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit, + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + await provider.contextProxy.setValue("mode", "code") + // The view is pinned to architect while the shared mode is code. The forward + // write moves both to ask, so the rollback has two different values to put back. + await provider.saveViewState("mode", "architect") + expect(provider.getValue("mode")).toBe("code") + expect(provider["viewLocalState"].mode).toBe("architect") + + await expect(provider["handleModeSwitchUnlocked"]("ask", mockTask)).rejects.toThrow("listener failed") + + // The shared value returns to code... + expect(provider.getValue("mode")).toBe("code") + // ...and the pin returns to ITS OWN pre-switch value, not to the shared one. + // A rollback that replayed the shared value through setValue would leave this + // view pinned to code and a task started here would run in the wrong mode. + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + // The durable pin must hold THIS view's own pre-switch mode. A persisted entry + // carrying the restored shared mode would resurrect the wrong mode on reload and + // start the next task here in the wrong mode. + expect(persisted.mode).toBe("architect") + // The task-history compensation is still the pre-switch mode. + expect(updateTaskHistorySpy).toHaveBeenCalled() + expect((updateTaskHistorySpy.mock.calls.at(-1)?.[0] as HistoryItem).mode).toBe("code") + }) + + it("leaves no mode pin behind when a view without a pin fails the same way", async () => { + // Only the TaskModeSwitched emit may throw: addClineToStack emits other events + // during setup, and a listener failure is what the rollback covers. Kept as an + // untyped Mock so the object literal still overlaps Partial. + const emit = vi.fn() + emit.mockImplementation((event: string) => { + if (event === "taskModeSwitched") { + throw new Error("listener failed") + } + }) + + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit, + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { + return Promise.resolve([]) + }) + await provider.addClineToStack(mockTask) + await provider.contextProxy.setValue("mode", "code") + // No saveViewState: this view has no pin, so the forward write creates one. + expect(provider["viewLocalState"].mode).toBeUndefined() + + await expect(provider["handleModeSwitchUnlocked"]("ask", mockTask)).rejects.toThrow("listener failed") + + expect(provider.getValue("mode")).toBe("code") + // The pin the failed switch created must be cleared again rather than left + // holding the restored shared mode. + expect(provider["viewLocalState"].mode).toBeUndefined() + expect(provider.getValues().mode).toBe("code") + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + // Either no entry was written for this view or the entry carries no mode key; what + // must not happen is a persisted pin holding the restored shared mode. + expect(persisted.mode).toBeUndefined() + }) + + it("restores the view mode pin even when the shared-mode rollback write fails", async () => { + // Only the TaskModeSwitched emit throws: a listener failure is what triggers the + // durable rollback under test. + const emit = vi.fn() + emit.mockImplementation((event: string) => { + if (event === "taskModeSwitched") { + throw new Error("listener failed") + } + }) + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit, + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => Promise.resolve([])) + await provider.addClineToStack(mockTask) + await provider.contextProxy.setValue("mode", "code") + // The view is pinned to architect while the shared mode is code, so the rollback + // has two different stores to put back. + await provider.saveViewState("mode", "architect") + + // Fail ONLY the shared-mode restore. The pin restore is awaited separately, so it + // must still land: one try/catch around both restores would skip it and leave this + // view pinned to the abandoned mode. + const updateSpy = vi.mocked(mockContext.globalState.update) + const originalUpdate = updateSpy.getMockImplementation() + updateSpy.mockImplementation((key: string, value: unknown) => { + if (key === "mode" && value === "code") { + return Promise.reject(new Error("shared mode restore failed")) + } + return originalUpdate ? originalUpdate(key, value) : Promise.resolve() + }) + + // A rollback that itself failed must not be reported as a clean rollback, and the + // caller has to learn which store is still wrong. + const rollbackError = await provider["handleModeSwitchUnlocked"]("ask", mockTask).then( + () => undefined, + (error: unknown) => error as Error, + ) + expect(rollbackError?.message).toContain("left persisted mode state inconsistent") + expect(rollbackError?.message).toContain("shared mode: shared mode restore failed") + expect(rollbackError?.message).toContain("Original failure: listener failed") + + // The second restore still ran: this view is back on its own pre-switch mode. + expect(provider["viewLocalState"].mode).toBe("architect") + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + expect(persisted.mode).toBe("architect") + updateSpy.mockRestore() + }) + + it("compensates the durable mode write and emits nothing when the signal aborts while the write is in flight", 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) + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementation(() => Promise.resolve([])) + await provider.addClineToStack(mockTask) + await provider.contextProxy.setValue("mode", "code") + await provider.saveViewState("mode", "architect") + + // Hold the durable mode write open so the cancellation lands while it is still + // in flight. The write then SUCCEEDS after the caller was already handed a + // timeout rejection: without a post-settle check the emit, the in-memory task + // mode, and the profile load all run for a switch that was reported cancelled. + const updateSpy = vi.mocked(mockContext.globalState.update) + const originalUpdate = updateSpy.getMockImplementation() + let releaseModeWrite!: () => void + const writeGate = new Promise((resolve) => { + releaseModeWrite = resolve + }) + let gated = false + updateSpy.mockImplementation(async (key: string, value: unknown) => { + if (key === "mode" && !gated) { + gated = true + await writeGate + } + return originalUpdate ? originalUpdate(key, value) : Promise.resolve() + }) + + const emitSpy = vi.spyOn(provider, "emit") + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("ask", mockTask, controller.signal) + + await vi.waitFor(() => expect(updateSpy).toHaveBeenCalledWith("mode", "ask")) + controller.abort() + releaseModeWrite() + await switchPromise + + // Nothing about the abandoned switch is published. + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "ask") + expect(emitSpy).not.toHaveBeenCalledWith("modeChanged", "ask") + expect(mockTask["_taskMode"]).toBe("code") + // Everything the write had already landed is put back: shared mode, this view's + // pin, and the task-history entry. + expect(updateSpy).toHaveBeenCalledWith("mode", "code") + expect(provider["viewLocalState"].mode).toBe("architect") + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] ?? {} + expect(persisted.mode).toBe("architect") + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect((updateTaskHistorySpy.mock.calls.at(-1)?.[0] as HistoryItem).mode).toBe("code") + updateSpy.mockRestore() + }) + + 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 +1054,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 +1339,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") @@ -1027,15 +1597,80 @@ describe("ClineProvider - Sticky Mode", () => { // Since the error is thrown before updating the task's _taskMode, // neither the task mode nor global state are updated + // The switch is a compensating transaction: the durable write landed and was then + // undone, so the last "mode" write puts the previous value back and the task + // never moved. const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - expect(modeCalls.length).toBe(0) - - // The task's mode should NOT have been updated since the error occurred first - expect(mockTask._taskMode).toBe("code") + expect(modeCalls.length).toBeGreaterThanOrEqual(2) + expect(modeCalls[0][1]).toBe("architect") + // Assert the exact restored value rather than "not architect": a weak assertion also passes + // when the rollback writes an unrelated mode. This scenario never seeded a shared mode, + // so the value the compensation must put back is undefined - the task's own mode is the + // "code" value asserted on the next line, which is a different store. + expect(modeCalls[modeCalls.length - 1][1]).toBe(undefined) consoleErrorSpy.mockRestore() }) + it("rolls the task-history write back when the durable mode write fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // A minimal typed double (no `as any`): the private _taskMode is read through + // bracket notation below. + 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, + ) + await provider.addClineToStack(mockTask) + await seedTaskHistory([ + { + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + }, + ]) + + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + // The durable (shared + per-view) mode write fails. + const setValueSpy = vi.spyOn(provider, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await expect(provider.handleModeSwitch("architect")).rejects.toThrow("persist failed") + + // The history write that had already landed is undone with the previous mode... + expect(setValueSpy).toHaveBeenCalledWith("mode", "architect") + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: "test-task-id", mode: "architect" }), + ) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: "test-task-id", mode: "code" }), + ) + // ...and the task never observed the switch. + expect(mockTask["_taskMode"]).toBe("code") + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", "test-task-id", "architect") + + setValueSpy.mockRestore() + updateTaskHistorySpy.mockRestore() + }) + it("should handle updateTaskHistory failures", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 01864c0234..2f173ba99f 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1024,4 +1024,137 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) }) + + describe("deleteProviderProfile", () => { + it("removes the stored profile so a dangling mode mapping can no longer re-activate it", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed the stored profile settings: the default profile plus a child + // profile (mirroring the cross-profile subtasks e2e scenario), with the + // "ask" mode mapped to the child profile. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.providerSettingsManager.setModeConfig("ask", childId) + + // The UI-facing list mirrors the store, as maintained by upsert/activate. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The deleted profile's settings are gone from the manager store. + const remaining = await provider.providerSettingsManager.listConfig() + expect(remaining.some((config) => config.name === "subtask-child-profile")).toBe(false) + await expect( + provider.providerSettingsManager.getProfile({ name: "subtask-child-profile" }), + ).rejects.toThrow(/subtask-child-profile.*not found/) + + // The mode mapping still points at the deleted id, but it no longer + // resolves to a stored profile, so handleModeSwitch falls through to the + // current configuration instead of re-activating the deleted profile. + const savedConfigId = await provider.providerSettingsManager.getModeConfigId("ask") + expect(savedConfigId).toBe(childId) + expect(remaining.find(({ id }) => id === savedConfigId)).toBeUndefined() + + // The deletion alone must have repointed the context at the surviving profile. Assert that + // before driving any further public path: a later mode switch that happens to repair the + // selection would otherwise mask a deletion that left currentApiConfigName on the profile + // it just removed. + const afterDeletion = provider.contextProxy.getValues() + expect(afterDeletion.currentApiConfigName).toBe("default") + expect(afterDeletion.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + + // Drive the public path the comment above describes instead of asserting only the + // mapping: switching to the mode that was pinned to the deleted profile must fall + // through to the surviving profile, not re-activate the deleted one. + await provider.handleModeSwitch("ask") + const afterSwitch = provider.contextProxy.getValues() + expect(afterSwitch.currentApiConfigName).toBe("default") + expect(afterSwitch.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("treats an already-gone secret as success so the stale list entry is still pruned", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Only the default profile exists in the store: the ghost profile's secret + // was already gone (e.g. pruned by an earlier run) but its list entry + // survived. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "ghost-profile", + }) + + // The manager's "not found" rejection must not surface to the caller ... + await expect( + provider.deleteProviderProfile({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.not.toThrow() + + // ... it prunes the stale list entry and repoints the selection. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("still refuses to delete the last stored profile when a stale list entry remains", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + // A ghost list entry survives next to the only real profile, so the + // provider-level "last profile" guard does not fire: the refusal must come + // from the settings store itself. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + await expect( + provider.deleteProviderProfile({ + name: "default", + id: defaultId, + apiProvider: providerIdentifiers.openrouter, + }), + ).rejects.toThrow("Cannot delete the last remaining configuration") + + // 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 dcd70f92f1..f66f0cbacd 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -69,7 +69,7 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, RooCodeSettings } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -99,6 +99,7 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + saveViewState: vi.fn(), customModesManager: { getCustomModes: vi.fn(), deleteCustomMode: vi.fn(), @@ -115,6 +116,16 @@ const mockClineProvider = { setValue: vi.fn(), getValue: vi.fn(), }, + // Delegates to contextProxy.setValue so existing assertions keep holding while + // the updateSettings flow is exercised through the provider-level mutation path. + setValue: vi + .fn() + .mockImplementation((key: string, value: unknown) => + mockClineProvider.contextProxy.setValue( + key as keyof RooCodeSettings, + value as RooCodeSettings[keyof RooCodeSettings], + ), + ), log: vi.fn(), postStateToWebview: vi.fn(), resolveWebviewThemeFixtureProbe: vi.fn(), @@ -260,6 +271,148 @@ import { resolveImageMentions } from "../../mentions/resolveImageMentions" import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" +import type { ProviderSettingsManager } from "../../config/ProviderSettingsManager" + +describe("webviewMessageHandler - webviewDidLaunch", () => { + // Single structural view of the provider members this suite reassigns at runtime: + // the class type declares several of them as getters / readonly, so the fixture + // type is the writable view of the same object (no cast through unknown needed). + type LaunchProviderFixture = { + setViewStateId: (viewStateId: string) => Promise + workspaceTracker: { initializeFilePaths: () => Promise } + providerSettingsManager: { + listConfig: () => Promise + hasConfig: (name: string) => Promise + } + activateProviderProfile: (options: { name: string }) => Promise + getMcpHub: () => unknown + getStateToPostToWebview: () => Promise<{ telemetrySetting: string }> + } + const double = mockClineProvider as LaunchProviderFixture + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue( + Object.assign({} as Awaited>, { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + currentApiConfigName: "view-local-profile", + }), + ) + double.setViewStateId = vi.fn().mockResolvedValue(undefined) + double.workspaceTracker = { initializeFilePaths: vi.fn().mockResolvedValue(undefined) } + double.providerSettingsManager = { + listConfig: vi + .fn() + .mockResolvedValue([{ name: "shared-profile", apiProvider: providerIdentifiers.anthropic }]), + hasConfig: vi.fn().mockResolvedValue(false), + } + double.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + double.getMcpHub = vi.fn().mockReturnValue(undefined) + double.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "disabled" }) + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + // Key-aware so a mutated global-state key (e.g. "") resolves to nothing instead + // of the canned value, keeping the re-pin branch's global lookup observable. + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "currentApiConfigName" ? "shared-profile" : undefined, + ) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + // Capture the fixture's pre-suite values for the members this suite reassigns: + // the module-level fixture does not declare them, and vi.clearAllMocks() only + // resets call history — it never restores property assignments, so without this + // restore the launch doubles leak into every later suite in this file. + const originalLaunchMembers = { + setViewStateId: double.setViewStateId, + workspaceTracker: double.workspaceTracker, + providerSettingsManager: double.providerSettingsManager, + activateProviderProfile: double.activateProviderProfile, + getMcpHub: double.getMcpHub, + getStateToPostToWebview: double.getStateToPostToWebview, + } + + afterEach(() => { + Object.assign(double, originalLaunchMembers) + }) + + it("validates the view-local currentApiConfigName on launch", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(double.setViewStateId).toHaveBeenCalledWith("view-1") + + // The merged (view-local) name is validated first; the shared global is only + // consulted when the view-local name is invalid. + expect(double.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(mockClineProvider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("shared-profile") + // Both names are invalid in this setup, so the shared global is repaired. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith({ name: "shared-profile" }) + }) + + it("re-pins only the view when its profile is missing but the shared global is still valid", async () => { + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The view pin is re-pinned to the first available profile, + // and the shared global selection is left untouched: no global write, no global activation. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "currentApiConfigName", + "shared-profile", + ) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("re-pins the view to the shared global profile rather than the first listed profile", async () => { + double.providerSettingsManager.listConfig = vi.fn().mockResolvedValue([ + { name: "first-listed", apiProvider: providerIdentifiers.anthropic }, + { name: "shared-profile", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The view pin follows the still-valid shared global selection, not the first + // profile in the list; the global selection is left untouched. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.saveViewState).not.toHaveBeenCalledWith("currentApiConfigName", "first-listed") + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("records the legacy repair without activating a profile when no name is listed", async () => { + double.providerSettingsManager.listConfig = vi + .fn() + .mockResolvedValue([{ apiProvider: providerIdentifiers.anthropic }]) + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockResolvedValue(false) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The legacy repair still records the (empty) selection, but does not activate a + // profile that has no name. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", undefined) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("logs and continues launch when view-state registration fails", async () => { + // Earlier tests in this suite already ran the full webviewDidLaunch flow on the + // shared module-level double, so isViewLaunched is already true. Reset it, or the + // assertion below would pass even if the failed registration aborted launch. + mockClineProvider.isViewLaunched = false + double.setViewStateId = vi.fn().mockRejectedValue(new Error("storage down")) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + // The failed registration is logged ... + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("view-state registration failed")) + // ... launch handling still posts the initial state ... + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + // ... and marks the view as launched. + expect(mockClineProvider.isViewLaunched).toBe(true) + }) +}) describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { @@ -1240,6 +1393,108 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - host-owned keys in an updateSettings payload", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("refuses to apply profile- and provider-owned keys supplied by the webview", async () => { + // The webview is untrusted input. `apiConfiguration` is not even a RooCodeSettings key, + // so the only way it reaches the handler is a payload the compile-time type cannot + // express - which is what a crafted webview sends. Passing it through a variable rather + // than an inline literal models that at runtime. + const craftedPayload = { + enableCheckpoints: true, + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "attacker-key", + }, + listApiConfigMeta: [{ name: "evil-profile", id: "evil-id", apiProvider: providerIdentifiers.openrouter }], + viewStates: { + "evil-view": { mode: "act", currentApiConfigName: "evil-profile", updatedAt: 1 }, + }, + currentApiConfigName: "evil-profile", + } + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: craftedPayload, + }) + + // The ordinary setting still takes its normal route through provider.setValue, which + // is also what keeps the acting view's buffer and pin in sync. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("enableCheckpoints", true) + // The profile- and provider-owned keys reach neither sink: not the shared store, and + // not this view's local buffer (which getState() prefers over the shared values). + for (const key of ["apiConfiguration", "listApiConfigMeta", "viewStates", "currentApiConfigName"]) { + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith(key, expect.anything()) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith(key, expect.anything()) + } + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignoring host-owned setting 'apiConfiguration'"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Ignoring host-owned setting 'currentApiConfigName'"), + ) + }) +}) + +describe("webviewMessageHandler - profile deletion routes through the provider mutation path", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // providerSettingsManager is readonly on the class and this suite reassigns it, so the + // fixture type is the writable view of the same object - the same pattern the + // webviewDidLaunch suite above uses. + type Writable = { -readonly [K in keyof T]: T[K] } + type DeletionProviderFixture = { providerSettingsManager: Partial> } + const writable = mockClineProvider as DeletionProviderFixture + + it("deletes through ClineProvider so the queue, compensation and sibling re-pin still run", async () => { + writable.providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter }, + { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.anthropic }, + ]), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + mockClineProvider.deleteProviderProfile = vi.fn().mockResolvedValue(undefined) + // The handler compares the dialog answer against the TRANSLATED string, not the key: + // t comes from src/i18n, so the stub has to return the same value t produces here. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "doomed-profile" }) + + expect(mockClineProvider.deleteProviderProfile).toHaveBeenCalledWith({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.openrouter, + }) + expect(mockClineProvider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("reports the delete failure without falling back to a direct manager delete", async () => { + writable.providerSettingsManager = { + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.openrouter }, + ]), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + mockClineProvider.deleteProviderProfile = vi + .fn() + .mockRejectedValue(new Error("You cannot delete the last profile")) + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue(t("common:answers.yes") as never) + + await webviewMessageHandler(mockClineProvider, { type: "deleteApiConfiguration", text: "doomed-profile" }) + + expect(mockClineProvider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.delete_api_config") + }) +}) + describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { beforeEach(() => { vi.clearAllMocks() @@ -1308,6 +1563,20 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { expect(ensureDcgInstalled).not.toHaveBeenCalled() expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) }) + + it("routes the write through provider.setValue so view-local state stays in sync", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: false }, + }) + + // The provider-level call is the write path under test. The mock forwards to + // contextProxy.setValue, so an assertion on the proxy alone would also pass + // if the handler bypassed the provider and skipped the view-local sync. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) + }) }) // A plain boolean needs no normalization branch in the updateSettings loop, @@ -2138,6 +2407,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 @@ -2306,4 +2594,15 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) + + afterEach(() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 193540455b..365299d64b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -95,6 +95,14 @@ import { getLMStudioModels } from "../../api/providers/fetchers/lmstudio" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) +// Keys that only host-side, validated paths may write. `apiConfiguration` and +// `listApiConfigMeta` are resolved by ProviderSettingsManager, which validates a +// configuration before it can become active, and `viewStates` is the per-view durable +// map that ClineProvider owns. The generic settings loop below writes straight into +// the shared store and into this view's local buffer - and getState() prefers the +// buffer - so a webview payload must not be allowed to carry them. +const HOST_OWNED_SETTINGS = new Set(["apiConfiguration", "listApiConfigMeta", "viewStates", "currentApiConfigName"]) + // Serializes handling of "telemetrySetting" messages. Each invocation reads the previous // setting, awaits a persistence write, then applies the new live telemetry state -- with no // serialization, two rapid messages (e.g. a fast toggle) can interleave across those awaits: @@ -580,7 +588,20 @@ export const webviewMessageHandler = async ( provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) } break - case "webviewDidLaunch": + case "webviewDidLaunch": { + // A failed view-state registration must not abort launch handling: the + // initial state, theme and API-configuration sync below still run, and the + // provider restores its previous viewStateId on failure (setViewStateId) so + // a later launch retries registration and loadViewState instead of + // treating the failed id as already handled. + try { + await provider.setViewStateId(message.viewStateId) + } catch (error) { + provider.log( + `[webviewDidLaunch] view-state registration failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) @@ -629,17 +650,36 @@ export const webviewMessageHandler = async ( } } - const currentConfigName = getGlobalState("currentApiConfigName") + const currentState = await provider.getState() + const currentConfigName = currentState.currentApiConfigName if (currentConfigName) { if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) { - // Current config name not valid, get first config in list. + // The merged name (which may be this view's durable pin) no longer + // resolves. When the shared global selection is still valid, re-pin + // only this view so the global selection is left untouched; only + // repair the global when it is invalid as well. + const globalConfigName = getGlobalState("currentApiConfigName") + const globalStillValid = + !!globalConfigName && + (await provider.providerSettingsManager.hasConfig(globalConfigName)) const name = listApiConfig[0]?.name - await updateGlobalState("currentApiConfigName", name) - if (name) { - await provider.activateProviderProfile({ name }) - return + if (globalStillValid && globalConfigName) { + // Re-pin this view to the still-valid shared global selection (not the + // first listed profile) so the view adopts the shared choice; the + // global selection itself is left untouched. + await provider.saveViewState("currentApiConfigName", globalConfigName) + // Fall through: refresh listApiConfigMeta and post listApiConfig + // to this webview below. + } else { + // Current config name not valid, get first config in list. + await updateGlobalState("currentApiConfigName", name) + + if (name) { + await provider.activateProviderProfile({ name }) + return + } } } } @@ -689,6 +729,7 @@ export const webviewMessageHandler = async ( provider.isViewLaunched = true break + } case "newTask": // Initializing new instance of Cline will make sure that any // agentically running promises in old instance don't affect our new @@ -747,6 +788,17 @@ export const webviewMessageHandler = async ( } for (const [key, value] of Object.entries(message.updatedSettings)) { + if (HOST_OWNED_SETTINGS.has(key)) { + // Boundary check, not normalization: these keys reach the runtime through the + // profile-management paths, which validate them first. Applying them here would + // let an injected webview swap in an attacker-controlled API configuration that + // getState() then serves to every consumer. + provider.log( + `[updateSettings] Ignoring host-owned setting '${key}' supplied by the webview; it must go through the profile-management path.`, + ) + continue + } + let newValue = value if (key === "language") { @@ -856,7 +908,9 @@ export const webviewMessageHandler = async ( } } - await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) + // Route through provider.setValue so view-local buffer/pin sync stays + // consistent with the other mutation paths. + await provider.setValue(key as keyof RooCodeSettings, newValue) } await provider.postStateToWebview() @@ -2375,18 +2429,22 @@ export const webviewMessageHandler = async ( const oldName = message.text - const newName = (await provider.providerSettingsManager.listConfig()).filter( - (c) => c.name !== oldName, - )[0]?.name + const profileToDelete = (await provider.providerSettingsManager.listConfig()).find( + (profile) => profile.name === oldName, + ) - if (!newName) { + if (!profileToDelete) { vscode.window.showErrorMessage(t("common:errors.delete_api_config")) return } try { - await provider.providerSettingsManager.deleteConfig(oldName) - await provider.activateProviderProfile({ name: newName }) + // Route through the provider: deleteProviderProfile serialises against the other + // profile mutations, snapshots and compensates the shared stores, and re-pins every + // sibling view still pinned to the deleted profile. Calling + // providerSettingsManager.deleteConfig here bypasses all of that and leaves those + // views with stale in-memory and persisted state. + await provider.deleteProviderProfile(profileToDelete) } catch (error) { provider.log( `Error delete api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index e4b15aa27e..0f2f4ba734 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 @@ -1036,12 +1031,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 0931ca779d..b92852a277 100644 --- a/src/package.json +++ b/src/package.json @@ -95,6 +95,26 @@ "title": "%command.settings.title%", "icon": "$(settings-gear)" }, + { + "command": "zoo-code.plusButtonClickedInTab", + "title": "%command.newTask.title%", + "icon": "$(edit)" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "title": "%command.settings.title%", + "icon": "$(settings-gear)" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "title": "%command.marketplace.title%", + "icon": "$(extensions)" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "title": "%command.history.title%", + "icon": "$(history)" + }, { "command": "zoo-code.openInNewTab", "title": "%command.openInNewTab.title%", @@ -241,22 +261,22 @@ ], "editor/title": [ { - "command": "zoo-code.plusButtonClicked", + "command": "zoo-code.plusButtonClickedInTab", "group": "navigation@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.settingsButtonClicked", + "command": "zoo-code.settingsButtonClickedInTab", "group": "navigation@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.marketplaceButtonClicked", + "command": "zoo-code.marketplaceButtonClickedInTab", "group": "navigation@3", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, { - "command": "zoo-code.historyButtonClicked", + "command": "zoo-code.historyButtonClickedInTab", "group": "overflow@1", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" }, @@ -265,6 +285,24 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "false" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "false" + } ] }, "keybindings": [ diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index c4fa00f479..606a1177f6 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -519,7 +519,10 @@ export const ExtensionStateContextProvider: React.FC<{ }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index dc33af38d1..572363e567 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -21,6 +21,14 @@ import { mergeExtensionState, createInitialExtensionState, } from "../ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + getViewStateId: vi.fn(() => "view-a"), + }, +})) const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -111,7 +119,96 @@ const InitialStateTestComponent = () => { ) } +const ViewLocalStateTestComponent = () => { + const { mode, setMode, currentApiConfigName, setCurrentApiConfigName } = useExtensionState() + + return ( +
+
{mode}
+
{currentApiConfigName}
+ + +
+ ) +} + describe("ExtensionStateContext", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts webviewDidLaunch with the stable viewStateId from vscode API", () => { + render( + + + , + ) + + expect(vscode.getViewStateId).toHaveBeenCalled() + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch", viewStateId: "view-a" }) + }) + + it("posts webviewDidLaunch without a viewStateId when getViewStateId is unavailable", () => { + const savedGetViewStateId = vscode.getViewStateId + Object.defineProperty(vscode, "getViewStateId", { configurable: true, value: undefined }) + try { + render( + + + , + ) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch", viewStateId: undefined }) + } finally { + Object.defineProperty(vscode, "getViewStateId", { configurable: true, value: savedGetViewStateId }) + } + }) + + it("reseeds view-local mode and API profile from a new state payload after local edits", () => { + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "code", currentApiConfigName: "profile-a", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("code") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-a") + + act(() => { + screen.getByTestId("set-local-mode").click() + screen.getByTestId("set-local-api-config").click() + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("ask") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("local-profile") + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "architect", currentApiConfigName: "profile-b", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("architect") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-b") + }) + it("initializes with empty allowedCommands array", () => { render( diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..6153e07fd4 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,365 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +// Minimal Storage surface for VSCodeAPIWrapper browser fallback tests. Typed +// precisely (instead of casting to Storage) so each double only promises the +// members the wrapper actually touches. +interface MockStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + clear(): void +} + +const createMockStorage = (initialState: Record = {}): MockStorage => { + const state = { ...initialState } + return { + getItem: vi.fn((key: string) => state[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + state[key] = value + }), + removeItem: vi.fn((key: string) => { + delete state[key] + }), + clear: vi.fn(() => { + for (const key of Object.keys(state)) { + delete state[key] + } + }), + } +} + +describe("VSCodeAPIWrapper", () => { + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }) + }) + + it("reuses the persisted webview viewStateId when browser storage is available", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "persisted-view" }) }), + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("persisted-view") + }) + + it("creates and persists a new viewStateId when storage has been cleared", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("falls back to in-memory state when browser storage access is restricted", () => { + const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const storage: MockStorage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + removeItem: vi.fn(() => { + throw new Error("storage denied") + }), + clear: vi.fn(() => { + throw new Error("storage denied") + }), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(randomUUID).toHaveBeenCalledTimes(1) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.setItem).toHaveBeenCalled() + }) + + it("falls back to a timestamp-random id when crypto.randomUUID is unavailable", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: {}, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto global is undefined", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: undefined, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto object lacks randomUUID", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { "": 1 }, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // A truthy crypto global without a randomUUID member must still take the + // deterministic fallback: 1700000000000.toString(36) === "loyw3v28" and + // (0.987654321).toString(36) === "0.zk00000ytu", so the id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("creates a new viewStateId when the stored state parses to JSON null", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "after-null-view") }, + }) + const storage = createMockStorage({ vscodeState: "null" }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("after-null-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "after-null-view" }) + }) + + it("replaces an empty persisted viewStateId with a freshly created one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "refilled-view" }) + }) + + it("replaces a non-object persisted state with a freshly created viewStateId", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "replaced-string-view") }, + }) + // A persisted JSON string is truthy but not an object: the guard must keep it out of + // the fresh state, so the persisted record contains only the new viewStateId. + const storage = createMockStorage({ vscodeState: JSON.stringify("stale-string-state") }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("replaced-string-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "replaced-string-view" }) + }) + + it("returns the normalized id when the persisted id needs the extension-side cleanup", () => { + const randomUUID = vi.fn(() => "should-not-be-generated") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // ClineProvider.setViewStateId trims and rewrites unsafe characters, so the + // webview must report the same normalized id instead of the raw stored value. + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " side 1 " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("side_1") + expect(wrapper.getViewStateId()).toBe("side_1") + expect(randomUUID).not.toHaveBeenCalled() + }) + + it("generates a fresh id when the persisted id is whitespace-only", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "ws-refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("ws-refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "ws-refilled-view" }) + }) + + it("generates a fresh id when the persisted id is the __proto__ string", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "proto-refused-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "__proto__" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("proto-refused-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "proto-refused-view" }) + }) + + it("keeps one generated id when a write fails while stale persisted state stays readable", () => { + const randomUUID = vi.fn().mockReturnValueOnce("gen-one").mockReturnValueOnce("gen-two") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // Storage still serves a stale persisted state (e.g. pre-identity webview state + // without a viewStateId) while setItem throws: without the write-failure flag, + // every getViewStateId call would re-read the stale JSON and generate a fresh id. + const storage: MockStorage = { + getItem: vi.fn(() => JSON.stringify({ mode: "architect" })), + setItem: vi.fn(() => { + throw new Error("write denied") + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + const first = wrapper.getViewStateId() + const second = wrapper.getViewStateId() + + expect(first).toBe("gen-one") + expect(second).toBe("gen-one") // no flap: the in-memory state stays authoritative + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "gen-one" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("keeps the in-memory viewStateId when setItem is unavailable while reads still work", () => { + const randomUUID = vi.fn().mockReturnValueOnce("no-setitem-view").mockReturnValueOnce("flapped-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + const storage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + // The persisted JSON never gained the id: the in-memory state stays authoritative + // instead of re-reading the stale JSON on every call. + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "no-setitem-view" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("treats persisted storage as authoritative again once a write recovers", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "recovered-view") }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + let writesFail = true + const storage: MockStorage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + if (writesFail) { + throw new Error("write denied") + } + backing[key] = value + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // The first generation cannot be persisted: the in-memory state is authoritative. + expect(wrapper.getViewStateId()).toBe("recovered-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "recovered-view" }) + + // A later write succeeds: the flag clears and the persisted JSON is + // authoritative again for reads. + writesFail = false + wrapper.setState({ mode: "code", viewStateId: "recovered-view" }) + expect(backing.vscodeState).toBe(JSON.stringify({ mode: "code", viewStateId: "recovered-view" })) + // An external writer refreshes the persisted record: a recovered wrapper must see + // it (flag cleared); a still-flagged wrapper would keep the in-memory copy. + backing.vscodeState = JSON.stringify({ mode: "code", viewStateId: "recovered-view", external: true }) + expect(wrapper.getState()).toEqual({ mode: "code", viewStateId: "recovered-view", external: true }) + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..05dd380dfe 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -11,8 +11,13 @@ import { WebviewMessage } from "@roo/WebviewMessage" * dev server by using native web browser features that mock the functionality * enabled by acquireVsCodeApi. */ -class VSCodeAPIWrapper { +export class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private fallbackState: unknown | undefined + // Once a persistent-storage write fails (setItem throws or is unavailable), + // the persisted JSON is stale: the in-memory fallbackState is authoritative + // for getState() until a write succeeds again. + private storageWriteFailed: boolean = false constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -22,6 +27,46 @@ class VSCodeAPIWrapper { } } + /** + * Generates a unique identifier for this webview instance. + * + * @remarks Used only when no persisted identifier exists yet. + */ + private createViewStateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + /** + * Returns the stable view state identifier for this webview, creating and persisting + * one on first use so the extension can keep per-view state isolated across providers. + */ + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + // Mirror the ClineProvider.setViewStateId normalization so the webview never + // registers an id the extension would rewrite or reject: trim, replace unsafe + // characters, and drop whitespace-only and "__proto__" values. + const normalizedViewStateId = + typeof existingViewStateId === "string" ? existingViewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") : "" + + if (normalizedViewStateId && normalizedViewStateId !== "__proto__") { + return normalizedViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,10 +94,25 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { - const state = localStorage.getItem("vscodeState") - return state ? JSON.parse(state) : undefined } + + if (this.storageWriteFailed) { + // A previous write could not be persisted: reading localStorage would + // return stale JSON, so the in-memory fallback is authoritative. + return this.fallbackState + } + + try { + // Stryker disable next-line ConditionalExpression,OptionalChaining: equivalent mutant - when localStorage is unavailable the guard-false path and the throwing body both return this.fallbackState from this catch + if (typeof localStorage?.getItem === "function") { + const state = localStorage.getItem("vscodeState") + return state ? JSON.parse(state) : this.fallbackState + } + } catch { + return this.fallbackState + } + + return this.fallbackState } /** @@ -69,10 +129,25 @@ class VSCodeAPIWrapper { public setState(newState: T): T { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) - return newState } + + this.fallbackState = newState + + try { + // Stryker disable next-line ConditionalExpression,OptionalChaining: equivalent mutant - when localStorage is unavailable the guard-false path and the throwing body both mark the write failed and return newState + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + this.storageWriteFailed = false + } else { + this.storageWriteFailed = true + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + this.storageWriteFailed = true + } + + return newState } }