diff --git a/.gitignore b/.gitignore index 3961778d5e..60c3749fb5 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ qdrant_storage/ plans/ roo-cli-*.tar.gz* +.merge-* +.zdt-* diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index f2b2e7e8e5..b7e6270d55 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, + SECRET_STATE_KEYS, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, GLOBAL_SETTINGS_KEYS, globalSettingsSchema, @@ -22,6 +23,13 @@ describe("destructive command guard global setting", () => { }) }) +describe("provider api-key secret registration", () => { + it("stores mimo and poe api keys in secret storage, not plaintext global state", () => { + expect(SECRET_STATE_KEYS).toContain("mimoApiKey") + expect(SECRET_STATE_KEYS).toContain("poeApiKey") + }) +}) + describe("alwaysDenyUnapprovedCommands global setting", () => { it("is opt-in by default", () => { expect(DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS).toBe(false) @@ -39,6 +47,35 @@ describe("alwaysDenyUnapprovedCommands global setting", () => { }) }) +describe("viewStates global setting", () => { + it("parses a valid per-view record", () => { + const parsed = globalSettingsSchema.parse({ + viewStates: { + "stable-view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1730000000000 }, + "stable-view-2": {}, + }, + }) + expect(parsed.viewStates?.["stable-view-1"]).toEqual({ + mode: "code", + currentApiConfigName: "profile-a", + updatedAt: 1730000000000, + }) + expect(parsed.viewStates?.["stable-view-2"]).toEqual({}) + expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates") + }) + + it("rejects invalid field values inside a view entry", () => { + expect(() => globalSettingsSchema.parse({ viewStates: { v: { mode: 42 } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { currentApiConfigName: 42 } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { updatedAt: "soon" } } })).toThrow() + }) + + it("rejects non-object viewStates values", () => { + expect(() => globalSettingsSchema.parse({ viewStates: "not-a-record" })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: "not-an-entry" } })).toThrow() + }) +}) + describe("collapseCommandOutputByDefault global setting", () => { it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => { const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value }) diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index e64406fa9c..bc0fb97a94 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -2,7 +2,7 @@ import type { EventEmitter } from "events" import type { Socket } from "net" import type { RooCodeEvents } from "./events.js" -import type { RooCodeSettings } from "./global-settings.js" +import type { GlobalState, RooCodeSettings } from "./global-settings.js" import type { HistoryItem } from "./history.js" import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js" import type { IpcMessage, IpcServerEvents } from "./ipc.js" @@ -21,6 +21,7 @@ export interface RooCodeAPI extends EventEmitter { * Starts a new task with an optional initial message and images. * @param task Optional initial task message. * @param images Optional array of image data URIs (e.g., "data:image/webp;base64,..."). + * @param preserveOpenTabs When true, skips the new-tab editor cleanup (revert + close-all), preserving open tabs and dirty/unsaved editors. * @returns The ID of the new task. */ startNewTask({ @@ -28,11 +29,13 @@ export interface RooCodeAPI extends EventEmitter { text, images, newTab, + preserveOpenTabs, }: { configuration?: RooCodeSettings text?: string images?: string[] newTab?: boolean + preserveOpenTabs?: boolean }): Promise /** * Resumes a task with the given ID. @@ -128,7 +131,9 @@ export interface RooCodeAPI extends EventEmitter { */ getConfiguration(): RooCodeSettings /** - * Sets the configuration for the current task. + * Sets the configuration for the extension. Writes target the extension-host (sidebar) view: + * view-local values are pinned to the sidebar's per-view state, while global values are + * written to the shared ContextProxy. * @param values An object containing key-value pairs to set. */ setConfiguration(values: RooCodeSettings): Promise @@ -195,6 +200,52 @@ export interface RooCodeAPI extends EventEmitter { setTerminalProfile(name: string | undefined): void } +/** + * Test-only extension API surface. + * + * These methods expose raw global-state reads and task ask control by ID that + * production callers must not rely on. The implementation is gated at runtime + * against VS Code ExtensionMode.Production, so only test hosts and the e2e + * harness keep access. + */ +/** + * GlobalState keys addressable through the test-only API surface. + * + * `taskHistory` is excluded at the type level because it carries user PII and the + * runtime refuses it for both accessors. Credential-bearing keys are still refused + * at runtime (a key union cannot express the resolution), and `setGlobalState` + * additionally requires the key to have a global-settings value schema. + */ +export type TestableGlobalStateKey = Exclude + +export interface RooCodeTestOnlyApi { + /** + * Programmatically approves the pending ask for a task by ID. Intended for use in tests only. + */ + approveTaskAsk(taskId: string): Promise + /** + * Programmatically denies the pending ask for a task by ID. Intended for use in tests only. + */ + denyTaskAsk(taskId: string): Promise + /** + * Simulates selecting a follow-up suggestion for a task by ID, including its optional mode switch. + * Intended for use in tests only. + */ + selectTaskFollowupSuggestion(options: { taskId: string; answer: string; mode?: string }): Promise + /** + * GlobalState keys the test-only accessors can address. `taskHistory` is excluded + * because it carries user PII and both runtime methods reject it; credential-bearing + * keys are rejected at runtime as well, which a key union cannot express. + */ + getGlobalState(key: K): GlobalState[K] + /** + * Writes a value to VS Code globalState. Intended for use in tests only. The + * implementation additionally requires the key to have a global-settings value + * schema and rejects credential-bearing keys and `taskHistory` at runtime. + */ + setGlobalState(key: K, value: GlobalState[K]): Promise +} + export interface RooCodeTestAPI extends RooCodeAPI { captureWebviewThemeFixture(): Promise getLatestAnnouncementId(): string diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..dc7492bfe8 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -114,6 +114,15 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Persisted non-secret selections for a stable webview instance. + */ +export const viewStateSchema = z.object({ + mode: z.string().optional(), + currentApiConfigName: z.string().optional(), + updatedAt: z.number().optional(), +}) + /** * GlobalSettings */ @@ -122,6 +131,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), @@ -368,6 +378,8 @@ export const SECRET_STATE_KEYS = [ "ioIntelligenceApiKey", "nanoGptApiKey", "basetenApiKey", + "mimoApiKey", + "poeApiKey", ] as const // Global secrets that are part of GlobalSettings (not ProviderSettings) @@ -387,6 +399,26 @@ export type SecretState = Pick => SECRET_STATE_KEYS.includes(key as ProviderSecretKey) || GLOBAL_SECRET_KEYS.includes(key as GlobalSecretKey) +/** + * Credential-bearing provider settings that do not use an *ApiKey name shape: + * header maps that can carry Authorization values, session tokens, service + * account JSON, and credential-file paths. They must be excluded from every + * flattened configuration surface alongside SECRET_STATE_KEYS. + */ +export const SENSITIVE_PROVIDER_CONFIG_KEYS = [ + "openAiHeaders", + "vertexKeyFile", + "vertexJsonCredentials", + "zooSessionToken", + "mimoApiKey", + "poeApiKey", + "qwenCodeOauthPath", + "geminiCliOAuthPath", +] as const + +export const isSensitiveProviderConfigKey = (key: string): boolean => + (SENSITIVE_PROVIDER_CONFIG_KEYS as readonly string[]).includes(key) + /** * GlobalState */ diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a5393ddc66..f08688cbea 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -669,6 +669,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..e8739d7f1e 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,11 +1,22 @@ 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 { + __getLiveTabPanelCountForTests, + __resetLiveTabPanelsForTests, + getPanel, + getVisibleProviderOrLog, + openClineInNewTab, + registerCommands, + setPanel, +} from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -137,11 +148,20 @@ 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(() => { vi.clearAllMocks() + // clearAllMocks() keeps mock implementations: reset the instance-lookup + // return value so a preceding test's tab double cannot leak into the + // dead-tab drop assertions below. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) handlers = {} mockOutputChannel = { @@ -163,6 +183,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 +264,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 +444,166 @@ 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 logs the drop when a tab panel is tracked without a live tab instance", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + await handlers["zoo-code.focusInput"]() + + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance the action is dropped and the + // drop is logged so the silent no-op stays diagnosable. + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "focusInput: no live provider for the tracked tab panel; action dropped", + ) + }) + + it("focusInput posts the focus message on the tab instance when a tab panel is tracked", async () => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + await handlers["zoo-code.focusInput"]() + // The tab takes selection priority: assert it was selected by identity + // (reference, not structural equality). + expect((ClineProvider.getInstanceForView as Mock).mock.calls[0]![0]).toBe(tabPanel) + // No error was logged on the success path. + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - // Representative coverage for the .catch arm on all five void-prefixed - // postMessageToWebview sites in registerCommands.ts (settingsButtonClicked - // posts twice, plus historyButtonClicked, marketplaceButtonClicked, and - // acceptInput). Each handler is synchronous, so the .catch arm runs on a - // microtask; setImmediate ensures all microtasks are flushed before we assert. The + it("setPanel keeps independent refs: clearing only the tab ref re-enables the sidebar post", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + // The tab ref does not wipe the sidebar ref... (the dead-tab drop is + // logged, and the sidebar still must not receive the message). + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "focusInput: no live provider for the tracked tab panel; action dropped", + ) + + // ...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 +621,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" }) }) }) @@ -459,6 +692,7 @@ describe("openClineInNewTab", () => { // Reset module-level panel state. setPanel(undefined, "sidebar") setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() }) it("creates a webview panel with title 'Zoo Code'", async () => { @@ -473,6 +707,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 +720,681 @@ describe("openClineInNewTab", () => { }), ) }) + + it("reveals the existing tab instead of creating a second panel", async () => { + const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + }) + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockExistingProvider) + + const result = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + expect(result).toBe(mockExistingProvider) + expect(mockPanel.reveal).toHaveBeenCalledTimes(1) + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + expect(mockExistingProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + }) + + it("creates a new tab panel when the tracked tab's provider has been disposed", async () => { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + }) + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The reuse path must resolve the tracked panel (not skip the lookup): + // without this assertion the test would also pass if the handler + // stopped consulting getInstanceForView at all. + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(mockPanel) + expect(mockPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("re-points the tracked tab ref at the panel that becomes active", async () => { + // Panel A is created first and tracked... + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // ...then panel B is created, which re-points the tracked tab ref. + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // Activating A must reassign the tracked tab ref to A's panel... + const stateChange = (panelA.onDidChangeViewState as Mock).mock.calls[0]![0] as (e: { + webviewPanel: vscode.WebviewPanel + }) => void + // Activate panelA in place and pass it through: the production handler + // tracks e.webviewPanel directly, so a clone would let a handler that + // copies the event panel still pass the identity check below. + Object.assign(panelA, { active: true, visible: true }) + stateChange({ webviewPanel: panelA }) + + // ...so plusButtonClickedInTab targets A's provider, not B's. + const mockProviderA = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + // Require the tracked panel by identity: a handler that clones the + // state-change panel can no longer resolve the provider. + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + view === panelA ? mockProviderA : undefined, + ) + const handlers = new Map unknown>() + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + handlers.set(id, cb) + return { dispose: vi.fn() } + }, + ) + const mockSidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: mockSidebarProvider, + }) + + await handlers.get("zoo-code.plusButtonClickedInTab")!() + + expect(mockProviderA.evictCurrentTask).toHaveBeenCalledTimes(1) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { + ;(MdmService.getInstance as Mock).mockImplementation(() => { + throw new Error("MDM service not initialized") + }) + + const provider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // The creation must survive the MDM lookup failure: the provider is + // constructed with an undefined MDM service and the tab panel is + // still created. + const ctor = vi.mocked(ClineProvider) + expect(ctor.mock.instances[0]).toBeDefined() + expect(ctor).toHaveBeenCalledWith( + mockContext, + mockOutputChannel, + "editor", + undefined, + expect.any(WebviewFocusTracker), + undefined, + ) + expect(provider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The fallback is observable in the output channel. + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + "[openClineInNewTab] MDM service unavailable, continuing without it: Error: MDM service not initialized", + ) + }) + + it("opens a new group to the right and targets 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("re-points the tracked tab ref at the remaining live panel when the tracked panel closes", async () => { + // Panel A is created and tracked first... + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + // ...then panel B opens in a different group: B becomes active, so the + // state-change handler re-points the tracked ref at B. + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + Object.assign(panelB, { active: true, visible: true }) + ;(panelB.onDidChangeViewState as Mock).mock.calls[0]![0]!({ webviewPanel: panelB }) + expect(getPanel()).toBe(panelB) + + // Closing the tracked panel (B) emits no state change for A, so the dispose + // handler alone must re-point the tracked ref at the remaining live panel. + Object.assign(panelA, { visible: true }) + const disposeB = (panelB.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeB() + expect(getPanel()).toBe(panelA) + + // The re-pointed panel must actually serve the tab-scoped commands: the + // command handler resolves the provider from the tracked panel, so panel + // A's provider receives the message even though B was created last. + 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", + }) + + // Closing the last live tab clears the ref again. + const disposeA = (panelA.onDidDispose as Mock).mock.calls[0]![0] as () => void + disposeA() + expect(getPanel()).toBeUndefined() + }) + + it("re-points the tracked tab at the best remaining live panel on close", async () => { + const makePanel = (marker: string) => + Object.assign({} as vscode.WebviewPanel, { + marker, + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }) + const openThree = async (p1: vscode.WebviewPanel, p2: vscode.WebviewPanel, p3: vscode.WebviewPanel) => { + ;(vscode.window.createWebviewPanel as Mock) + .mockReturnValueOnce(p1) + .mockReturnValueOnce(p2) + .mockReturnValueOnce(p3) + // A preceding test's instance-lookup double must not leak into the reuse + // path: each open here creates a fresh panel. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + // The last created panel becomes the tracked tab. + ;(p3.onDidChangeViewState as Mock).mock.calls[0]![0]!({ webviewPanel: p3 }) + expect(getPanel()).toBe(p3) + return p3 + } + const disposeTracked = (p: vscode.WebviewPanel) => { + const handler = (p.onDidDispose as Mock).mock.calls[0]![0] as () => void + handler() + } + + // Phase 1 — an active remaining panel wins over a visible one, even + // while the active panel itself is not visible: during a view-state + // transition active and visible can diverge, and the active panel is + // the one the user was last looking at, so it must win the re-pointing. + const panelA = makePanel("phase-1-A") + const panelD = makePanel("phase-1-D") + const panelC = makePanel("phase-1-C") + await openThree(panelA, panelD, panelC) + Object.assign(panelA, { active: true, visible: false }) + Object.assign(panelD, { active: false, visible: true }) + disposeTracked(panelC) + expect(getPanel()).toBe(panelA) + + // Phase 2 — a visible remaining panel wins over a hidden one. + __resetLiveTabPanelsForTests() + setPanel(undefined, "tab") + const panelE = makePanel("phase-2-E") + const panelV = makePanel("phase-2-V") + const panelC2 = makePanel("phase-2-C") + await openThree(panelE, panelV, panelC2) + Object.assign(panelE, { active: false, visible: false }) + Object.assign(panelV, { active: false, visible: true }) + disposeTracked(panelC2) + expect(getPanel()).toBe(panelV) + + // Phase 3 — with no active or visible remaining panel, the first remaining + // panel keeps the tracked ref: a panel can be open but hidden behind another + // editor group (neither active nor visible), and dropping the ref here would + // let the next open create a second panel instead of revealing it. + __resetLiveTabPanelsForTests() + setPanel(undefined, "tab") + const panelH1 = makePanel("phase-3-H1") + const panelH2 = makePanel("phase-3-H2") + const panelC3 = makePanel("phase-3-C") + await openThree(panelH1, panelH2, panelC3) + Object.assign(panelH1, { active: false, visible: false }) + Object.assign(panelH2, { active: false, visible: false }) + disposeTracked(panelC3) + expect(getPanel()).toBe(panelH1) + }) + + it("serializes concurrent opens so overlapping calls create one panel and share one provider", async () => { + const [first, second] = await Promise.all([ + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ]) + + // Overlapping "Open in editor" calls must share the in-flight + // creation: exactly one tab panel is created and both callers receive + // the same constructed provider. Pinning both results against the + // mocked constructor (not just against each other) keeps the test + // failing if the shared result is undefined. + const ctor = vi.mocked(ClineProvider) + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("shares one in-flight creation when openInNewTab and popoutButtonClicked start before it resolves", async () => { + // Defer the first creation at ContextProxy.getInstance so both command + // handlers can start while the creation is still in flight. + let resolveContextProxy!: () => void + ;(ContextProxy.getInstance as Mock).mockReturnValue( + new Promise((resolve) => { + resolveContextProxy = resolve + }), + ) + + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + const sidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: sidebarProvider, + }) + + const started = [commandHandlers["zoo-code.openInNewTab"](), commandHandlers["zoo-code.popoutButtonClicked"]()] + + // While the shared creation is suspended at ContextProxy.getInstance, + // neither caller has created a panel yet. + expect(vscode.window.createWebviewPanel).not.toHaveBeenCalled() + + resolveContextProxy() + const [first, second] = await Promise.all(started) + + // Both command entry points await the shared in-flight creation: + // exactly one tab panel is created and both results are the same + // constructed provider. + const ctor = vi.mocked(ClineProvider) + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("creates a fresh panel for a new call once the previous creation settled and its provider disposed", async () => { + // The first open settles and tracks its panel. + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + + // The tracked provider is disposed, so the next open cannot reuse the + // existing tab: the settled (and cleared) in-flight promise must not + // be returned, and a fresh panel is created. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + const secondProvider = await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + + const ctor = vi.mocked(ClineProvider) + const second = ctor.mock.instances[1] + expect(second).toBeDefined() + expect(secondProvider).toBe(second) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + }) + + it("keeps the replacement panel tracked when a stale panel's disposal fires late", async () => { + // Capture each created panel so the first panel's (stale) dispose + // handler can fire after the replacement is already tracked. + const createdPanels: { onDidDispose: Mock }[] = [] + ;(vscode.window.createWebviewPanel as Mock).mockImplementation(() => { + const panel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + createdPanels.push(panel) + return panel + }) + + // First open creates and tracks panel A. + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(getPanel()).toBe(createdPanels[0]) + + // Panel A's provider is disposed before the second open, so the + // second open creates the replacement panel B. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + await openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(getPanel()).toBe(createdPanels[1]) + + // Panel A's stale dispose handler fires after the replacement is + // tracked; it must not clobber the replacement's ref. + createdPanels[0].onDidDispose.mock.calls[0][0]() + + expect(getPanel()).toBe(createdPanels[1]) + + // Tab-surface commands still reach the provider that owns the + // replacement panel after the stale disposal. + const replacementProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(replacementProvider) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + await commandHandlers["zoo-code.historyButtonClickedInTab"]() + expect(replacementProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "historyButtonClicked", + }) + }) +}) + +describe("openClineInNewTab initialization failure", () => { + let mockOutputChannel: vscode.OutputChannel + let mockContext: vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + hide: vi.fn(), + name: "mock", + replace: vi.fn(), + show: vi.fn(), + dispose: vi.fn(), + } + + mockContext = { + subscriptions: [], + extensionUri: { path: "/mock/ext" }, + } as unknown as vscode.ExtensionContext + + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(mockPanel) + + // Reset module-level panel state. + setPanel(undefined, "sidebar") + setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() + }) + + it("cleans up the tracked refs and disposes the provider when initialization fails", async () => { + const resolveSpy = vi + .spyOn(ClineProvider.prototype, "resolveWebviewView") + .mockRejectedValue(new Error("init failed")) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockResolvedValue(undefined) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("init failed") + + // The half-registered panel must not stay tracked: the live registry + // drops it, the tracked tab ref is cleared (identity-guarded), and the + // provider is disposed so the failed surface leaves no listeners or + // state behind. + expect(__getLiveTabPanelCountForTests()).toBe(0) + expect(getPanel()).toBeUndefined() + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 9ee3443f9a..2020029a4c 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import delay from "delay" -import type { CommandId } from "@roo-code/types" +import type { CommandId, ExtensionMessage } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { getCommand } from "../utils/commands" @@ -30,16 +30,54 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl let sidebarPanel: vscode.WebviewView | undefined = undefined let tabPanel: vscode.WebviewPanel | undefined = undefined +// Tab panels that are still open. When the tracked panel closes, its dispose +// handler re-points the tracked ref at one of these: the remaining panel may +// not emit onDidChangeViewState (its own visibility did not change), so +// without this the tab-scoped title-bar commands and focusInput would be left +// without a target. +const liveTabPanels = new Set() + +/** + * Test-only: clears the module-level registry so tab panels created by one + * spec cannot leak into another's tracked-panel re-pointing. + * @internal Exported for focused implementation tests. + */ +export function __resetLiveTabPanelsForTests(): void { + liveTabPanels.clear() +} + +/** + * Test-only: the number of tab panels currently in the live registry, so a + * spec can assert the cleanup of a failed or closed panel without probing + * through a close of a live panel. + * @internal Exported for focused implementation tests. + */ +export function __getLiveTabPanelCountForTests(): number { + return liveTabPanels.size +} + +// In-flight "open in editor" creation shared by overlapping calls: a +// double-click starts before the first call tracks its new panel, so +// concurrent callers must share one creation instead of racing to create +// two tab panels. +let pendingTabPanelCreation: Promise | undefined + /** * Get the currently active panel * @returns WebviewPanel或WebviewView + * @internal Exported for focused implementation tests. */ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined { return tabPanel || sidebarPanel } /** - * Set panel references + * Set panel references. + * + * The two refs are independent: each surface keeps its own ref for its whole + * lifetime, so resolving the sidebar view never wipes a live tab panel (and + * vice versa). Callers pass `undefined` only when the surface itself is + * disposed (see the `onDidDispose` wiring in `openClineInNewTab`). */ export function setPanel( newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, @@ -47,13 +85,22 @@ export function setPanel( ): void { if (type === "sidebar") { sidebarPanel = newPanel as vscode.WebviewView - tabPanel = undefined } else { tabPanel = newPanel as vscode.WebviewPanel - sidebarPanel = undefined } } +/** + * The instance that owns the tracked tab panel, if it is still alive. + * + * Title-bar commands on the editor-tab surface use this instead of the + * visible-instance heuristic, so a click on the tab's title bar always + * targets that tab even when the sidebar is visible side-by-side. + */ +function getTabProvider(): ClineProvider | undefined { + return tabPanel ? ClineProvider.getInstanceForView(tabPanel) : undefined +} + export type RegisterCommandOptions = { context: vscode.ExtensionContext outputChannel: vscode.OutputChannel @@ -83,27 +130,58 @@ export const registerCommands = (options: RegisterCommandOptions) => { // `filePath?: string`, others take none) and VS Code dispatches positional // args dynamically. type CommandCallback = (...args: any[]) => unknown + +// Posts each action in order to the target instance. Failures are logged +// (not thrown) with the handler-specific prefix so a failed post stays +// attributable in the output channel. +const postActions = ( + outputChannel: vscode.OutputChannel, + target: ClineProvider, + actions: readonly NonNullable[], + logPrefix: string, +) => { + for (const action of actions) { + void target + .postMessageToWebview({ type: "action", action }) + .catch((error) => outputChannel.appendLine(`[${logPrefix}] postMessageToWebview failed: ${error}`)) + } +} + const getCommandsMap = ({ context, outputChannel, provider, }: RegisterCommandOptions): Record, CommandCallback> => ({ activationCompleted: () => {}, + // The `view/title` menu is scoped to the sidebar view, so the click + // origin of these handlers is the sidebar provider wired in at + // activation (`provider`). Target it directly instead of the + // visible-instance heuristic, which would follow the user's focus to a + // tab instance when both surfaces are open side-by-side. The `*InTab` + // variants serve the `editor/title` menu and target the tab instance + // through `getTabProvider()` instead. plusButtonClicked: async () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("plus") - if (!visibleProvider) { + await provider.evictCurrentTask() + await provider.refreshWorkspace() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + // Send focusInput action immediately after chatButtonClicked + // This ensures the focus happens after the view has switched + await provider.postMessageToWebview({ type: "action", action: "focusInput" }) + }, + plusButtonClickedInTab: async () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("plus") - await visibleProvider.evictCurrentTask() - await visibleProvider.refreshWorkspace() - await visibleProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - // Send focusInput action immediately after chatButtonClicked - // This ensures the focus happens after the view has switched - await visibleProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + await tabProvider.evictCurrentTask() + await tabProvider.refreshWorkspace() + await tabProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) }, popoutButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("popout") @@ -113,43 +191,50 @@ const getCommandsMap = ({ openInNewTab: () => openClineInNewTab({ context, outputChannel, webviewFocusTracker: provider.webviewFocusTracker }), settingsButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("settings") - if (!visibleProvider) { + // Also explicitly post the visibility message to trigger scroll reliably. + postActions(outputChannel, provider, ["settingsButtonClicked", "didBecomeVisible"], "settingsButtonClicked") + }, + settingsButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("settings") - void visibleProvider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) - // Also explicitly post the visibility message to trigger scroll reliably - void visibleProvider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + postActions( + outputChannel, + tabProvider, + ["settingsButtonClicked", "didBecomeVisible"], + "settingsButtonClickedInTab", + ) }, historyButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("history") - if (!visibleProvider) { + postActions(outputChannel, provider, ["historyButtonClicked"], "historyButtonClicked") + }, + historyButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("history") - void visibleProvider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + postActions(outputChannel, tabProvider, ["historyButtonClicked"], "historyButtonClickedInTab") }, marketplaceButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) - if (!visibleProvider) return - void visibleProvider - .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, provider, ["marketplaceButtonClicked"], "marketplaceButtonClicked") + }, + marketplaceButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { + return + } + postActions(outputChannel, tabProvider, ["marketplaceButtonClicked"], "marketplaceButtonClickedInTab") }, newTask: (params: { prompt?: string } | null | undefined) => handleNewTask(params, provider.webviewFocusTracker), setCustomStoragePath: async () => { @@ -176,8 +261,22 @@ const getCommandsMap = ({ try { await focusPanel(tabPanel, sidebarPanel) - // Send focus input message only for sidebar panels - if (sidebarPanel && getPanel() === sidebarPanel) { + // Post to the surface focusPanel selected: the tab takes + // selection priority, so the sidebar is targeted only when no + // tab panel is tracked. + if (tabPanel) { + const tabProvider = getTabProvider() + if (tabProvider) { + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + } else { + // The tracked tab panel has no live provider (its instance was + // disposed while the panel ref survived). focusPanel already + // revealed the tab surface, so posting to the sidebar would + // focus a surface that is not on screen: log the drop instead + // of swallowing it silently. + outputChannel.appendLine("focusInput: no live provider for the tracked tab panel; action dropped") + } + } else if (sidebarPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -226,7 +325,53 @@ type OpenClineInNewTabOptions = { webviewFocusTracker: WebviewFocusTracker } -export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTracker }: OpenClineInNewTabOptions) => { +export const openClineInNewTab = async (options: OpenClineInNewTabOptions) => { + // Serialize overlapping "Open in editor" calls: a double-click starts + // before the first call tracks its new panel, so without a shared + // in-flight creation both calls would race to create two tab panels. + // The shared promise is stored before the creation body awaits + // ContextProxy.getInstance, so every caller started while the creation + // is in flight — openInNewTab and popoutButtonClicked both dispatch + // through here — awaits it instead of creating a second panel: exactly + // one panel is created and every caller receives the same provider. + if (pendingTabPanelCreation) { + return pendingTabPanelCreation + } + + const creation = createTabPanelUnlocked(options) + pendingTabPanelCreation = creation + + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path in createTabPanelUnlocked then takes over + // for the tracked panel. Guard the clear so this settlement cannot + // clobber a replacement already stored in the slot. That clobber is + // unreachable in single-threaded settlement order: while the slot + // holds this in-flight creation, every other caller receives that + // same promise (guard above), so no replacement can be stored before + // this finally block runs — the equality check pins the invariant. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined + } + } +} + +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTracker }: OpenClineInNewTabOptions) => { + // Reuse the tracked tab instead of opening a second one: a repeated + // "Open in editor" click reveals the existing tab's panel. + if (tabPanel) { + const existingProvider = ClineProvider.getInstanceForView(tabPanel) + if (existingProvider) { + await tabPanel.reveal() + await existingProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + return existingProvider + } + } + // (This example uses webviewProvider activation event which is necessary to // deserialize cached webview, but since we use retainContextWhenHidden, we // don't need to use that event). @@ -237,8 +382,9 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr let mdmService: MdmService | undefined try { mdmService = MdmService.getInstance() - } catch { - // MDM service not initialized, which is fine - extension can work without it + } catch (error) { + // MDM service unavailable: log the fallback and continue without it. + outputChannel.appendLine(`[openClineInNewTab] MDM service unavailable, continuing without it: ${error}`) mdmService = undefined } @@ -270,6 +416,7 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr // Save as tab type panel. setPanel(newPanel, "tab") + liveTabPanels.add(newPanel) // TODO: Use better svg icon with light and dark variants (see // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). @@ -278,12 +425,21 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), } - await tabProvider.resolveWebviewView(newPanel) - + // Register the cleanup listeners before the first initialization await: a + // close that lands while resolveWebviewView is still running must be able + // to find the tracked-ref re-pointing already installed, or the panel + // would stay tracked (and the provider registered) after disposal. // Add listener for visibility changes to notify webview newPanel.onDidChangeViewState( (e) => { const panel = e.webviewPanel + // Re-point the tracked tab ref at the panel the user is actually + // looking at: several tab panels can stay visible at once, but + // only the active one is the current tab, and the title-bar + // commands must resolve that instance, not the last created one. + if (panel.active) { + setPanel(panel, "tab") + } if (panel.visible) { panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx } @@ -292,15 +448,47 @@ export const openClineInNewTab = async ({ context, outputChannel, webviewFocusTr context.subscriptions, // Register listener for disposal ) - // Handle panel closing events. + // Handle panel closing events: clear the tracked ref only if this panel + // is still the tracked one, so a late disposal of an already-replaced + // panel cannot clobber the replacement's ref. newPanel.onDidDispose( () => { - setPanel(undefined, "tab") + liveTabPanels.delete(newPanel) + if (tabPanel === newPanel) { + // Re-point the tracked ref at the best remaining live tab: an active + // panel first, then a visible one, then any remaining panel — a panel + // can be open but hidden behind another editor group (neither active + // nor visible), and clearing the tracked ref here would let the next + // open create a second panel instead of revealing the one already + // serving a task. The identity guard above keeps a late disposal of an + // already-replaced panel from clobbering the replacement's ref. + const remaining = + [...liveTabPanels].find((panel) => panel.active) || + [...liveTabPanels].find((panel) => panel.visible) || + [...liveTabPanels][0] + setPanel(remaining, "tab") + } }, null, context.subscriptions, // Also register dispose listener ) + try { + await tabProvider.resolveWebviewView(newPanel) + } catch (error) { + // Initialization failed: drop the half-registered panel from the live + // registry and the tracked ref (if this panel is still the tracked one), + // and dispose the provider so the failed surface leaves no listeners or + // state behind. The panel's own disposal listener (installed above) has + // already handled a user close that landed during initialization. + liveTabPanels.delete(newPanel) + if (tabPanel === newPanel) { + setPanel(undefined, "tab") + } + await tabProvider.dispose() + throw error + } + // 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") diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1250a7baca..62c1bd84ef 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 { @@ -43,6 +46,31 @@ export class ContextProxy { private stateCache: GlobalState private secretCache: SecretState + + // Per-key write tokens: a failed durable write rolls its cache value back only if it + // is still the current write for that key. The rollback target is always the durable + // record (see below), so the guard exists for one interleaving: a newer write that + // has already moved the cache but whose success is not confirmed yet, whose failure + // would otherwise be clobbered by the older write's rollback before the newer + // write's success record lands. + private stateWriteTokens = new Map() + private secretWriteTokens = new Map() + + // Tracks the last value confirmed to be in durable storage per key (seeded by + // initialize, refreshed by each confirmed write and refresh load, cleared by + // resetAllState), so a failed write rolls the cache back to the durable value + // instead of the cache value at call time — a superseded in-flight write may have + // already overwritten that value, and restoring it would leave the cache ahead of + // storage when the superseding write also fails. + private durableState = new Map() + private durableSecrets = new Map() + // Write-completion generation. A write issued before resetAllState (or + // refreshSecrets) may still be in flight when that reset/refresh re-seeds the + // durable records from storage: its value is then obsolete, and re-recording it + // would leave a later failed write rolling the cache back to a value storage no + // longer holds. Only completions from the current generation update the durable + // maps; resetAllState and refreshSecrets both advance the generation. + private resetGeneration = 0 private _isInitialized = false constructor(context: vscode.ExtensionContext) { @@ -61,6 +89,8 @@ export class ContextProxy { try { // Revert to original assignment this.stateCache[key] = this.originalContext.globalState.get(key) + // This load is a confirmed storage read: seed the rollback record. + this.durableState.set(key, this.stateCache[key]) } catch (error) { logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`) } @@ -70,6 +100,8 @@ export class ContextProxy { ...SECRET_STATE_KEYS.map(async (key) => { try { this.secretCache[key] = await this.originalContext.secrets.get(key) + // This load is a confirmed storage read: seed the rollback record. + this.durableSecrets.set(key, this.secretCache[key]) } catch (error) { logger.error( `Error loading secret ${key}: ${error instanceof Error ? error.message : String(error)}`, @@ -79,6 +111,8 @@ export class ContextProxy { ...GLOBAL_SECRET_KEYS.map(async (key) => { try { this.secretCache[key] = await this.originalContext.secrets.get(key) + // This load is a confirmed storage read: seed the rollback record. + this.durableSecrets.set(key, this.secretCache[key]) } catch (error) { logger.error( `Error loading global secret ${key}: ${error instanceof Error ? error.message : String(error)}`, @@ -89,6 +123,34 @@ export class ContextProxy { await Promise.all(promises) + // Migration: provider keys that moved from plaintext global state to secret + // storage keep working for users who configured them before the move: the + // value is relocated to secret storage and the plaintext copy is dropped. + for (const key of ["mimoApiKey", "poeApiKey"] as const) { + try { + const legacyValue = this.originalContext.globalState.get(key) + if (legacyValue === undefined) { + continue + } + // Preserve an existing secret: the plaintext copy is redundant once a + // secret exists, and a deletion that failed earlier must still be + // retried while the secret already wins reads. + if (this.secretCache[key] === undefined) { + await this.originalContext.secrets.store(key, legacyValue) + this.secretCache[key] = legacyValue + this.durableSecrets.set(key, legacyValue) + } + // Erase the plaintext only after the secret write succeeded (or was + // unnecessary): a failed store leaves the value for a retry instead of + // losing the user's key. + await this.originalContext.globalState.update(key, undefined) + } catch (error) { + logger.error( + `Error migrating legacy secret ${key}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + // Migration: Check for old nested image generation settings and migrate them await this.migrateImageGenerationSettings() @@ -283,7 +345,12 @@ export class ContextProxy { private async migrateImageGenerationSettings() { try { // Check if there's an old nested structure - const oldNestedSettings = this.originalContext.globalState.get("openRouterImageGenerationSettings") + // The pre-typed nested layout only ever carried these two fields (the + // migration below reads nothing else). + const oldNestedSettings = this.originalContext.globalState.get<{ + openRouterApiKey?: string + selectedModel?: string + }>("openRouterImageGenerationSettings") if (oldNestedSettings && typeof oldNestedSettings === "object") { logger.info("Migrating old nested image generation settings to flattened structure") @@ -365,8 +432,38 @@ export class ContextProxy { return this.originalContext.globalState.update(key, value) } + // Update the cache first so reads reflect the new value immediately, but restore + // the last durable value if the durable write fails so the cache cannot diverge + // from storage. The rollback uses the durable record (not the cache value at + // call time) because a superseded in-flight write may have overwritten that + // value since this write started; restoring it after its own failure would + // leave the cache ahead of storage. A key with no durable record yet can only + // hold undefined in its cache (no successful write or initialize has landed + // for it), so reading the record is exact in every reachable state. + const token = Symbol() + const writeGeneration = this.resetGeneration + this.stateWriteTokens.set(key, token) this.stateCache[key] = value - return this.originalContext.globalState.update(key, value) + return Promise.resolve(this.originalContext.globalState.update(key, value)) + .then(() => { + // The durable write landed: the cache value is now the durable value. + // Skip the record if a reset/refresh re-seeded the durable maps in the + // meantime: this write completed from before that re-seed, so its value + // is obsolete relative to the re-seeded storage. + if (this.resetGeneration !== writeGeneration) { + return + } + this.durableState.set(key, value) + }) + .catch((error) => { + // Only the write that is still current for this key may roll back: a newer + // write may have moved the cache and its success record is not in yet (see + // the field comment), so rolling back over it would lose that value. + if (this.stateWriteTokens.get(key) === token) { + this.stateCache[key] = this.durableState.get(key) as GlobalState[K] + } + throw error + }) } private getAllGlobalState(): GlobalState { @@ -383,13 +480,39 @@ export class ContextProxy { } storeSecret(key: SecretStateKey, value?: string) { - // Update cache. + // Update the cache first so reads reflect the new value immediately, but restore + // the last durable value if the durable write fails so the cache cannot diverge + // from storage (the durable record is exact in every reachable state: see + // updateGlobalState). + const token = Symbol() + const writeGeneration = this.resetGeneration + this.secretWriteTokens.set(key, token) this.secretCache[key] = value // Write directly to context. - return value === undefined - ? this.originalContext.secrets.delete(key) - : this.originalContext.secrets.store(key, value) + return Promise.resolve( + value === undefined + ? this.originalContext.secrets.delete(key) + : this.originalContext.secrets.store(key, value), + ) + .then(() => { + // The durable write (or delete) landed: the cache value is now the + // durable value. Skip the record if a reset/refresh re-seeded the + // durable maps in the meantime (see updateGlobalState). + if (this.resetGeneration !== writeGeneration) { + return + } + this.durableSecrets.set(key, value) + }) + .catch((error) => { + // Only the write that is still current for this key may roll back (see + // updateGlobalState): a newer write may have moved the cache and its + // success record is not in yet. + if (this.secretWriteTokens.get(key) === token) { + this.secretCache[key] = this.durableSecrets.get(key) as string | undefined + } + throw error + }) } /** @@ -397,10 +520,21 @@ export class ContextProxy { * This is useful when you need to ensure the cache has the latest values */ async refreshSecrets(): Promise { + // Invalidate writes issued before this refresh: once the re-seed below lands, + // their completions hold obsolete values that must not re-record over the + // freshly loaded durable records. + this.resetGeneration += 1 + // The refresh re-syncs the whole secret cache from storage and re-seeds the + // durable records with the loaded values, so a later rollback of a pending + // pre-refresh write restores the freshly loaded value (the in-write token + // guard still protects the interleaving where a newer write has moved the + // cache but its success is not confirmed yet). const promises = [ ...SECRET_STATE_KEYS.map(async (key) => { try { this.secretCache[key] = await this.originalContext.secrets.get(key) + // Freshly loaded value is the durable value: re-seed the rollback record. + this.durableSecrets.set(key, this.secretCache[key]) } catch (error) { logger.error( `Error refreshing secret ${key}: ${error instanceof Error ? error.message : String(error)}`, @@ -410,6 +544,8 @@ export class ContextProxy { ...GLOBAL_SECRET_KEYS.map(async (key) => { try { this.secretCache[key] = await this.originalContext.secrets.get(key) + // Freshly loaded value is the durable value: re-seed the rollback record. + this.durableSecrets.set(key, this.secretCache[key]) } catch (error) { logger.error( `Error refreshing global secret ${key}: ${error instanceof Error ? error.message : String(error)}`, @@ -557,7 +693,15 @@ export class ContextProxy { public async setValues(values: RooCodeSettings) { const entries = Object.entries(values) as [RooCodeSettingsKey, unknown][] - await Promise.all(entries.map(([key, value]) => this.setValue(key, value))) + // Settle every started write before rejecting: with Promise.all an early + // rejection would leave a sibling write in flight, and that write could land + // after a caller (e.g. API.setConfiguration) restored the prior values, + // re-persisting part of the failed configuration. + const results = await Promise.allSettled(entries.map(([key, value]) => this.setValue(key, value))) + const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected") + if (rejected) { + throw rejected.reason + } } /** @@ -587,9 +731,22 @@ export class ContextProxy { * @returns A promise that resolves when all reset operations are complete */ public async resetAllState() { - // Clear in-memory caches + // Invalidate writes issued before this reset: once the durable maps below + // are cleared and re-seeded by the re-initialization, their completions hold + // obsolete values that must not re-record over the re-seeded records. + this.resetGeneration += 1 + // Clear in-memory caches. The write tokens are left in place: the durable + // records below carry the rollback protection (see the in-write token guard, + // which still covers the interleaving where a newer write has moved the cache + // but its success is not confirmed yet). this.stateCache = {} this.secretCache = {} + // The durable records are rebuilt by the initialize() at the end of this reset + // (the storage delete above is awaited first), so they are cleared now: a write + // failing while the re-initialization has not re-seeded them must roll back to + // nothing, not to the pre-reset durable value storage no longer holds. + this.durableState.clear() + this.durableSecrets.clear() await Promise.all([ ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 01695d7994..306253578a 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -55,6 +55,19 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +/** + * Signals that a profile's configuration no longer exists. Callers that treat an + * already-deleted profile as an idempotent no-op branch on this type instead of + * matching error message text, which a profile name containing the phrase could + * otherwise spoof. + */ +export class ProviderSettingsNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderSettingsNotFoundError" + } +} + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() @@ -511,7 +524,7 @@ export class ProviderSettingsManager { const providerProfiles = await this.load() if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config '${name}' not found`) } if (Object.keys(providerProfiles.apiConfigs).length === 1) { @@ -522,6 +535,11 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + // A missing config is a caller-meaningful signal, not a failure: rethrow it + // unwrapped so callers can branch on the type instead of message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index b4d3b1b8aa..31fbfd4359 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -76,11 +76,13 @@ describe("ContextProxy", () => { describe("constructor", () => { it("should initialize state cache with all global state keys", () => { - // +3 for the migration checks: + // +5 for the migration checks: // 1. openRouterImageGenerationSettings // 2. customCondensingPrompt // 3. customSupportPrompts (for migrateOldDefaultCondensingPrompt) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) + // 4. mimoApiKey legacy-plaintext probe + // 5. poeApiKey legacy-plaintext probe + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 5) for (const key of GLOBAL_STATE_KEYS) { expect(mockGlobalState.get).toHaveBeenCalledWith(key) } @@ -90,6 +92,62 @@ describe("ContextProxy", () => { expect(mockGlobalState.get).toHaveBeenCalledWith("customSupportPrompts") }) + it("relocates plaintext provider keys that moved to secret storage", async () => { + // A user configured mimoApiKey while it was still stored in plaintext + // global state; the key now lives in secret storage. + mockSecrets.get.mockImplementation(async (key: string) => + key === "mimoApiKey" || key === "poeApiKey" ? undefined : "test-secret", + ) + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? "legacy-mimo" : undefined)) + + const migrated = new ContextProxy(mockContext) + await migrated.initialize() + + // The legacy value is readable as a secret and no longer kept in plaintext. + expect(migrated.getSecret("mimoApiKey")).toBe("legacy-mimo") + expect(mockSecrets.store).toHaveBeenCalledWith("mimoApiKey", "legacy-mimo") + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + // poeApiKey has no legacy plaintext value: nothing is stored or cleared. + expect(mockSecrets.store).not.toHaveBeenCalledWith("poeApiKey", expect.anything()) + expect(mockGlobalState.update).not.toHaveBeenCalledWith("poeApiKey", expect.anything()) + }) + + it("keeps an existing secret when a stale plaintext copy exists", async () => { + mockSecrets.get.mockImplementation(async (key: string) => + key === "mimoApiKey" ? "current-secret" : "test-secret", + ) + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? "legacy-mimo" : undefined)) + + const migrated = new ContextProxy(mockContext) + await migrated.initialize() + + // The secret wins; the migration must not overwrite it with the stale + // plaintext value. + expect(migrated.getSecret("mimoApiKey")).toBe("current-secret") + expect(mockSecrets.store).not.toHaveBeenCalledWith("mimoApiKey", expect.anything()) + // The redundant plaintext copy must still be erased: leaving it keeps the + // exposure alive forever, and a deletion that failed once must be retried. + expect(mockGlobalState.update).toHaveBeenCalledWith("mimoApiKey", undefined) + }) + + it("keeps the plaintext copy when the migrated secret write fails", async () => { + mockSecrets.get.mockImplementation(async (key: string) => + key === "mimoApiKey" || key === "poeApiKey" ? undefined : "test-secret", + ) + mockGlobalState.get.mockImplementation((key: string) => (key === "mimoApiKey" ? "legacy-mimo" : undefined)) + mockSecrets.store.mockImplementationOnce(async () => { + throw new Error("secret store failed") + }) + + const migrated = new ContextProxy(mockContext) + await migrated.initialize() + + // Erasing the plaintext before the secret write lands could lose the key: + // the value must survive for a retry on the next initialization. + expect(mockGlobalState.update).not.toHaveBeenCalledWith("mimoApiKey", undefined) + expect(migrated.getSecret("mimoApiKey")).toBeUndefined() + }) + it("should initialize secret cache with all secret keys", () => { expect(mockSecrets.get).toHaveBeenCalledTimes(SECRET_STATE_KEYS.length + GLOBAL_SECRET_KEYS.length) for (const key of SECRET_STATE_KEYS) { @@ -110,8 +168,8 @@ describe("ContextProxy", () => { const result = proxy.getGlobalState("apiProvider") expect(result).toBe("deepseek") - // Original context should be called once during updateGlobalState (+3 for migration checks) - expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 3) // From initialization + migration checks + // Original context should be called once during updateGlobalState (+5 for migration checks) + expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 5) // From initialization + migration checks }) it("should handle default values correctly", async () => { @@ -157,6 +215,344 @@ describe("ContextProxy", () => { }) describe("updateGlobalState", () => { + it("restores the previous cached value when the durable write fails", async () => { + await proxy.updateGlobalState("apiProvider", "deepseek") + + mockGlobalState.update.mockRejectedValueOnce(new Error("storage failed")) + + await expect(proxy.updateGlobalState("apiProvider", "anthropic")).rejects.toThrow("storage failed") + + // A failed durable write must not leave the cache ahead of storage. + expect(proxy.getGlobalState("apiProvider")).toBe("deepseek") + }) + + it("restores the initialize-loaded value when the first write for a key fails", async () => { + // A key whose load seeded the durable record and whose first write fails: + // the rollback must restore the loaded value, not undefined. + mockGlobalState.get.mockImplementation((key: string) => + key === "apiProvider" ? "seeded-value" : undefined, + ) + await proxy.initialize() + mockGlobalState.update.mockRejectedValueOnce(new Error("storage failed")) + + await expect(proxy.updateGlobalState("apiProvider", "anthropic")).rejects.toThrow("storage failed") + + expect(proxy.getGlobalState("apiProvider")).toBe("seeded-value") + }) + + it("does not roll back over a newer same-value write when the older write fails", async () => { + // Seed the cache so two overlapping writes carry the same new value: the + // stale-previous restore is only visible when value comparison matches. + await proxy.updateGlobalState("apiProvider", "deepseek") + + // The older write's durable update fails while a newer same-key write is + // in flight: its failure must not restore its previous value over the + // newer write's success (its write token was superseded). + mockGlobalState.update.mockRejectedValueOnce(new Error("storage failed")) + const older = proxy.updateGlobalState("apiProvider", "anthropic") + const newer = proxy.updateGlobalState("apiProvider", "anthropic") + + await expect(older).rejects.toThrow("storage failed") + await newer + + expect(proxy.getGlobalState("apiProvider")).toBe("anthropic") + }) + + it("restores the last durable value when a superseded in-flight write also fails", async () => { + // Write A (prev=X, value=Y) is superseded by write B (prev=Y, value=Z) and + // both durable writes fail. A's rollback is skipped (superseded token), and + // B's rollback must restore the durable value X, not the cache value it + // captured at call time (Y): Y was only ever in the cache, never in storage, + // so restoring it would leave the cache ahead of storage. + await proxy.updateGlobalState("apiProvider", "deepseek") + + let failA!: (error: Error) => void + let failB!: (error: Error) => void + mockGlobalState.update + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failA = reject + }), + ) + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failB = reject + }), + ) + const writeA = proxy.updateGlobalState("apiProvider", "anthropic") + const writeB = proxy.updateGlobalState("apiProvider", "openrouter") + + // A fails first: superseded by B, so it cannot roll back. + failA(new Error("storage failed")) + await expect(writeA).rejects.toThrow("storage failed") + expect(proxy.getGlobalState("apiProvider")).toBe("openrouter") + + // B fails second: it is the current write, so it rolls back — to the durable + // value (storage never accepted anthropic or openrouter). + failB(new Error("storage failed")) + await expect(writeB).rejects.toThrow("storage failed") + expect(proxy.getGlobalState("apiProvider")).toBe("deepseek") + }) + + it("does not roll back a secret over a newer same-value write when the older write fails", async () => { + await proxy.storeSecret("apiKey", "old-key") + + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + const older = proxy.storeSecret("apiKey", "new-key") + const newer = proxy.storeSecret("apiKey", "new-key") + + await expect(older).rejects.toThrow("secrets failed") + await newer + + expect(proxy.getSecret("apiKey")).toBe("new-key") + }) + + it("restores the last durable secret when a superseded in-flight write also fails", async () => { + // Same double-failure scenario as the state case for storeSecret: the + // superseding write's rollback must restore the durable value, not the + // cache value it captured at call time. + await proxy.storeSecret("apiKey", "old-key") + + let failA!: (error: Error) => void + let failB!: (error: Error) => void + mockSecrets.store + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failA = reject + }), + ) + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failB = reject + }), + ) + const writeA = proxy.storeSecret("apiKey", "mid-key") + const writeB = proxy.storeSecret("apiKey", "new-key") + + failA(new Error("secrets failed")) + await expect(writeA).rejects.toThrow("secrets failed") + expect(proxy.getSecret("apiKey")).toBe("new-key") + + failB(new Error("secrets failed")) + await expect(writeB).rejects.toThrow("secrets failed") + expect(proxy.getSecret("apiKey")).toBe("old-key") + }) + + it("restores the previous secret when the durable write fails", async () => { + await proxy.storeSecret("apiKey", "old-key") + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + + await expect(proxy.storeSecret("apiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBe("old-key") + }) + + it("restores the initialize-loaded secret when the first write for a key fails", async () => { + // Same as the state case, for the SECRET_STATE_KEYS load loop's seed. + mockSecrets.get.mockImplementation((key: string) => (key === "apiKey" ? "seeded-secret" : undefined)) + await proxy.initialize() + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + + await expect(proxy.storeSecret("apiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBe("seeded-secret") + }) + + it("restores the initialize-loaded global secret when the first write for a key fails", async () => { + // Same as the state case, for the GLOBAL_SECRET_KEYS load loop's seed. + mockSecrets.get.mockImplementation((key: string) => + key === "openRouterImageApiKey" ? "seeded-global-secret" : undefined, + ) + await proxy.initialize() + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + + await expect(proxy.storeSecret("openRouterImageApiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("openRouterImageApiKey")).toBe("seeded-global-secret") + }) + + it("restores the refreshed secret when a post-refresh write fails", async () => { + // The refresh re-seeds the durable record with the loaded value; a write + // failing after the refresh must roll back to that value, not the + // pre-refresh one the record held before the re-sync. + await proxy.storeSecret("apiKey", "old-key") + mockSecrets.get.mockImplementation((key: string) => (key === "apiKey" ? "refreshed-value" : undefined)) + await proxy.refreshSecrets() + + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + await expect(proxy.storeSecret("apiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBe("refreshed-value") + }) + + it("restores the refreshed global secret when a post-refresh write fails", async () => { + // Same as the refresh case, for the GLOBAL_SECRET_KEYS re-sync loop. + await proxy.storeSecret("openRouterImageApiKey", "old-key") + mockSecrets.get.mockImplementation((key: string) => + key === "openRouterImageApiKey" ? "refreshed-global" : undefined, + ) + await proxy.refreshSecrets() + + mockSecrets.store.mockRejectedValueOnce(new Error("secrets failed")) + await expect(proxy.storeSecret("openRouterImageApiKey", "new-key")).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("openRouterImageApiKey")).toBe("refreshed-global") + }) + + it("does not roll a pre-refresh secret write back over refreshed values", async () => { + await proxy.storeSecret("apiKey", "old-key") + // Defer the durable failure until after the refresh completes: a failure that + // lands before the re-sync runs its rollback microtask first, so the re-sync + // would mask the stale restore regardless of the token guard. + let failStore!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failStore = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "stale-write") + + // The refresh re-syncs the cache from storage and re-seeds the durable + // records with the loaded values: the pending write's later failure rolls + // back to the refreshed durable value, not its previous value. + mockSecrets.get.mockResolvedValueOnce("refreshed-value") + await proxy.refreshSecrets() + + failStore(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + expect(proxy.getSecret("apiKey")).toBe("refreshed-value") + }) + + it("does not restore a pending write into a reset cache", async () => { + // The pending write fails after resetAllState cleared the caches and the + // durable records: its rollback must restore the (re-seeded) durable value, + // which is nothing — not its previous value, which storage no longer holds. + // The failure is deferred (see the pre-refresh case) so the rollback + // cannot be masked by the re-initialization's own cache writes. + await proxy.updateGlobalState("apiProvider", "deepseek") + let failUpdate!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failUpdate = reject + }), + ) + const pending = proxy.updateGlobalState("apiProvider", "anthropic") + + await proxy.resetAllState() + + failUpdate(new Error("storage failed")) + await expect(pending).rejects.toThrow("storage failed") + + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + }) + + it("does not restore a pending secret write into a reset cache", async () => { + // Same deferred-failure discipline as the state case: the pending secret write + // fails after resetAllState cleared the secret token, so without that clear its + // stale previous value would resurrect in the re-seeded secret cache. + await proxy.storeSecret("apiKey", "old-key") + let failStore!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failStore = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "stale-write") + + // Reset re-initialization reads through secrets.get: seed it with undefined so + // the re-seeded cache holds nothing for the assertion to distinguish from the + // stale restore. + mockSecrets.get.mockResolvedValue(undefined) + await proxy.resetAllState() + + failStore(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + + expect(proxy.getSecret("apiKey")).toBeUndefined() + }) + + it("does not roll a post-reset write back over the pre-reset durable value", async () => { + // resetAllState clears the durable records before the storage deletes: a + // write issued into the cleared window and failing before the + // re-initialization re-seeds must roll back to nothing (storage is + // already cleared), not to the pre-reset durable value. + await proxy.updateGlobalState("apiProvider", "deepseek") + + let failDelete!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failDelete = reject + }), + ) + const resetPromise = proxy.resetAllState() + // The synchronous clears ran; the reset is now awaiting the storage deletes. + + let failWrite!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failWrite = reject + }), + ) + const pending = proxy.updateGlobalState("apiProvider", "openrouter") + + failWrite(new Error("storage failed")) + await expect(pending).rejects.toThrow("storage failed") + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + + failDelete(new Error("reset failed")) + await expect(resetPromise).rejects.toThrow("reset failed") + }) + + it("does not reseed the durable record from a pre-reset write that completes after the reset", async () => { + // A write issued before resetAllState may still be in flight when the reset + // clears the durable maps and the re-initialization re-seeds them from + // (now empty) storage. Its late completion must not re-record its obsolete + // value over the re-seeded record: a later failed write would then roll the + // cache back to a value storage no longer holds. + await proxy.updateGlobalState("apiProvider", "deepseek") + + let resolveWrite!: (value?: unknown) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWrite = resolve + }), + ) + const stale = proxy.updateGlobalState("apiProvider", "openrouter") + + // Reset reads through globalState.get (undefined by default): the + // re-seeded durable record holds nothing for this key. + await proxy.resetAllState() + + // The pre-reset write lands after the re-seed. + resolveWrite() + await stale + + let failWrite!: (error: Error) => void + mockGlobalState.update.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failWrite = reject + }), + ) + const pending = proxy.updateGlobalState("apiProvider", "openrouter") + failWrite(new Error("storage failed")) + await expect(pending).rejects.toThrow("storage failed") + + // The rollback target is the re-seeded (empty) durable record, not the + // stale pre-reset write's value. + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + }) + it("should update state directly in original context", async () => { await proxy.updateGlobalState("apiProvider", "deepseek") @@ -229,6 +625,75 @@ describe("ContextProxy", () => { const storedValue = await proxy.getSecret("apiKey") expect(storedValue).toBeUndefined() }) + + it("does not roll a post-reset secret write back over the pre-reset durable value", async () => { + // Same window as the state case, for the secret durable record. + await proxy.storeSecret("apiKey", "old-key") + + let failDelete!: (error: Error) => void + mockSecrets.delete.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failDelete = reject + }), + ) + const resetPromise = proxy.resetAllState() + + let failWrite!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failWrite = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "post-reset-key") + + failWrite(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + expect(proxy.getSecret("apiKey")).toBeUndefined() + + failDelete(new Error("reset failed")) + await expect(resetPromise).rejects.toThrow("reset failed") + }) + + it("does not reseed the durable record from a pre-refresh secret write that completes after the refresh", async () => { + // Same race as the pre-reset case, for the refresh re-seed: a write + // issued before refreshSecrets may complete after the refresh has + // re-seeded the durable secret records from storage. + await proxy.storeSecret("apiKey", "old-key") + + let resolveStore!: (value?: unknown) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStore = resolve + }), + ) + const stale = proxy.storeSecret("apiKey", "stale-key") + + // The refresh re-seeds from storage. + mockSecrets.get.mockResolvedValue("storage-key") + await proxy.refreshSecrets() + + // The pre-refresh write lands after the re-seed. + resolveStore() + await stale + + let failStore!: (error: Error) => void + mockSecrets.store.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failStore = reject + }), + ) + const pending = proxy.storeSecret("apiKey", "new-key") + failStore(new Error("secrets failed")) + await expect(pending).rejects.toThrow("secrets failed") + + // The rollback target is the freshly loaded value, not the stale + // pre-refresh write's value. + expect(proxy.getSecret("apiKey")).toBe("storage-key") + }) }) describe("setValue", () => { @@ -306,6 +771,22 @@ describe("ContextProxy", () => { expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key") expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4") }) + + it("settles every started write before rejecting", async () => { + const landed: string[] = [] + vi.spyOn(proxy, "setValue").mockImplementation(async (key) => { + if (key === "mode") { + throw new Error("write failed") + } + await new Promise((resolve) => setTimeout(resolve, 20)) + landed.push(String(key)) + }) + + await expect(proxy.setValues({ mode: "ask", apiModelId: "gpt-4" })).rejects.toThrow("write failed") + // The sibling write must have landed before the rejection surfaced, so a + // caller restoring prior values cannot race an in-flight write. + expect(landed).toContain("apiModelId") + }) }) describe("setProviderSettings", () => { @@ -740,4 +1221,20 @@ Output only the summary of the conversation so far, without any additional comme expect(customSupportPromptsUpdateCalls.length).toBe(0) }) }) + + describe("export", () => { + it("should exclude viewStates from the exported settings", async () => { + await proxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + await proxy.setValue("customInstructions", "global instructions") + + const exported = await proxy.export() + + // Per-view selection state is machine-local and must never transfer + // between settings, while ordinary global settings keep round-tripping. + expect(exported).not.toHaveProperty("viewStates") + expect(exported?.customInstructions).toBe("global instructions") + }) + }) }) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index a1fc9ac7de..3b93b7cd22 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,7 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -973,6 +973,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 90db81d8a1..e038734281 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -331,6 +331,97 @@ describe("importExport", () => { ]) }) + it("should not apply imported viewStates to the context proxy", async () => { + const fileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { + test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" }, + }, + }, + globalSettings: { + mode: "code", + viewStates: { + "stable-sidebar-view": { + mode: "architect", + currentApiConfigName: "profile-a", + updatedAt: 1, + }, + }, + }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(fileContent) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "test", id: "test-id", apiProvider: providerIdentifiers.openai }, + { name: "default", id: "default-id", apiProvider: providerIdentifiers.anthropic }, + ]) + + // Stateful write-tracking proxy: every write path (setValues, setValue, + // setProviderSettings) is merged into `state` and recorded in `writes`, + // so the assertions below cover every payload rather than one call. + // Plain-function doubles cast once, matching the file-level mock pattern. + const makeStatefulProxy = (seed: Record) => { + const state: Record = { ...seed } + const writes: Record[] = [] + const record = (values: Record) => { + writes.push(values) + Object.assign(state, values) + } + return { + state, + writes, + proxy: Object.assign({} as ContextProxy, { + setValues: vi.fn(async (values: Record) => record(values)), + setValue: vi.fn(async (key: string, value: unknown) => record({ [key]: value })), + setProviderSettings: vi.fn(async (settings: Record) => record(settings)), + }), + } + } + + // This machine already has view state: it must survive the import untouched. + const existingViewStates = { + "existing-view": { mode: "code", currentApiConfigName: "default", updatedAt: 0 }, + } + const seeded = makeStatefulProxy({ viewStates: existingViewStates }) + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: seeded.proxy, + customModesManager: mockCustomModesManager, + }) + + expect(result.success).toBe(true) + // Per-view selection state is machine-local: importing settings must not + // apply another machine's view pins, while other settings round-trip. + expect(seeded.state.viewStates).toEqual(existingViewStates) + expect(seeded.state.mode).toBe("code") + expect(result).not.toHaveProperty("globalSettings.viewStates") + + // A machine without view state must not gain any from the import. + const fresh = makeStatefulProxy({}) + const freshResult = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: fresh.proxy, + customModesManager: mockCustomModesManager, + }) + + expect(freshResult.success).toBe(true) + expect(fresh.state).not.toHaveProperty("viewStates") + expect(fresh.state.mode).toBe("code") + expect(freshResult).not.toHaveProperty("globalSettings.viewStates") + + // No write path may carry the imported machine's view pins. + for (const payload of [...seeded.writes, ...fresh.writes]) { + expect(payload).not.toHaveProperty("viewStates") + } + }) + it("should return success: false when file content is invalid", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) @@ -872,6 +963,80 @@ describe("importExport", () => { .spyOn(vscode.window, "showInformationMessage") .mockResolvedValue(undefined) const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + // Should show warning message with short summary (not full details) + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("1 item had issues during import."), + ) + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("See Developer Tools console for details."), + ) + // Should log full details to console + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Settings import completed with warnings:", + expect.arrayContaining([expect.stringContaining("problematic-profile")]), + ) + expect(showInfoMessageSpy).not.toHaveBeenCalled() + + // Provider state should be delivered once, then cleared. + expect(seenImportedAt).toHaveLength(1) + expect(seenImportedAt[0]).toBeGreaterThan(0) + expect(mockProvider.settingsImportedAt).toBeUndefined() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + + showWarningMessageSpy.mockRestore() + showInfoMessageSpy.mockRestore() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } + }) + + it("clears settingsImportedAt after posting the imported state so later launches do not replay it", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const seenImportedAt: Array = [] + const mockProvider = { + settingsImportedAt: undefined as number | undefined, + postStateToWebview: vi.fn().mockImplementation(async () => { + seenImportedAt.push(mockProvider.settingsImportedAt) + }), + } await importSettingsWithFeedback( { @@ -883,32 +1048,12 @@ describe("importExport", () => { filePath, ) - // Should show warning message with short summary (not full details) - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("1 item had issues during import."), - ) - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("See Developer Tools console for details."), - ) - // Should log full details to console - expect(consoleWarnSpy).toHaveBeenCalledWith( - "Settings import completed with warnings:", - expect.arrayContaining([expect.stringContaining("problematic-profile")]), - ) - expect(showInfoMessageSpy).not.toHaveBeenCalled() - - // Provider state should be delivered once, then cleared. expect(seenImportedAt).toHaveLength(1) expect(seenImportedAt[0]).toBeGreaterThan(0) expect(mockProvider.settingsImportedAt).toBeUndefined() - expect(mockProvider.postStateToWebview).toHaveBeenCalled() - - showWarningMessageSpy.mockRestore() - showInfoMessageSpy.mockRestore() - consoleWarnSpy.mockRestore() }) - it("clears settingsImportedAt after posting the imported state so later launches do not replay it", async () => { + it("should call broadcastResetToAllInstances after successful import when available", async () => { const filePath = "/mock/path/settings.json" const mockFileContent = JSON.stringify({ providerProfiles: { @@ -926,7 +1071,55 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) ;(fs.access as Mock).mockResolvedValue(undefined) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + broadcastResetToAllInstances: vi.fn().mockResolvedValue(undefined), + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("settings_imported"), + ) + }) + it("posts the initiating provider state only after the reset broadcast completes", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, @@ -935,11 +1128,14 @@ describe("importExport", () => { { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, ]) - const seenImportedAt: Array = [] + const callOrder: string[] = [] const mockProvider = { - settingsImportedAt: undefined as number | undefined, + settingsImportedAt: 0, postStateToWebview: vi.fn().mockImplementation(async () => { - seenImportedAt.push(mockProvider.settingsImportedAt) + callOrder.push("post") + }), + broadcastResetToAllInstances: vi.fn().mockImplementation(async () => { + callOrder.push("broadcast") }), } @@ -953,9 +1149,129 @@ describe("importExport", () => { filePath, ) - expect(seenImportedAt).toHaveLength(1) - expect(seenImportedAt[0]).toBeGreaterThan(0) - expect(mockProvider.settingsImportedAt).toBeUndefined() + // The broadcast clears the durable view state, so the initiating provider's + // webview post must run after it — otherwise the webview keeps the stale + // pre-import per-view mode/profile. + expect(callOrder).toEqual(["broadcast", "post"]) + }) + + it("should skip broadcastResetToAllInstances when callback is missing", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + } + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("settings_imported"), + ) + // A missing callback must not reach the broadcast guard's failure path. + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Failed to broadcast reset after settings import"), + ) + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } + }) + + it("should keep successful import result when broadcastResetToAllInstances throws", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: providerIdentifiers.openai, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: providerIdentifiers.openai }, + ]) + + const broadcastError = new Error("broadcast failed") + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + broadcastResetToAllInstances: vi.fn().mockRejectedValue(broadcastError), + } + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1) + expect(mockProvider.broadcastResetToAllInstances).toHaveBeenCalledTimes(1) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to broadcast reset after settings import"), + ) + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining("settings_imported"), + ) + // The rejected broadcast must not leave the import timestamp dangling: the + // guarded catch falls through to the cleanup reset. + expect(mockProvider.settingsImportedAt).toBeUndefined() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } }) it("should handle multiple profiles with mixed valid and invalid providers", async () => { @@ -1214,32 +1530,36 @@ describe("importExport", () => { const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined) const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) - await importSettingsWithFeedback( - { - providerSettingsManager: mockProviderSettingsManager, - contextProxy: mockContextProxy, - customModesManager: mockCustomModesManager, - provider: mockProvider, - }, - filePath, - ) - - // Should show warning message with plural summary for multiple warnings - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("2 items had issues during import."), - ) - // Should log full details to console - expect(consoleWarnSpy).toHaveBeenCalledWith( - "Settings import completed with warnings:", - expect.arrayContaining([ - expect.stringContaining("problematic-profile-1"), - expect.stringContaining("problematic-profile-2"), - ]), - ) + // Should show warning message with plural summary for multiple warnings + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("2 items had issues during import."), + ) + // Should log full details to console + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Settings import completed with warnings:", + expect.arrayContaining([ + expect.stringContaining("problematic-profile-1"), + expect.stringContaining("problematic-profile-2"), + ]), + ) - showWarningMessageSpy.mockRestore() - consoleWarnSpy.mockRestore() + showWarningMessageSpy.mockRestore() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } }) it.each([ @@ -1483,28 +1803,34 @@ describe("importExport", () => { const showWarningMessageSpy = vi.spyOn(vscode.window, "showWarningMessage").mockResolvedValue(undefined) const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) - await importSettingsWithFeedback( - { - providerSettingsManager: mockProviderSettingsManager, - contextProxy: mockContextProxy, - customModesManager: mockCustomModesManager, - provider: mockProvider, - }, - filePath, - ) - - expect(showWarningMessageSpy).toHaveBeenCalledWith( - expect.stringContaining("1 item had issues during import."), - ) - expect(showWarningMessageSpy).not.toHaveBeenCalledWith(expect.stringContaining("profile had issues")) - expect(consoleWarnSpy).toHaveBeenCalledWith( - "Settings import completed with warnings:", - expect.arrayContaining([expect.stringContaining("globalSettings.requestDelaySeconds")]), - ) + expect(showWarningMessageSpy).toHaveBeenCalledWith( + expect.stringContaining("1 item had issues during import."), + ) + expect(showWarningMessageSpy).not.toHaveBeenCalledWith( + expect.stringContaining("profile had issues"), + ) + expect(consoleWarnSpy).toHaveBeenCalledWith( + "Settings import completed with warnings:", + expect.arrayContaining([expect.stringContaining("globalSettings.requestDelaySeconds")]), + ) - showWarningMessageSpy.mockRestore() - consoleWarnSpy.mockRestore() + showWarningMessageSpy.mockRestore() + } finally { + // Restore even when an assertion above throws, so a leftover spy + // cannot suppress real warnings for the remaining tests in this file. + consoleWarnSpy.mockRestore() + } }) }) }) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 9882039ad3..e747a472bb 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -37,6 +37,7 @@ type ImportWithProviderOptions = ImportOptions & { provider: { settingsImportedAt?: number postStateToWebview: () => Promise + broadcastResetToAllInstances?(): Promise } } @@ -98,6 +99,14 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { for (const [key, rawValue] of Object.entries(rawGlobalSettings)) { const path = `globalSettings.${key}` + + // Per-view selection state is machine-local: it round-trips through the + // normal runtime and pruning paths, but importing it would pin selections + // from another machine's views on this one. + if (key === "viewStates") { + continue + } + const schema = globalSettingsShape[key as keyof GlobalSettings] if (!schema) { @@ -387,7 +396,21 @@ export const importSettingsWithFeedback = async ( if (result.success) { provider.settingsImportedAt = Date.now() + + // Broadcast invalidation to all other live ClineProvider instances first, so the + // initiating provider's post below reflects the cleared view state rather than the + // stale pre-import per-view mode/profile. + try { + if (provider.broadcastResetToAllInstances) { + await provider.broadcastResetToAllInstances() + } + } catch (error) { + // Log but do not fail the import if broadcast fails — the import itself succeeded. + console.warn(`Failed to broadcast reset after settings import: ${error}`) + } + await provider.postStateToWebview() + provider.settingsImportedAt = undefined const warnings = "warnings" in result ? result.warnings : undefined diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index dc7880644c..19b73f0884 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -887,10 +887,17 @@ export class Task extends EventEmitter implements TaskLike { private async initializeTaskMode(provider: ClineProvider): Promise { try { const state = await provider.getState() - this._taskMode = state?.mode || defaultModeSlug + + // Avoid clobbering a newer value that may have been set while awaiting provider state + // (e.g., a message-selected mode switch issued right after task creation). + if (this._taskMode === undefined) { + this._taskMode = state?.mode || defaultModeSlug + } } catch (error) { - // If there's an error getting state, use the default mode - this._taskMode = defaultModeSlug + // If there's an error getting state, use the default mode (unless a newer value was set). + if (this._taskMode === undefined) { + this._taskMode = defaultModeSlug + } // Use the provider's log method for better error visibility const errorMessage = `Failed to initialize task mode: ${error instanceof Error ? error.message : String(error)}` provider.log(errorMessage) @@ -2560,8 +2567,36 @@ export class Task extends EventEmitter implements TaskLike { if (provider) { if (mode) { - await provider.setMode(mode) - this._taskMode = mode + // Route through the shared mode-switch handler so the switch is + // validated and recorded like any other mode change (task history, + // TaskModeSwitched, and — when this is the focused task — the view's + // durable mode pin + ModeChanged broadcast). The handler writes this + // task's mode only after validation and persistence, so an unknown + // slug leaves the task mode untouched instead of recording a bad one. + // A mode-switch failure (e.g. a task-history write failure) must not + // swallow the submitted message: log it locally and continue delivery. + // Let the constructor-started mode initialization settle first: its + // deferred provider-state read would otherwise resolve after the switch + // and clobber the explicitly selected mode with the pre-switch value. + try { + await this.waitForModeInitialization() + await provider.handleModeSwitch(mode, this) + // handleModeSwitch resolves without applying the switch for an + // unknown slug or an aborted/timed-out mutation; the task keeps + // its previous mode while the message below is delivered in the + // old mode, so leave a trace when the requested mode is not the + // task's mode after the call. + if (this._taskMode !== mode) { + console.warn( + `[Task#submitUserMessage] Mode switch to ${mode} was not applied (taskId=${this.taskId})`, + ) + } + } catch (error) { + console.error( + `[Task#submitUserMessage] Mode switch to ${mode} failed (taskId=${this.taskId}):`, + error, + ) + } } if (providerProfile) { @@ -4266,7 +4301,6 @@ export class Task extends EventEmitter implements TaskLike { // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => - // Stryker disable next-line ConditionalExpression: equivalent mutant, only tool_use and mcp_tool_use entries carry an id, so the type check cannot change entry.id === event.id (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4319,7 +4353,6 @@ export class Task extends EventEmitter implements TaskLike { // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined - // Stryker disable next-line ConditionalExpression: equivalent mutant, event.name is always a string here, so the legacy nameless branch is unreachable if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4336,7 +4369,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: equivalent mutant, streamingKey is always a string here (makeStreamingKey), so the null side is unreachable streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4345,7 +4377,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4742,7 +4773,7 @@ export class Task extends EventEmitter implements TaskLike { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. - // Stryker disable next-line LogicalOperator: equivalent mutant, finalizeRawChunks emits end events with a string name, so the ?? event.id fallback is unreachable + let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4759,13 +4790,12 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline + if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3066613263..365881b258 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -38,6 +38,7 @@ import type { RequestPolicySnapshot } from "../../prompts/tools/effective-tool-p import { asyncStreamFrom } from "../../../test-utils/stream" import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" +import { defaultModeSlug } from "../../../shared/modes" import { readFileTool } from "../../tools/ReadFileTool" import { writeToFileTool } from "../../tools/WriteToFileTool" @@ -59,6 +60,9 @@ type TaskTestAccess = { getFilesReadByRooSafely: (context: string) => Promise addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise resetAssistantMessagePersistence: () => void + // Private on Task; the provider-owned mode write (ClineProvider.handleModeSwitch) + // sets it, and tests mirror that write through this helper. + _taskMode: string | undefined buildCleanConversationHistory: ( messages: ApiMessage[], requestModelInfo: ModelInfo, @@ -3486,6 +3490,7 @@ describe("Cline", () => { getMcpHub: vi.fn().mockReturnValue(undefined), getSkillsManager: vi.fn().mockReturnValue(undefined), say: vi.fn(), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), postStateToWebview: vi.fn().mockResolvedValue(undefined), postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), @@ -4034,8 +4039,16 @@ describe("Cline", () => { }) it("uses a mode selected through submitUserMessage in the next API request", async () => { - vi.spyOn(mockProvider, "getState").mockResolvedValue(providerStateWith({ mode: "ask" })) - vi.spyOn(mockProvider, "setMode").mockResolvedValue(undefined) + vi.spyOn(mockProvider, "getState").mockResolvedValue( + Object.assign({} as ProviderState, { mode: "ask", mcpEnabled: false }), + ) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + // Mirror ClineProvider.handleModeSwitch: after validation and persistence + // the provider owns the task's mode write. + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -4044,6 +4057,10 @@ describe("Cline", () => { }) vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + // Let the task's initial mode ("ask", from provider state) settle first, so the + // mode selected with the user message is the task's final mode write. + await task.getTaskMode() + await task.submitUserMessage("switch modes", undefined, "code") vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") const stream = (async function* () { @@ -4056,10 +4073,217 @@ describe("Cline", () => { await task.attemptApiRequest().next() - expect(mockProvider.setMode).toHaveBeenCalledWith("code") + expect(mockProvider.handleModeSwitch).toHaveBeenCalledWith("code", task) expect(requireDefined(createMessage.mock.calls[0])[2]?.mode).toBe("code") }) + it("still delivers the user message when the mode switch fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + const switchError = new Error("task history write failed") + vi.spyOn(mockProvider, "handleModeSwitch").mockRejectedValue(switchError) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await task.submitUserMessage("still delivered", undefined, "code") + + // The mode-switch rejection is caught and logged locally... + expect(consoleErrorSpy).toHaveBeenCalledWith( + `[Task#submitUserMessage] Mode switch to code failed (taskId=${task.taskId}):`, + switchError, + ) + // ...and the pending ask is still answered, so the submitted text is not lost. + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "still delivered", []) + consoleErrorSpy.mockRestore() + }) + + it("warns when the mode switch resolves without applying the requested mode", async () => { + vi.spyOn(mockProvider, "getState").mockResolvedValue( + Object.assign({} as ProviderState, { mode: "ask", mcpEnabled: false }), + ) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + // Unknown slug / aborted mutation: the handler resolves without writing + // the task's mode (ClineProvider leaves _taskMode untouched in both cases). + vi.spyOn(mockProvider, "handleModeSwitch").mockResolvedValue(undefined) + const handleResponseSpy = vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await task.submitUserMessage("delivered in old mode", undefined, "architect") + + // The message is still delivered in the previous mode... + expect(handleResponseSpy).toHaveBeenCalledWith("messageResponse", "delivered in old mode", []) + // ...and the dropped selection leaves a trace. + expect(warnSpy).toHaveBeenCalledWith( + `[Task#submitUserMessage] Mode switch to architect was not applied (taskId=${task.taskId})`, + ) + warnSpy.mockRestore() + }) + + it("keeps the selected mode when the deferred mode initialization settles after the submission", async () => { + // The constructor-started initializeTaskMode() is still awaiting the + // provider state. submitUserMessage must let that initialization settle + // before switching: without the await, the deferred read resolves after + // the switch and clobbers the selected mode with the pre-switch value. + // One shared pending promise: both the mode and api-config initializers + // await provider.getState(), and a per-call promise would leave the mode + // initializer's own read pending forever. + let releaseState: (state: ProviderState) => void = () => {} + const deferredState = new Promise((resolve) => { + releaseState = resolve + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + const submitted = task.submitUserMessage("select while initializing", undefined, "architect") + + // Flush microtasks: the switch must still wait for the pending + // initialization. Without the await, the mocked switch would already have + // run here and the end-state assertion could not tell the orders apart. + for (let i = 0; i < 5; i++) { + await Promise.resolve() + } + expect(mockProvider.handleModeSwitch).not.toHaveBeenCalled() + + // Resolve the pending provider state only after the submission has started, + // carrying the provider's pre-switch mode. + releaseState(Object.assign({} as ProviderState, { mode: "ask" })) + + await submitted + + expect(mockProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + // The explicit selection survives: the deferred initialization ran before, + // not after, the mode write. + expect(await task.getTaskMode()).toBe("architect") + }) + + it("does not let a pending mode initialization clobber a provider-scoped mode write", async () => { + // The constructor-started initializeTaskMode() is still awaiting the provider + // state. A provider-scoped switch (e.g. selectTaskFollowupSuggestion) writes + // the task's mode without the submitUserMessage wait: when the deferred read + // resolves afterwards, initialization must not restore the pre-switch mode. + let releaseState: (state: ProviderState) => void = () => {} + const deferredState = new Promise((resolve) => { + releaseState = resolve + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + // A provider-scoped switch writes the task mode through the provider-owned write. + await mockProvider.handleModeSwitch("architect", task) + + // The deferred initialization settles after the switch, carrying the + // provider's pre-switch mode. + releaseState({ mode: "ask" } as unknown as ProviderState) + + // The explicit selection survives the late initialization. + expect(await task.getTaskMode()).toBe("architect") + }) + + it("keeps a provider-scoped mode when mode initialization fails", async () => { + // The catch path must apply the same guard as the success path: a provider-scoped + // switch that landed before the failed initialization must survive it. + let failState: (error: Error) => void = () => {} + const deferredState = new Promise((_resolve, reject) => { + failState = reject + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + vi.spyOn(mockProvider, "handleModeSwitch").mockImplementation(async (mode, targetTask) => { + if (targetTask) { + getTaskTestAccess(targetTask)._taskMode = mode + } + }) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + // The pre-write must differ from the default slug (the catch path's fallback) + // or the clobber it guards against would be unobservable. + const preWriteMode = defaultModeSlug === "code" ? "architect" : "code" + // The switch lands while the initialization is still pending... + await mockProvider.handleModeSwitch(preWriteMode, task) + // ...then the initialization fails. + failState(new Error("state lookup failed")) + await task.getTaskMode() + + // The failed initialization must not clobber the provider-scoped write. + expect(getTaskTestAccess(task)._taskMode).toBe(preWriteMode) + }) + + it("falls back to the default slug when mode initialization fails before any write", async () => { + // The catch path still initializes the default slug when nothing newer exists. + let failState: (error: Error) => void = () => {} + const deferredState = new Promise((_resolve, reject) => { + failState = reject + }) + vi.spyOn(mockProvider, "getState").mockImplementation(() => deferredState) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + failState(new Error("state lookup failed")) + await task.getTaskMode() + + expect(getTaskTestAccess(task)._taskMode).toBe(defaultModeSlug) + }) + + it("does not log an initialization error when the provider state is merely undefined", async () => { + // state?.mode on an undefined state must not throw into the catch path: the catch + // is reserved for real getState() failures, which it logs through the provider. + vi.spyOn(mockProvider, "getState").mockResolvedValue(undefined as unknown as ProviderState) + const logSpy = vi.spyOn(mockProvider, "log") + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "initial task", + startTask: false, + }) + vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {}) + + await task.getTaskMode() + + expect(getTaskTestAccess(task)._taskMode).toBe(defaultModeSlug) + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Failed to initialize task mode")) + }) + it("stores a provider profile selected through submitUserMessage", async () => { const selectedConfiguration: ProviderSettings = { ...mockApiConfig, diff --git a/src/core/tools/SwitchModeTool.ts b/src/core/tools/SwitchModeTool.ts index e1216fcaad..1f3d302fe9 100644 --- a/src/core/tools/SwitchModeTool.ts +++ b/src/core/tools/SwitchModeTool.ts @@ -55,8 +55,19 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> { return } - // Switch the mode using shared handler - await task.providerRef.deref()?.handleModeSwitch(mode_slug) + // Switch the mode using shared handler. Pass this task explicitly so the + // switch is scoped to it rather than the provider's currently focused task. + // A provider released mid-turn (its view disposed) cannot execute the switch: + // the optional chain would swallow the call, so guard explicitly and report + // the failure instead of a false success. + const provider = task.providerRef.deref() + if (!provider) { + task.recordToolError("switch_mode") + task.didToolFailInCurrentTurn = true + pushToolResult(formatResponse.toolError("Cannot switch mode: the provider is no longer available.")) + return + } + await provider.handleModeSwitch(mode_slug, task) pushToolResult( `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ diff --git a/src/core/tools/__tests__/switchModeTool.spec.ts b/src/core/tools/__tests__/switchModeTool.spec.ts index fd8e939759..29bf17c84e 100644 --- a/src/core/tools/__tests__/switchModeTool.spec.ts +++ b/src/core/tools/__tests__/switchModeTool.spec.ts @@ -33,20 +33,22 @@ describe("SwitchModeTool", () => { let mockCallbacks: ToolCallbacks let mockHandleModeSwitch: ReturnType let mockGetState: ReturnType + let mockGetTaskMode: ReturnType beforeEach(() => { vi.clearAllMocks() mockHandleModeSwitch = vi.fn().mockResolvedValue(undefined) mockGetState = vi.fn().mockResolvedValue({ mode: "code", customModes: [] }) + mockGetTaskMode = vi.fn().mockResolvedValue("code") mockTask = { - getTaskMode: vi.fn().mockResolvedValue("code"), consecutiveMistakeCount: 0, recordToolError: vi.fn(), didToolFailInCurrentTurn: false, sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), ask: vi.fn().mockResolvedValue({}), + getTaskMode: mockGetTaskMode, providerRef: { deref: vi.fn().mockReturnValue({ getState: mockGetState, @@ -168,8 +170,8 @@ describe("SwitchModeTool", () => { }), ) - // Should have called handleModeSwitch with the target slug - expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect") + // Should have called handleModeSwitch with the target slug and the task + expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect", mockTask) // Should have pushed success result expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( @@ -187,7 +189,7 @@ describe("SwitchModeTool", () => { JSON.stringify({ tool: "switchMode", mode: "ask", reason: "" }), ) - expect(mockHandleModeSwitch).toHaveBeenCalledWith("ask") + expect(mockHandleModeSwitch).toHaveBeenCalledWith("ask", mockTask) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith("Successfully switched from Code mode to Ask mode.") }) @@ -247,7 +249,7 @@ describe("SwitchModeTool", () => { // Should have asked for approval first expect(mockCallbacks.askApproval).toHaveBeenCalled() // Should have called handleModeSwitch (which throws) - expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect") + expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect", mockTask) // Error should be caught and reported expect(mockCallbacks.handleError).toHaveBeenCalledWith("switching mode", switchError) }) @@ -306,7 +308,7 @@ describe("SwitchModeTool", () => { await switchModeTool.handle(mockTask, block, mockCallbacks) expect(mockCallbacks.askApproval).toHaveBeenCalled() - expect(mockHandleModeSwitch).toHaveBeenCalledWith("custom-mode") + expect(mockHandleModeSwitch).toHaveBeenCalledWith("custom-mode", mockTask) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( "Successfully switched from Code mode to Custom Mode mode because: testing custom modes.", ) @@ -328,17 +330,82 @@ describe("SwitchModeTool", () => { expect(mockCallbacks.askApproval).toHaveBeenCalledWith("tool", expectedMessage) }) - it("reads the current mode from the task when provider state differs", async () => { - vi.mocked(mockTask.getTaskMode).mockResolvedValue("architect") - mockGetState.mockResolvedValue({ mode: "orchestrator", customModes: [] }) + // ===== current mode source ===== + + it("should read the current mode from task.getTaskMode, not provider state", async () => { + // The provider state still reports the stale "code" mode while the task is actually in + // "architect". The switch report must use the task's own mode. + mockGetState.mockResolvedValue({ mode: "code", customModes: [] }) + mockGetTaskMode.mockResolvedValue("architect") const block = createBlock({ mode_slug: "code", reason: "switching back" }) await switchModeTool.handle(mockTask, block, mockCallbacks) - expect(mockHandleModeSwitch).toHaveBeenCalledWith("code") + expect(mockGetTaskMode).toHaveBeenCalledTimes(1) + expect(mockHandleModeSwitch).toHaveBeenCalledWith("code", mockTask) expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( "Successfully switched from Architect mode to Code mode because: switching back.", ) }) + + it("should use the default slug reported by task.getTaskMode when no mode is active", async () => { + // task.getTaskMode applies the default slug when the task has no explicit mode, so the + // tool reports switching from the default (Code) mode. + mockGetState.mockResolvedValue({ customModes: [] }) + mockGetTaskMode.mockResolvedValue("code") + + const block = createBlock({ mode_slug: "ask", reason: "test" }) + + await switchModeTool.handle(mockTask, block, mockCallbacks) + + expect(mockGetTaskMode).toHaveBeenCalledTimes(1) + expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith( + "Successfully switched from Code mode to Ask mode because: test.", + ) + }) + + // SwitchModeTool routes the switch through task.providerRef.deref(): when the + // provider was already disposed the deref is undefined, and the tool must report + // the failure instead of a false success (the provider cannot execute the switch). + it("should report an error when the provider reference is already released", async () => { + const toolTask = { + consecutiveMistakeCount: 0, + recordToolError: vi.fn(), + didToolFailInCurrentTurn: false, + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"), + ask: vi.fn().mockResolvedValue({}), + getTaskMode: vi.fn().mockResolvedValue("code"), + providerRef: { + deref: vi.fn().mockReturnValue(undefined), + }, + } as unknown as Task // structural double: the tool only reads the fields above + const callbacks: ToolCallbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), + toolCallId: "test-call-id", + } + const block = { + type: "tool_use" as const, + name: "switch_mode" as const, + params: { mode_slug: "architect", reason: "test" }, + partial: false, + nativeArgs: { mode_slug: "architect", reason: "test" }, + } as unknown as ToolUse<"switch_mode"> // mirrors the createBlock helper above + + await switchModeTool.handle(toolTask, block, callbacks) + + expect(callbacks.handleError).not.toHaveBeenCalled() + expect(toolTask.recordToolError).toHaveBeenCalledWith("switch_mode") + expect(toolTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + JSON.stringify({ + status: "error", + message: "The tool execution failed", + error: "Cannot switch mode: the provider is no longer available.", + }), + ) + expect(callbacks.pushToolResult).not.toHaveBeenCalledWith(expect.stringContaining("Successfully switched")) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3d88df632b..dd6dc704fe 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -17,6 +17,7 @@ import { type GlobalState, type ProviderName, type ProviderSettings, + type ProviderSettingsWithId, type RooCodeSettings, type ProviderSettingsEntry, type StaticAppProperties, @@ -59,6 +60,7 @@ import { isRetiredProvider, modelIdKeys, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -112,7 +114,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -139,6 +141,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 @@ -219,6 +229,10 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 + private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -351,6 +365,41 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Whether this instance wrote an entry under its own session-local temporary id. + * Temporary ids restart from 0 on every extension host start, so an entry under the + * same name can belong to a different session. Only re-key entries this instance wrote. + */ + private wroteUnderTemporaryViewStateId = false + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + + /** + * This view's pinned profile name from the view-local buffer. Exposed for + * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` + * is private and must not be reached through bracket access. + */ + get pinnedProfileName(): string | undefined { + return this.viewLocalState.currentApiConfigName + } + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.90.0-providers-tools-reasoning" // v3.90.0 providers and models, safer tool execution, reasoning and streaming @@ -366,14 +415,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) @@ -398,6 +450,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) => { @@ -520,6 +575,490 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy with corrupt + * entries dropped. + * 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. + * A corrupt entry (null or a non-object) is dropped here rather than reaching the + * prune sort (which dereferences updatedAt) or the repoint destructuring, both of + * which would throw and reject the surrounding mutation. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return Object.create(null) + } + + // Null prototype: a view id that names an Object.prototype key ("constructor", + // "toString", "valueOf", ...) must not resolve through the prototype chain and + // be mistaken for a persisted entry. + const states: Record = Object.create(null) + let dropped = 0 + + for (const [viewId, entry] of Object.entries(viewStates)) { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + states[viewId] = entry + } else { + dropped++ + } + } + + if (dropped > 0) { + this.log( + `[getPersistedViewStates] dropped ${dropped} invalid viewStates ${dropped === 1 ? "entry" : "entries"}`, + ) + } + + return states + } + + /** + * Persists this view's non-secret selections through the serialized write queue. + * The write re-reads the map fresh and merges into the existing entry, removing the + * entry entirely when nothing persistable remains, so concurrent views cannot clobber it. + * The entry is keyed by the view id active when the change was made. Writes captured + * while the provider still holds its temporary (pre-launch) id persist under that id + * and are re-keyed to the stable view id when the webview registers one, so a change + * that lands before the launch message stays durable instead of being lost. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + // The first write to an unowned temporary id claims the slot instead of merging + // into it: temporary names restart from 0 on every host start, so an entry under + // this name can be a leftover from an earlier session, and merging would adopt its + // fields and later re-key them under the stable id. Entries under a stable id, or + // under the temporary id this instance already authored, keep the merge semantics. + const ownsTargetEntry = viewStateId !== this.viewId || this.wroteUnderTemporaryViewStateId + const current = ownsTargetEntry ? (states[viewStateId] ?? {}) : {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + let wroteEntry = false + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + wroteEntry = true + } + + try { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected write leaves the cache claiming an + // entry that never reached disk. Restore the cache to the value durable + // storage actually holds (undefined when the key was never written); the + // cache update lands even if this compensating write also fails, so + // readers cannot observe the lost entry either way. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + // Authorship is recorded only after the durable write lands: a failed persistence + // must not mark the temporary id as owned, or a later load would adopt an entry + // that was never actually stored. + if (wroteEntry && viewStateId === this.viewId) { + this.wroteUnderTemporaryViewStateId = true + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Re-points persisted view pins that reference a removed profile so views do not + * rehydrate a missing profile name after a reload. Runs through the serialized + * write queue like every other viewStates mutation. + */ + private async repointPersistedViewStates( + removedProfileName: string, + replacementProfileName: string, + ): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + let changed = false + + for (const [viewId, entry] of Object.entries(states)) { + if (entry?.currentApiConfigName !== removedProfileName) { + continue + } + + changed = true + const { currentApiConfigName: _removed, ...rest } = entry + + if (rest.mode) { + states[viewId] = { ...rest, currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } else { + states[viewId] = { currentApiConfigName: replacementProfileName, updatedAt: Date.now() } + } + } + + if (changed) { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(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) + .filter(([, entry]) => !!entry && typeof entry === "object") + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + + /** + * Re-keys this provider's temporary pre-launch viewStates entry to the newly + * registered stable id so pre-launch writes become durable under the stable key + * instead of orphaning under a session-local temporary id. Only the provider's own + * temporary id is eligible: an entry under a previously registered stable id belongs + * to that webview's storage and is left alone. When the stable entry already exists + * it wins and the temporary entry is dropped, because temporary ids are session + * counters that can collide across window reloads. Runs through the serialized write + * queue like every other viewStates mutation. + */ + private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { + const previousViewStateId = this.viewId + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + // An entry under this name can belong to a different session, because temporary + // ids restart from 0 on every extension host start. Only re-key entries this + // instance wrote, otherwise a stale entry from another session is adopted. + if (!this.wroteUnderTemporaryViewStateId) { + return + } + + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + } + + try { + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected re-key would leave the cache serving + // the stable entry while durable storage still holds the temporary one. + // Restore the cache to the value durable storage actually holds (the same + // rollback savePersistedViewState applies), so a later load or a retry of + // the registration sees the pre-rekey entry. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } + + // Only a successful write clears authorship: a failed attempt leaves the entry + // under the temporary name, and a retry must still be able to re-key it. + this.wroteUnderTemporaryViewStateId = false + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Resolves once this view's stable state id has been registered (setViewStateId) and + * its persisted view state loaded, or when the provider disposes. API-driven task + * creation awaits this (bounded by a timeout) so a task cannot consume shared + * default mode/profile state before the view's own persisted selections are loaded. + */ + private viewStateReadinessResolve!: () => void + + // Created eagerly in the field initializer rather than lazily in a getter: the + // resolver is consumed by the first setViewStateId registration or by dispose, + // so a promise created after that point would never settle and startNewTask's + // readiness race would fall through to its timeout every time. + public readonly viewStateReadiness: Promise = new Promise((resolve) => { + this.viewStateReadinessResolve = resolve + }) + + /** + * True once dispose has started. Dispose resolves viewStateReadiness to release + * waiters, so API-driven flows awaiting readiness must consult this flag to tell a real + * readiness apart from a teardown: a disposed provider must never create a task. + */ + public get isDisposed(): boolean { + return this._disposed + } + + /** + * True once a sidebar view or panel has been resolved for this provider. Headless API + * callers can create tasks without any view: those must not wait on the webview + * readiness barrier (nothing will ever launch), while a resolved view must. + */ + public get hasResolvedView(): boolean { + return this.view !== undefined + } + + /** + * Registers this provider's stable view identifier and loads any persisted selections it owns. + * The identifier is sanitized so it remains a safe object key in the shared viewStates map. + */ + public async setViewStateId(viewStateId: string | undefined): Promise { + // The webview generates this id on purpose - it has to survive a webview reload, + // which recreates the view on both sides - so the host cannot mint it instead. + // What the host owns is which view may hold a given persisted key, so everything + // below validates the supplied value before it becomes one. + if (typeof viewStateId !== "string") { + // A crafted postMessage can carry any JSON value here even though the message + // type declares a string; the persisted key must stay a string. + this.log(`[setViewStateId] Ignoring a non-string viewStateId for view ${this.viewId}.`) + // The view falls back to shared state; do not leave API-driven task creation + // waiting on a registration that will not happen. + this.viewStateReadinessResolve() + return + } + + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject "__proto__": writing states["__proto__"] would go through the + // Object.prototype setter and be silently dropped by the later spread. + normalizedViewStateId === "__proto__" + ) { + // No stable id will be registered for this view (no usable id in the launch + // message, or this exact id was already registered): the view falls back to + // shared state, and API-driven task creation must not time out waiting on a + // view state that never registers. Resolve now; the promise is idempotent. + this.viewStateReadinessResolve() + return + } + + // Bound the key: the webview generates a UUID, so a much longer value is not a + // view id, and an unbounded key is unbounded growth in the shared viewStates map. + if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { + this.log( + `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, + ) + // The view falls back to shared state; do not leave API-driven task creation + // waiting on a registration that will not happen. + this.viewStateReadinessResolve() + return + } + + const claimant = ClineProvider.getAllInstances().find( + (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, + ) + if (claimant) { + // Ownership check: a key another live view holds must not be adopted. Doing so + // would load that view's pinned profile, nested overlay, and mode into this + // provider, and this webview could then overwrite that view's persisted entry. + // Staying on the temporary id keeps this view working against the shared state. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, + ) + // The view falls back to shared state; do not leave API-driven task creation + // waiting on a registration that will not happen. + this.viewStateReadinessResolve() + return + } + + const previousViewStateId = this.viewStateId + + this.viewStateId = normalizedViewStateId + + try { + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) + + 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 + } finally { + // The stable id is now registered (or restored): API-driven task creation + // can proceed against this view's loaded state. + this.viewStateReadinessResolve() + } + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + * Fields mutated while the async profile lookup is in flight are reapplied on top of the + * loaded state, field by field, so in-flight user selections are not clobbered by the load. + */ + private async loadViewState(): Promise { + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + + // While the stable id has not been registered yet, the key is this instance's + // session-local name. Temporary names restart from 0 on every host start, so an + // entry under the same name can belong to a different session. Load only entries + // this instance authored. + if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { + return + } + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + // Snapshot the in-memory buffer before the async profile lookup. The + // mutation paths update viewLocalState in place, so a shallow copy is + // what makes fields mutated during the load window observable below. + const preLoadBuffer = { ...this.viewLocalState } + + if (persisted?.mode) { + // A persisted mode may reference a custom mode that was deleted after it was + // pinned: restore it only when the slug still resolves, so a stale slug cannot + // shadow the shared mode from getState(). + const customModes = await this.customModesManager.getCustomModes() + if (getModeBySlug(persisted.mode, customModes)) { + loadedState.mode = persisted.mode as Mode + } else { + this.log(`[loadViewState] Ignoring unknown persisted mode "${persisted.mode}"`) + } + } + + if (persisted?.currentApiConfigName) { + loadedState.currentApiConfigName = persisted.currentApiConfigName + + try { + const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ + name: persisted.currentApiConfigName, + }) + loadedState.apiConfiguration = apiConfiguration as ProviderSettings + } catch (error) { + this.log( + `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (this.viewStateId !== loadedForViewId) { + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return + } + + // Reapply the buffer fields that changed while the load was in flight, + // tracking the change instead of testing against undefined: a field cleared + // during the load window must stay cleared (the loaded value must not + // resurrect it), and a field written to a new value must win over it. + // Untouched fields keep the persisted values authoritative, and the + // pre-load buffer is never merged wholesale so stale temporary-id state + // cannot override the stable persisted state. + const postLoadBuffer = this.viewLocalState + const mergedState: Partial = { ...loadedState } + + if (!Object.is(preLoadBuffer.mode, postLoadBuffer.mode)) { + if (postLoadBuffer.mode === undefined) { + delete mergedState.mode + } else { + mergedState.mode = postLoadBuffer.mode + } + } + + if (!Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName)) { + if (postLoadBuffer.currentApiConfigName === undefined) { + delete mergedState.currentApiConfigName + } else { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } + } + + if (!Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration)) { + if (postLoadBuffer.apiConfiguration === undefined) { + delete mergedState.apiConfiguration + } else { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } + } + + this.viewLocalState = mergedState + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Saves a single view-local state value. The in-memory buffer is always updated; the + * non-secret subset (mode, currentApiConfigName) is persisted durably under the view + * id active when the change was made, re-keyed to the stable id on registration. + */ + public async saveViewState( + key: K, + value: ViewLocalStateValues[K] | undefined, + ): Promise { + await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -854,69 +1393,81 @@ export class ClineProvider } this._disposed = true - this._postStateToWebviewThrottled.cancel() - this.log("Disposing ClineProvider...") - - // Reject any tasks still waiting for a scheduler permit so they don't - // hold the event loop after the provider is torn down. - this.taskScheduler.cancelQueued() - - // Clear all tasks from the stack. The first pop goes through evictCurrentTask() - // so an active delegated child is marked interrupted before the extension shuts down, - // rather than being left persisted as "active" across the reload. - if (this.taskRegistry.length > 0) { - const task = this.taskRegistry.current! - await this.evictCurrentTask() - await this.drainTaskDisposal(task) - } - while (this.taskRegistry.length > 0) { - const task = this.taskRegistry.current! - await this.removeClineFromStack() - await this.drainTaskDisposal(task) - } - this.log("Cleared all tasks") + try { + // A disposed provider will never register a stable view state id: release + // any API-driven task creation that is awaiting viewStateReadiness. + this.viewStateReadinessResolve() + this._postStateToWebviewThrottled.cancel() + this.log("Disposing ClineProvider...") + + // Reject any tasks still waiting for a scheduler permit so they don't + // hold the event loop after the provider is torn down. + this.taskScheduler.cancelQueued() + + // Clear all tasks from the stack. The first pop goes through evictCurrentTask() + // so an active delegated child is marked interrupted before the extension shuts down, + // rather than being left persisted as "active" across the reload. + if (this.taskRegistry.length > 0) { + const task = this.taskRegistry.current! + await this.evictCurrentTask() + await this.drainTaskDisposal(task) + } + while (this.taskRegistry.length > 0) { + const task = this.taskRegistry.current! + await this.removeClineFromStack() + await this.drainTaskDisposal(task) + } - // Clear all pending edit operations to prevent memory leaks - this.clearAllPendingEditOperations() - this.log("Cleared pending operations") + this.log("Cleared all tasks") - if (this.view && "dispose" in this.view) { - this.view.dispose() - this.log("Disposed webview") - } + // Clear all pending edit operations to prevent memory leaks + this.clearAllPendingEditOperations() + this.log("Cleared pending operations") - this.clearWebviewResources() + if (this.view && "dispose" in this.view) { + this.view.dispose() + this.log("Disposed webview") + } - // Clean up cloud service event listener - if (CloudService.hasInstance()) { - CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) - } + this.clearWebviewResources() - while (this.disposables.length) { - const x = this.disposables.pop() + // Clean up cloud service event listener + if (CloudService.hasInstance()) { + CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) + } - if (x) { - x.dispose() + while (this.disposables.length) { + const x = this.disposables.pop() + + if (x) { + x.dispose() + } } - } - this._workspaceTracker?.dispose() - this._workspaceTracker = undefined - await this.mcpHub?.unregisterClient() - this.mcpHub = undefined - await this.skillsManager?.dispose() - this.skillsManager = undefined - await this.marketplaceManager?.cleanup() - this.customModesManager?.dispose() - this.taskHistoryStore.dispose() - this.log("Disposed all disposables") - ClineProvider.activeInstances.delete(this) + this._workspaceTracker?.dispose() + this._workspaceTracker = undefined + await this.mcpHub?.unregisterClient() + this.mcpHub = undefined + await this.skillsManager?.dispose() + this.skillsManager = undefined + await this.marketplaceManager?.cleanup() + this.customModesManager?.dispose() + this.taskHistoryStore.dispose() + this.log("Disposed all disposables") + } finally { + // Deregistration must not depend on any cleanup step completing: a + // throwing disposable anywhere in dispose() (e.g. an MCP client whose + // disposal rejects) would otherwise leave this provider registered + // while _disposed is already set, so no later retry could repair the + // registration. + ClineProvider.activeInstances.delete(this) - // Clean up any event listeners attached to this provider - this.removeAllListeners() + // Clean up any event listeners attached to this provider + this.removeAllListeners() - McpServerManager.unregisterProvider(this) + McpServerManager.unregisterProvider(this) + } } public get isViewVisible(): boolean { @@ -931,6 +1482,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() @@ -1265,7 +1826,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, @@ -1475,11 +2039,25 @@ export class ClineProvider return } - try { - await this.view?.webview.postMessage(omitOriginalContentFromExtensionMessage(message)) - } catch { - // View disposed, drop message silently + const webview = this.view?.webview + if (!webview) { + return } + + // Dispatch without awaiting the renderer ack: VS Code settles postMessage only when the + // webview page acknowledges the message, and a page reload or view dispose in flight + // orphans that promise forever. Awaiting it could wedge every caller on the task critical + // path (e.g. the trailing postStateToWebview in handleModeSwitchUnlocked gates the next + // turn after a mode switch). Message ordering is enforced by the message seq, not the ack. + // Promise.resolve() normalizes non-promise returns (e.g. test doubles) before the catch. + void Promise.resolve(webview.postMessage(omitOriginalContentFromExtensionMessage(message))).catch((error) => { + // Swallow: postMessage rejects when the webview is disposed in flight. + // Log the dropped message type so a wedged webview channel is diagnosable + // instead of silently losing state updates. + this.log( + `[postMessageToWebview] dropped message type=${message.type}: ${error instanceof Error ? error.message : String(error)}`, + ) + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -1719,8 +2297,20 @@ export class ClineProvider * @param newMode The mode to switch to * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. + * A task that is not this view's focused task only receives the task-scoped effects + * (history entry + in-memory mode): the view's durable mode, the ModeChanged + * broadcast, and profile activation keep applying to the focused task's selection. + * Unknown mode slugs are ignored (logged + no-op) so unvalidated callers (the webview + * "mode" message sends message.text as Mode) cannot persist invalid modes. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { + const targetMode = getModeBySlug(newMode, await this.customModesManager.getCustomModes()) + + if (!targetMode) { + this.log(`[ClineProvider#handleModeSwitch] ignoring unknown mode "${newMode}"`) + return + } + return this.enqueueProviderProfileMutation((signal) => this.handleModeSwitchUnlocked(newMode, targetTask, signal), ) @@ -1733,9 +2323,21 @@ export class ClineProvider ): Promise { const task = targetTask + // Captured for the pin-write rollback below: the mode this task held before + // the switch (bracket access: _taskMode is private to Task). + const previousTaskMode = task ? task["_taskMode"] : undefined + let rollbackTaskWrites: (() => Promise) | undefined + // A cancelled or timed-out switch must not be partially applied: bail out + // before the task history / _taskMode writes as well as the durable mode + // write below. Aborts that land while the history write is in flight are + // handled in flight (the landed write is rolled back); the pre-write check + // further down still covers the remaining gap before the durable write. + if (signal?.aborted) { + return + } + if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) - task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) try { // Update the task history with the new mode first. @@ -1743,10 +2345,53 @@ export class ClineProvider if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) + + // An abort that lands while the history write is in flight has + // already persisted the new mode: restore the pre-switch item and + // bail before the in-memory task write and the emit, so task + // history, task state, and provider mode cannot diverge. A failed + // rollback must not surface as the persistence error of the + // cancelled switch: log it and keep the cancellation result. + if (signal?.aborted) { + try { + // Restore only the field this switch changed: re-read the item so + // fields the running task persisted during the pending window + // (tokens, cost, status, apiConfigName) survive the rollback. + const latest = this.getTaskHistoryItem(task.taskId) ?? taskHistoryItem + await this.updateTaskHistory({ ...latest, mode: taskHistoryItem.mode }) + } catch (rollbackError) { + this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) + } + return + } + } else if (signal?.aborted) { + return } + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode + + // If the view's durable mode pin below fails after these task-side writes + // landed, restore them: the caller delivers the message on the caught + // error, and the new task mode must not run against the view's unchanged + // mode and mode-linked API configuration. + const historyItemBefore = taskHistoryItem + rollbackTaskWrites = async () => { + task["_taskMode"] = previousTaskMode + // Listeners were told about the new mode before the failure landed: re-emit + // with the restored mode so API/IPC consumers track the rollback. + if (previousTaskMode) { + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, previousTaskMode) + } + if (historyItemBefore) { + // Restore only the field this switch changed: re-read the item so + // fields the running task persisted during the write survive. + const latest = this.getTaskHistoryItem(task.taskId) ?? historyItemBefore + await this.updateTaskHistory({ ...latest, mode: historyItemBefore.mode }) + } + } } catch (error) { // If persistence fails, log the error but don't update the in-memory state. this.log( @@ -1759,71 +2404,228 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) - - this.emit(RooCodeEventName.ModeChanged, 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 + } - // If workspace lock is on, keep the current API config — don't load mode-specific config - const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) - if (lockApiConfigAcrossModes) { - if (targetTask !== null) { - await this.postStateToWebview() + // A mode switch requested for a task that is not this view's focused task applies + // only to that task (history entry + in-memory mode): pinning the view's durable + // mode, broadcasting ModeChanged, or activating a profile on behalf of a + // background task would clobber the focused task's selection. + const viewScopedSwitch = task === undefined || task === null || this.getCurrentTask() === task + // Captured for the reconcile below: the view's pre-switch durable mode pin + // (undefined means the view never pinned a mode) and the pre-switch shared + // mode. The pin write only touches the view buffer and the per-view store, so + // reading the shared value here is unaffected by it. + const previousViewMode = this.viewLocalState.mode + const previousMode = this.getValue("mode") + let pinLanded = false + let sharedAttempted = false + + // Explicit partial-failure behavior: every failure after the task-mode write + // rolls back all writes this switch owns - shared mode, view pin, task mode, + // and the task-history entry - so the caller (Task#submitUserMessage) delivers + // the submitted message on the pre-switch mode and profile instead of a + // half-applied switch that runs the new mode against the unchanged profile. + // Each restore is awaited on its own and logged on failure, never swallowed. + const reconcileSwitchWrites = async () => { + if (sharedAttempted) { + // The shared write may have landed before its promise rejected: restore + // it unconditionally so getValues() cannot mix a fresh shared mode with + // the rolled-back view state. + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + if (pinLanded) { + try { + await this.saveViewState("mode", previousViewMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back the view mode pin after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + } + if (rollbackTaskWrites) { + try { + await rollbackTaskWrites() + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back task mode writes after the view mode pin write failed: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } } - return } - if (signal?.aborted) return + // Once the mode-linked activation below has started writing shared state, a + // mode-only rollback would pair the old mode with the new profile: the catch must + // then keep the switch instead of reconciling. + let activationStarted = false + try { + if (viewScopedSwitch) { + // Pin the view's durable per-view mode first: the pin is this view's own + // selection. If any write below fails, the reconcile above restores every + // write that landed, including this pin, so a delivered message never runs + // the new task mode against an unchanged view mode or mode-linked profile. + await this.saveViewState("mode", newMode) + pinLanded = true + + // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState + // buffer stays in sync with the durable global write: getValues() merges + // viewLocalState on top of the ContextProxy values, so an unsynced stale + // restored mode would otherwise shadow the fresh switch for consumers. + sharedAttempted = true + await this.setValue("mode", newMode) + } - // Load the saved API config for the new mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) - const listApiConfig = await this.providerSettingsManager.listConfig() + // If workspace lock is on, keep the current API config: the mode-linked + // activation below is skipped, and the committed switch falls through to the + // emit/post tail outside the reconciled region. + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (!lockApiConfigAcrossModes) { + // A background-task switch applies to the task only: no view pin, no + // ModeChanged, and no mode-linked activation on its behalf. + if (!viewScopedSwitch) { + return + } - if (signal?.aborted) return + // Load the saved API config for the new mode if it exists. + const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const listApiConfig = await this.providerSettingsManager.listConfig() + + // The pin and shared mode writes above have committed the mode: an abort + // here skips the remaining activation work but falls through to the + // emit/post tail so listeners and the webview learn the committed mode. + if (!signal?.aborted) { + await this.updateGlobalState("listApiConfigMeta", listApiConfig) + + // If this mode has a saved config, use it. + if (savedConfigId) { + const profile = listApiConfig.find(({ id }) => id === savedConfigId) + + if (profile?.name) { + // Check if the profile has actual API configuration (not just an id). + // In CLI mode, the ProviderSettingsManager may return empty default profiles + // that only contain 'id' and 'name' fields. Activating such a profile would + // overwrite the CLI's working API configuration with empty settings. + // Skip activation if the profile has no apiProvider set - this indicates + // an unconfigured/empty profile. + const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) + const hasActualSettings = !!fullProfile.apiProvider - // Update listApiConfigMeta first to ensure UI has latest data. - await this.updateGlobalState("listApiConfigMeta", listApiConfig) + if (hasActualSettings) { + // The committed-switch guard flips only when the activation has + // actually written the activated profile: a lookup failure (e.g. a + // deleted profile) throws before any write and must still reconcile. + await this.activateProviderProfileUnlocked( + { name: profile.name }, + { + ...(targetTask === null ? { skipCurrentTaskRebuild: true } : {}), + onSharedWriteStart: () => { + activationStarted = true + }, + }, + signal, + ) + } else { + // The task will continue with the current/default configuration. + } + } else { + // The task will continue with the current/default configuration. + } + } else { + // If no saved config for this mode, save current config as default. + // Use this view's effective profile: with per-view pins the shared slot can + // belong to a sibling view, and recording that profile as this mode's default + // would activate the wrong profile for every later switch to it. + const currentApiConfigNameAfter = this.getValues().currentApiConfigName - // If this mode has a saved config, use it. - if (savedConfigId) { - const profile = listApiConfig.find(({ id }) => id === savedConfigId) + if (currentApiConfigNameAfter) { + const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) - if (profile?.name) { - // Check if the profile has actual API configuration (not just an id). - // In CLI mode, the ProviderSettingsManager may return empty default profiles - // that only contain 'id' and 'name' fields. Activating such a profile would - // overwrite the CLI's working API configuration with empty settings. - // Skip activation if the profile has no apiProvider set - this indicates - // an unconfigured/empty profile. - const fullProfile = await this.providerSettingsManager.getProfile({ name: profile.name }) - const hasActualSettings = !!fullProfile.apiProvider - - if (hasActualSettings) { - await this.activateProviderProfileUnlocked( - { name: profile.name }, - targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, - signal, - ) - } else { - // The task will continue with the current/default configuration. + if (config?.id) { + await this.providerSettingsManager.setModeConfig(newMode, config.id) + } + } + } } - } else { - // The task will continue with the current/default configuration. } - } else { - // If no saved config for this mode, save current config as default. - const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") - - if (currentApiConfigNameAfter) { - const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) - - if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) + } catch (error) { + // Two reasons to keep the switch instead of reconciling: once the mode-linked + // activation has started writing shared state, a mode-only rollback would pair + // the old mode with the new profile; and after a timeout abort the queue has + // advanced, so a newer mutation may own these fields and replaying the + // pre-switch values would clobber it. + if (!activationStarted && !signal?.aborted) { + await reconcileSwitchWrites() + } else if (signal?.aborted) { + // After an abort the mutation queue has advanced: a newer mutation may + // already own the mode fields, so emitting here could announce a mode the + // newer mutation replaced - listeners are intentionally not notified on + // this path. + this.log(`[handleModeSwitch] Skipping rollback for aborted switch to "${newMode}"`) + } else { + // The activation had already written the activated profile: the switch is + // kept. Listeners must still learn the committed mode before the rethrow, + // and the webview must refresh in case the activation failed before its + // own state post. + if (viewScopedSwitch) { + this.emit(RooCodeEventName.ModeChanged, newMode) + } + if (targetTask !== null) { + try { + await this.postStateToWebview() + } catch (postError: unknown) { + this.log( + `[handleModeSwitch] Mode switch to "${newMode}" kept but the state post failed: ${ + postError instanceof Error ? postError.message : String(postError) + }`, + ) + } } } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + + // The switch has committed: notify listeners only now, so a reconciled failure + // never leaves listeners tracking a mode the stores rolled back from. After an + // abort the mutation queue has advanced: announce only while this switch's + // committed mode is still current - a superseded commit stays silent because + // the newer mutation already announced its own mode. + if (signal?.aborted && this.getValues().mode !== newMode) { + return + } + if (viewScopedSwitch) { + this.emit(RooCodeEventName.ModeChanged, newMode) } + // The switch has committed: a UI-refresh failure must not roll it back, so the + // trailing post runs outside the reconciled region and its failure is only logged. if (targetTask !== null) { - await this.postStateToWebview() + try { + await this.postStateToWebview() + } catch (postError: unknown) { + this.log( + `[handleModeSwitch] Mode switch to "${newMode}" committed but the state post failed: ${ + postError instanceof Error ? postError.message : String(postError) + }`, + ) + } } } @@ -2015,11 +2817,28 @@ export class ClineProvider if (signal.aborted) return id + // saveConfig normalizes the record (schema parse, legacy downgrades): + // read the stored copy back and use it for every activation write, + // sibling refresh, overlay, and task-handler update below, so raw + // webview fields the profile schema strips cannot enter the shared + // store, sibling view state, or task configuration. When the read-back + // fails the caller's settings are kept: the record landed, and the + // next load re-reads the stored copy. + let normalizedSettings = providerSettings + try { + const { + name: _savedName, + id: _savedId, + ...storedRecord + } = await this.providerSettingsManager.getProfile({ name }) + normalizedSettings = storedRecord as ProviderSettings + } catch { + // Best-effort read-back; see the comment above. + } + if (activate) { const { mode } = await this.getState() - if (signal.aborted) return id - // These promises do the following: // 1. Adds or updates the list of provider profiles. // 2. Sets the current provider profile. @@ -2030,12 +2849,27 @@ export class ClineProvider // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. - // `allSettled` so a caller that rolls back after a failure never races a write still in flight. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + + // Same sibling protection as the activation path: the shared write + // below replaces the shared store, so pin other profiles' settings + // into the views that reference them first. + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) + + // `allSettled` so the caller's error path never races a write still in flight. const activationResults = await Promise.allSettled([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), + this.updateGlobalState("listApiConfigMeta", listApiConfigMeta), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain global write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), - this.contextProxy.setProviderSettings(providerSettings), + this.contextProxy.setProviderSettings(normalizedSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", @@ -2043,20 +2877,39 @@ export class ClineProvider if (failedActivation) throw failedActivation.reason - if (signal.aborted) return id + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the updated profile's + // name with stale settings. + await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) // Keep the current task's sticky provider profile in sync with the newly-activated profile. await this.persistStickyProviderProfileToCurrentTask(name) } else { - await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + await this.updateGlobalState("listApiConfigMeta", listApiConfigMeta) + this._updateViewLocalStateFromMutation({ listApiConfigMeta }) + + // A save that does not activate still changes the profile's settings, and + // every view pinned to it buffers a full copy of them. Refresh those views + // (and this one when it is the pinned view) or their getState() keeps + // serving the settings captured before the save under the same name. + if (this.pinnedProfileName === name) { + await this._saveViewLocalStateFromMutation({ apiConfiguration: normalizedSettings }) + // Only rebuild when the task actually runs under the saved profile: a + // view pinned to this profile can hold a task that follows a different + // sticky profile, and the forced rebuild would silently switch that + // conversation to the saved profile's settings. + if (this.getCurrentTask()?.taskApiConfigName === name) { + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) + } + } + await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) } - if (signal.aborted) return id - await this.postStateToWebview() return id } @@ -2073,13 +2926,132 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // Commit the secret deletion before the compensating writes. If the deletion + // fails, nothing else runs: the durable pins still reference the profile that + // still exists and the shared list still contains it. Staging the re-point or + // the list prune before the deletion would let a failed deleteConfig leave a + // shared list missing a profile that is still stored, with the pins already + // moved to the replacement. + // 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 is still pruned as an idempotent success, while any other failure + // (e.g. refusing to delete the last remaining configuration) propagates before + // the compensating writes run. Matching message text instead would let a + // profile whose name contains "not found" swallow an unrelated failure. + try { + await this.providerSettingsManager.deleteConfig(profileToDelete.name) + } catch (error) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + throw error + } + this.log( + `deleteProviderProfile: settings for '${profileToDelete.name}' were not found; pruning the stale list entry only`, + ) + } + + // Only after the deletion has committed: re-point the durable view pins. A + // re-point failure now leaves at most a dangling pin naming a profile whose + // entry no longer exists, which the view load tolerates, instead of corrupting + // the shared list when the deletion itself failed. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + + const viewPinsDeletedProfile = + this.viewLocalState.currentApiConfigName === profileToDelete.name || + // No view-local pin: the view follows the shared selection, so it only needs the + // replacement activation when that shared selection referenced the deleted profile; + // an unrelated deletion must not rebuild this view's task handler. + (this.viewLocalState.currentApiConfigName === undefined && + globalSettings.currentApiConfigName === profileToDelete.name) + + // Prune the profile from the shared list after the deletion has committed when + // this view keeps an unrelated pin: the activation path below writes the list + // itself, because it re-reads the profile entries from the settings store. + if (!viewPinsDeletedProfile) { + const survivingEntries = this.getProviderProfileEntries().filter( + ({ name }) => name !== profileToDelete.name, + ) + await this.contextProxy.setValue("listApiConfigMeta", survivingEntries) + } + + if (viewPinsDeletedProfile) { + // Apply the replacement through the activation path so this view's + // viewLocalState.apiConfiguration and the current task's api handler are + // refreshed; a name-only update would leave the deleted profile's settings + // behind in both. + await this.activateProviderProfile({ name: profileToActivate }) + + // The return below would otherwise skip the sibling re-pin further down: + // the activation's pinned-name refresh only selects siblings pinned to + // the replacement, so live views still buffered on the deleted profile + // must be re-pointed here as well, with the replacement's settings when + // they can be resolved (a name-only re-pin is still better than leaving + // the deleted name behind). + let replacementSettings: ProviderSettings | undefined + try { + const { name: _replacementName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + replacementSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + await this.rePinViewLocalStateForDeletedProfile( + profileToDelete.name, + profileToActivate, + replacementSettings, + ) + return + } + + // This view pins an unrelated profile, which must survive the deletion. The shared + // list was already staged above the secret deletion (see the staging block at the + // top of this method), so only the replacement wiring remains here: resolve the + // surviving profile's settings so the shared provider keys and any other live + // view still pinned to the deleted profile can be updated with a matching + // configuration. - await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, - listApiConfigMeta: entries, - }) + let survivingSettings: ProviderSettings | undefined + try { + const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + + // This view does not pin the deleted profile here (that case returned + // above), so only the shared selection can dangle: when the deleted profile + // was the globally selected one, the shared slot and the shared provider + // settings still carry its configuration. + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName + + if (deletedWasGlobal) { + // The shared selection changed, but this view's own pin (if any) still + // names a surviving profile: update the shared store only, leaving the + // view-local pin untouched. + await this.contextProxy.setValue("currentApiConfigName", profileToActivate) + } + + if (deletedWasGlobal && survivingSettings) { + // The deleted profile was the globally active one, so the shared + // provider keys still carry its settings; replace them so getState() + // reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + } + + // Re-pin other live views still buffered on the deleted profile: their + // buffer and durable viewStates entry would otherwise keep serving the + // deleted profile's name and configuration. + await this.rePinViewLocalStateForDeletedProfile(profileToDelete.name, profileToActivate, survivingSettings) await this.postStateToWebview() } @@ -2114,12 +3086,62 @@ export class ClineProvider } } + /** + * Snapshot the settings of sibling views pinned to a different profile into + * their view-local overlays before an activation or upsert overwrites the + * shared provider-settings store. `getState()` merges the overlay over the + * shared store, so a sibling pinned to another profile with no overlay would + * otherwise start reporting its pin's name together with the newly activated + * profile's settings - and a task created from that view would run against + * the wrong provider, model, and credentials. Siblings pinned to the + * activated profile are refreshed by `refreshViewLocalStateForUpdatedProfile`; + * unpinned siblings follow the shared slot, where name and settings move + * together. + */ + private async snapshotSiblingOverlaysBeforeSharedSettingsWrite(activatedProfileName: string): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => + instance !== this && + instance.pinnedProfileName !== undefined && + instance.pinnedProfileName !== activatedProfileName && + instance.viewLocalState.apiConfiguration === undefined, + ) + + await Promise.all( + affected.map(async (instance) => { + const pinName = instance.pinnedProfileName + if (pinName === undefined) { + return + } + + try { + const { name: _siblingPinName, ...settings } = await this.providerSettingsManager.getProfile({ + name: pinName, + }) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: settings as ProviderSettings }) + await instance.postStateToWebview() + } catch (error) { + // If the pinned profile cannot be resolved, leave the overlay as is: + // the deletion path re-points such views, and a wrong snapshot is + // worse than the pre-write shared values. + this.log( + `[snapshotSiblingOverlaysBeforeSharedSettingsWrite] Unable to resolve profile '${pinName}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }), + ) + } + async activateProviderProfile( args: { name: string } | { id: string }, options?: { persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + preserveForeignTask?: boolean }, ) { return this.enqueueProviderProfileMutation((signal) => @@ -2133,11 +3155,20 @@ export class ClineProvider persistModeConfig?: boolean persistTaskHistory?: boolean skipCurrentTaskRebuild?: boolean + preserveForeignTask?: boolean + // Fires once the activation has written the activated profile to shared + // state, so callers can mark the switch committed at the true point of no + // return (a profile lookup failure must stay reconcilable). + onSharedWriteStart?: () => void }, signal?: AbortSignal, ): Promise { const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) + // The manager has now written the activated profile: from here on a failure + // must keep the switch rather than roll the mode back onto the old profile. + options?.onSharedWriteStart?.() + if (signal?.aborted) return const persistModeConfig = options?.persistModeConfig ?? true @@ -2146,11 +3177,31 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. + const listApiConfigMeta = await this.providerSettingsManager.listConfig() + + // Siblings pinned to other profiles must keep their own settings: the + // shared write below replaces the shared store, so pin their profile's + // settings into their overlays first. + await this.snapshotSiblingOverlaysBeforeSharedSettingsWrite(name) + await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), + this.contextProxy.setValue("listApiConfigMeta", listApiConfigMeta), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain ContextProxy write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) + + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the activated profile's + // name with stale settings. + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } const { mode } = await this.getState() @@ -2159,8 +3210,18 @@ export class ClineProvider await this.providerSettingsManager.setModeConfig(mode, id) } - // Change the provider for the current task. - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild }) + // Change the provider for the current task. A launch-time pin repair + // (preserveForeignTask) must not switch a task that follows a different + // sticky profile: the repair deliberately leaves the task's sticky profile + // untouched, so its handler and credentials must stay on that profile too. + const foreignTaskFollowed = + options?.preserveForeignTask === true && + this.getCurrentTask()?.taskApiConfigName !== undefined && + this.getCurrentTask()?.taskApiConfigName !== name + this.updateTaskApiHandlerIfNeeded(providerSettings, { + forceRebuild: true, + skipCurrentTaskRebuild: skipCurrentTaskRebuild || foreignTaskFollowed, + }) // Update the current task's sticky provider profile, unless this activation is // being used purely as a non-persisting restoration (e.g., reopening a task from history). @@ -2177,6 +3238,145 @@ export class ClineProvider } } + /** + * Refresh the view-local apiConfiguration buffer of the other live views + * pinned to the given profile. An upsert/activation rewrites the profile's + * settings in the shared store and the store-backed manager, but a view + * whose buffer loaded the profile earlier keeps shadowing the stale + * settings in its getState() until its own next mutation. The originating + * view refreshes its buffer at the mutation site itself. + */ + private async refreshViewLocalStateForUpdatedProfile( + name: string, + providerSettings: ProviderSettings, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === name, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + + // The active task of a pinned view holds its own API handler built from + // the profile's settings: rebuild it with the saved settings or the + // running conversation keeps sending the old token even though the + // view's buffer has caught up. Only rebuild when the task actually runs + // under the saved profile: a task that follows a different sticky + // profile must keep its own settings. Direct private access across + // sibling instances of the same class is compile-time safe. + if (instance.getCurrentTask()?.taskApiConfigName === name) { + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + } + await instance.postStateToWebview() + }), + ) + } + + /** + * Re-pin the other live views whose buffer still names a deleted profile: + * without this their in-memory buffer and durable viewStates entry keep + * serving the deleted profile's name and configuration on top of the + * surviving shared state. Each affected view's durable entry is re-pinned + * through the serialized write queue, so the rename survives reloads. + */ + private async rePinViewLocalStateForDeletedProfile( + deletedProfileName: string, + replacementName: string, + replacementSettings: ProviderSettings | undefined, + ): Promise { + const affected = ClineProvider.getAllInstances().filter( + (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + if (replacementSettings) { + values.apiConfiguration = replacementSettings + } + + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation(values) + // The active task of a view pinned to the deleted profile holds its own API + // handler and sticky profile name built from the deleted profile: rebuild + // the handler with the replacement's settings and re-point the sticky name, + // or the running conversation keeps sending the deleted profile's + // configuration even though the view's buffer was re-pointed (the posted + // state prefers the task's own apiConfiguration). Only re-point when the + // task actually runs under the deleted profile: a view pinned to it can + // hold a task that follows a different sticky profile, and forcing the + // replacement would silently switch that conversation and rewrite its + // persisted profile name without the user asking. + if (instance.getCurrentTask()?.taskApiConfigName === deletedProfileName) { + if (replacementSettings) { + instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(replacementName) + } + await instance.postStateToWebview() + }), + ) + } + + /** + * Re-point live views still pinned to a profile that is being renamed onto + * the new name. A rename keeps the profile id, so a view pinned to the old + * name should follow the record instead of falling back to the shared + * selection: without this, the pin dangles at a name that no longer + * resolves and the buffered overlay keeps serving the pre-rename copy. + * Must run before the old name is deleted. + */ + public async repointViewLocalStateAfterProfileRename( + oldName: string, + newName: string, + renamedSettings: ProviderSettingsWithId | undefined, + ): Promise { + // Closed views keep their pin only in the persisted map: re-point those entries + // first so a view that launches after an intervening activation still follows the + // renamed profile instead of dangling at the deleted name. + await this.repointPersistedViewStates(oldName, newName) + const affected = ClineProvider.getAllInstances().filter((instance) => instance.pinnedProfileName === oldName) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + // Assigning the settings (even undefined) is what replaces or clears the + // overlay: the renamed record's current settings are the only correct + // overlay for a pin that now names the new profile. + await instance._saveViewLocalStateFromMutation({ + currentApiConfigName: newName, + apiConfiguration: renamedSettings, + }) + // A task running under the old name holds its own API handler and sticky + // profile name: re-point both, or the conversation keeps sending the + // pre-rename configuration under a name that no longer resolves. + if (instance.getCurrentTask()?.taskApiConfigName === oldName) { + if (renamedSettings) { + instance.updateTaskApiHandlerIfNeeded(renamedSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(newName) + } + await instance.postStateToWebview() + }), + ) + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) @@ -2312,8 +3512,10 @@ export class ClineProvider // picks up the new token immediately for the current task. await this.upsertProviderProfile(entry.name, updated, true) } else { - // Non-active profiles just need the token saved to disk. - await this.providerSettingsManager.saveConfig(entry.name, updated) + // Non-active profiles need the token saved to disk and the views pinned + // to them refreshed: a direct saveConfig leaves a pinned view serving the + // old token until it reloads. + await this.upsertProviderProfile(entry.name, updated, false) } } } @@ -3056,12 +4258,18 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // Merge viewLocalState on top of global state so a provider can serve + // state values scoped to its own view while preserving ContextProxy defaults. + const mergedStateValues = { ...stateValues, ...this.viewLocalState } + const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. + // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = - stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) - ? stateValues.apiProvider + mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) + ? mergedStateValues.apiProvider : providerIdentifiers.anthropic // Build the apiConfiguration object combining state values and secrets. @@ -3122,124 +4330,140 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // A view-local profile is a complete snapshot, not a patch over the shared store. + // Spreading it on top of providerSettings would let a key the profile never defined + // leak in from another profile, which ContextProxy.setProviderSettings deliberately + // prevents by clearing omitted keys. Use the view's own settings in place and take + // only apiProvider from the shared state when the profile does not carry one. + // The overlay is read from the buffer itself: the raw apiConfiguration state key + // is a partial patch from shared-store paths, not a view snapshot. + const viewApiConfiguration = this.viewLocalState.apiConfiguration + const resolvedApiConfiguration = viewApiConfiguration + ? { + ...viewApiConfiguration, + apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, + } + : providerSettings + return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - allowedReadFiles: stateValues.allowedReadFiles ?? [], - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - allowedWriteFiles: stateValues.allowedWriteFiles ?? [], - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + apiConfiguration: resolvedApiConfiguration, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysDenyUnapprovedCommands: - stateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + mergedStateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, collapseCommandOutputByDefault: - stateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, + mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - 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, } } @@ -3303,7 +4527,36 @@ export class ClineProvider } public async setValue(key: K, value: RooCodeSettings[K]) { + // Mirror the setValues guard for the single-key write path: the updateSettings + // webview handler routes every key through setValue, so an unknown mode must not + // bypass the shared validation and reach persistence via the viewStates write. + if (key === "mode" && value !== undefined) { + // The generic signature keeps the nominal RooCodeSettings[K] even after K is + // narrowed to "mode" (runtime type string | undefined); one assertion to the + // narrowed shape lets the shared guard type-check, mirroring setValues. + const modeValue = value as string | undefined + if ( + typeof modeValue !== "string" || + !getModeBySlug(modeValue, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValue] Ignoring invalid mode "${String(value)}"`) + return + } + } + + // Capture the previous shared value before the ContextProxy write so a failed + // durable viewStates write can roll it back: otherwise the shared mode/profile + // would remain persisted without this view's pin. + const previousValue = this.contextProxy.getValue(key) + await this.contextProxy.setValue(key, value) + + try { + await this._saveViewLocalStateFromMutation({ [key]: value }) + } catch (error) { + await this.contextProxy.setValue(key, previousValue) + throw error + } } public getValue(key: K) { @@ -3311,11 +4564,186 @@ 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. + // A payload that carries its own customModes (createTask and setConfiguration + // call setValues before registering them) validates against those too, so a + // brand-new custom mode in the same payload is not silently dropped. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, [ + ...(await this.customModesManager.getCustomModes()), + ...(sanitizedValues.customModes ?? []), + ]) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } + } + + // Capture the previous shared value for every key being written — including keys + // being cleared (value undefined) — so a failed durable viewStates write can roll + // the ContextProxy mutation back, restoring cleared values as well. + const previousValues = Object.fromEntries( + (Object.keys(sanitizedValues) as (keyof RooCodeSettings)[]).map( + (key) => [key, this.contextProxy.getValue(key)] as [string, unknown], + ), + ) as Partial + + await this.contextProxy.setValues(sanitizedValues) + + try { + await this._saveViewLocalStateFromMutation(sanitizedValues) + } catch (error) { + await this.contextProxy.setValues(previousValues as RooCodeSettings) + throw error + } + } + + /** + * Persists the view-local subset of a ContextProxy mutation, then updates the in-memory + * viewLocalState buffer. Persistence is awaited first so a failed durable write cannot + * leave the local cache ahead of the persisted state. + */ + private async _saveViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + await this._persistViewLocalStateFromMutation(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial & Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val + } + } + + if ("apiConfiguration" in values) { + const val = values.apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } + + // Flat provider-settings keys (PROVIDER_SETTINGS_KEYS) are shared settings: + // they are written through the ContextProxy above and must NOT be merged + // into viewLocalState.apiConfiguration, which would turn them into a + // per-view override masking later shared updates from other views. If the + // mutation carries such a key, drop this view's overlay instead so it cannot + // mask the just-written shared values in getState() (the overlay, seeded by + // a persisted pin or a sibling refresh, is older than this shared write). + if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + delete this.viewLocalState.apiConfiguration + } + } + + /** + * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) + * into the registered viewStates map for this view. + */ + private async _persistViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + const persistedValues: Partial = {} + + if ("mode" in values) { + persistedValues.mode = values.mode as PersistedViewState["mode"] + } + + if ("currentApiConfigName" in values) { + persistedValues.currentApiConfigName = values.currentApiConfigName + } + + if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { + await this.savePersistedViewState(persistedValues) + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} + } + + /** + * Broadcast a reset/import invalidation to all live ClineProvider instances, clearing + * both in-memory view-local caches and durable per-view selections so stale view state + * cannot mask imported/reset shared state after reload. + * + * The durable clear runs on the serialized viewStates write queue so it is ordered + * against every in-flight savePersistedViewState: a queued save that ran after a + * direct clear would re-read the emptied map and re-create its captured per-view pin, + * leaving a stale selection that rehydrates after a reload. The in-memory buffer is + * cleared only after the queued clear has run, because a save enqueued before it + * writes its captured value back into the buffer when its durable write completes. + */ + async broadcastResetToAllInstances(): Promise { + const allInstances = ClineProvider.getAllInstances() + + // The durable per-view selections live in one shared global-state slot, so + // the reset needs exactly one durable clear. Route it through the persisted + // write queue (and run it before the in-memory clears): a per-instance clear + // outside the queue could interleave with a queued view-state write and + // either clobber that write or leave a re-created entry behind. + const clearWrite = ClineProvider.persistedViewStateWriteQueue.then(async () => { + await this.contextProxy.setValue("viewStates", undefined) + }) + ClineProvider.persistedViewStateWriteQueue = clearWrite.catch(() => {}) + await clearWrite + + for (const instance of allInstances) { + // Clear the in-memory buffer only after the queued durable clear has run: + // a save enqueued before the clear writes its captured value back into the + // buffer when its durable write completes, so an immediate clear would let + // that stale pin survive in the live state. + instance._clearViewLocalState() + + if (instance !== this) { + // A sibling's post can throw mid-reset (state generation reaches the + // settings file through customModesManager.getCustomModes): the failure + // must not stop the reset from reaching the remaining instances, whose + // buffers are already cleared above. The originator is deliberately not + // posted here: resetState posts its own state immediately after the + // broadcast, and a second post would refresh its webview twice. + try { + await instance.postStateToWebview() + } catch (error) { + this.log( + `[broadcastResetToAllInstances] failed to post reset state to a sibling view: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } + } } // dev @@ -3344,9 +4772,21 @@ 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() + + // Clear durable and in-memory per-view state across live instances so parallel tabs don't keep stale state. + await this.broadcastResetToAllInstances() + await this.postStateToWebview() await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } @@ -3484,6 +4924,13 @@ export class ClineProvider configuration: RooCodeSettings = {}, ): Promise { if (configuration) { + // Capture both selection layers a registration failure must roll back to: + // setValues below persists the payload's mode to the shared store AND the + // per-view record while the payload's custom modes are still unregistered. + // The pin is read defensively because createTask is also driven through + // structural provider doubles that carry no view-local buffer. + const previousMode = this.contextProxy.getValue("mode") + const previousViewMode = this.viewLocalState?.mode await this.setValues(configuration) if (configuration.allowedCommands) { @@ -3517,8 +4964,32 @@ export class ClineProvider // when it merges .roomodes + global settings on refresh. Persisting // via updateCustomMode ensures modes survive the merge cycle. if (configuration.customModes?.length) { - for (const mode of configuration.customModes) { - await this.customModesManager.updateCustomMode(mode.slug, mode) + try { + for (const mode of configuration.customModes) { + await this.customModesManager.updateCustomMode(mode.slug, mode) + } + } catch (error) { + // setValues already persisted the payload's mode to shared and per-view + // state; if registration fails the mode is not actually usable, so each + // layer rolls back to its own prior value (an unset pin stays unset) + // before the failure surfaces. Each restore is awaited on its own and + // logged on failure, never swallowed; the original failure is what + // callers see. + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[ClineProvider#createTask] Failed to restore the shared mode after custom-mode registration failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + try { + await this.saveViewState("mode", previousViewMode) + } catch (rollbackError) { + this.log( + `[ClineProvider#createTask] Failed to restore the view mode pin after custom-mode registration failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + throw error } } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 3121fe1046..8ebfdfd5c6 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -241,9 +241,15 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { new WebviewFocusTracker(), ) - // Mock providerSettingsManager + // Mock providerSettingsManager. saveConfig repoints the CURRENT getProfile mock at + // the record it wrote: the real manager reads back the stored record, and + // persistProviderProfile builds its activation writes from that read-back. + const currentGetProfile = () => provider["providerSettingsManager"].getProfile as ReturnType ;(provider as any).providerSettingsManager = { - saveConfig: vi.fn().mockResolvedValue("test-id"), + saveConfig: vi.fn().mockImplementation(async (name: string, config: Record) => { + currentGetProfile().mockResolvedValue({ name, id: "test-id", ...config }) + return "test-id" + }), listConfig: vi.fn().mockResolvedValue([ { name: "test-config", diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts new file mode 100644 index 0000000000..30d6e14ec5 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,2286 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.parallelMode.spec.ts + +import * as vscode from "vscode" + +import { + type ProviderSettingsEntry, + type ProviderSettingsWithId, + type RooCodeSettings, + PROVIDER_SETTINGS_KEYS, + RooCodeEventName, + providerIdentifiers, +} from "@roo-code/types" + +import { ContextProxy } from "../../config/ContextProxy" +import { WebviewFocusTracker } from "../WebviewFocusTracker" +import { ClineProvider } from "../ClineProvider" +import { TelemetryService } from "@roo-code/telemetry" + +import type { Task } from "../../task/Task" + +// Mock p-wait-for +vi.mock("p-wait-for", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock fs/promises +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + const mocked = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + } + + return { + ...actual, + ...mocked, + default: { + ...actual, + ...mocked, + }, + } +}) + +// Mock axios +vi.mock("axios", () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), + }, + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), +})) + +// Mock safeWriteJson +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + +// Mock path utils +vi.mock("../../../utils/path", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getWorkspacePath: vi.fn().mockReturnValue(""), + } +}) + +// Mock storage utils +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +// Mock MCP types +vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ + CallToolResultSchema: {}, + ListResourcesResultSchema: {}, + ListResourceTemplatesResultSchema: {}, + ListToolsResultSchema: {}, + ReadResourceResultSchema: {}, + ErrorCode: { + InvalidRequest: "InvalidRequest", + MethodNotFound: "MethodNotFound", + InternalError: "InternalError", + }, + McpError: class McpError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.name = "McpError" + this.code = code + } + }, +})) + +// Mock delay +vi.mock("delay", () => { + const delayFn = (_ms: number) => Promise.resolve() + delayFn.createDelay = () => delayFn + delayFn.reject = () => Promise.reject(new Error("Delay rejected")) + delayFn.range = () => Promise.resolve() + return { default: delayFn } +}) + +// Mock MCP client +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + __esModule: true, + Client: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + callTool: vi.fn().mockResolvedValue({ content: [] }), + } + }), +})) + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ + __esModule: true, + StdioClientTransport: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +const { onDidChangeConfigurationMock } = vi.hoisted(() => { + const onDidChangeConfigurationMock = vi.fn( + (handler: (e: { affectsConfiguration: (key: string) => boolean }) => void) => { + const disposable = { + dispose: vi.fn(), + } + const checkedKeys: string[] = [] + void handler({ + affectsConfiguration: (key: string) => { + checkedKeys.push(key) + return false + }, + }) + + if (checkedKeys.includes("workbench.colorTheme")) { + onDidChangeConfigurationMock.mock.calls.pop() + } + + return disposable + }, + ) + + return { onDidChangeConfigurationMock } +}) + +// Mock vscode +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + EventEmitter: vi.fn().mockImplementation(function () { + return { + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + } + }), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + Range: class Range { + constructor( + readonly startLine: number, + readonly startCharacter: number, + readonly endLine: number, + readonly endCharacter: number, + ) {} + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + getWorkspaceFolder: vi.fn(), + createFileSystemWatcher: vi.fn().mockReturnValue({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + onDidChangeConfiguration: onDidChangeConfigurationMock, + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + activeTextEditor: undefined, + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + tabGroups: { + onDidChangeTabs: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +// Mock TTS utils +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +// Mock API +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +// Mock system prompt +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +// Mock WorkspaceTracker - simple mock that works (same pattern as sticky-mode.spec.ts) +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(function () { + return { + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + } + }), +})) +// Mock ContextProxy for viewLocalState tests +vi.mock("../../config/ContextProxy", () => { + const defaultState = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + class MockContextProxy { + public globalStorageUri: { fsPath: string } + public extensionUri: { fsPath: string } + public extensionMode = 1 + /** + * Mirrors the real ContextProxy state cache: seeded from the store in the + * constructor (like initialize()), then mutated only through setValue, so + * getValue can return a stale value that diverges from direct store writes. + */ + private stateCache: Record = {} + + constructor(public context: vscode.ExtensionContext) { + this.globalStorageUri = context.globalStorageUri ?? { fsPath: "/test/storage/path" } + this.extensionUri = context.extensionUri ?? { fsPath: "/test/path" } + + for (const key of context.globalState.keys()) { + const value = context.globalState.get(key) + if (value !== undefined) { + this.stateCache[key] = value + } + } + } + + // Mirrors the real ContextProxy contract: every key in the state cache is + // served as-is, so values written via setValue round-trip — including the + // durable viewStates map that ClineProvider rewrites directly (a canned key + // list here would let a stale-snapshot regression hide from every test). + getValues = vi.fn().mockImplementation(() => ({ + ...defaultState, + ...this.stateCache, + })) + getValue = vi.fn().mockImplementation((key: string) => this.stateCache[key]) + // Mirrors the real ContextProxy contract: the flat provider-settings keys are served + // from the state cache, so values written via setProviderSettings round-trip. + // The apiProvider default mirrors the schema default for an unwritten store. + getProviderSettings = vi.fn().mockImplementation(() => { + const flat: Record = {} + for (const key of PROVIDER_SETTINGS_KEYS) { + const value = this.stateCache[key] + if (value !== undefined) { + flat[key] = value + } + } + return { apiProvider: providerIdentifiers.anthropic, ...flat } + }) + setValue = vi.fn().mockImplementation((key: string, value: unknown) => { + if (value === undefined || value === null) { + delete this.stateCache[key] + } else { + this.stateCache[key] = value + } + return this.context.globalState.update(key, value) ?? Promise.resolve() + }) + setValues = vi.fn().mockImplementation((values: Record) => { + return Promise.all(Object.entries(values).map(([key, value]) => this.setValue(key, value))).then( + () => undefined, + ) + }) + setProviderSettings = vi + .fn() + .mockImplementation((settings: Record) => this.setValues(settings)) + resetAllState = vi.fn().mockImplementation(() => { + const keys = this.context.globalState.keys() + return Promise.all(keys.map((key: string) => this.setValue(key, undefined))).then(() => undefined) + }) + } + return { ContextProxy: MockContextProxy } +}) + +// Mock Task +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(function (options?: { historyItem?: { id?: string } }) { + return { + api: undefined, + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + taskId: options?.historyItem?.id || "test-task-id", + emit: vi.fn(), + } + }), +})) + +// Mock extract-text +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockImplementation(async (_filePath: string) => { + const content = "const x = 1;\nconst y = 2;\nconst z = 3;" + const lines = content.split("\n") + return lines.map((line, index) => `${index + 1} | ${line}`).join("\n") + }), +})) + +// Mock model cache +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), + getModelsFromCache: vi.fn().mockReturnValue(undefined), +})) + +// Mock cloud service +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockResolvedValue([]), + getUserInfo: vi.fn().mockReturnValue(null), + getOrganizationSettings: vi.fn().mockReturnValue(null), + off: vi.fn(), + } + }, + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +// Mock modes +vi.mock("../../../shared/modes", async (importOriginal) => { + const actual = await importOriginal() + const modes = [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "debugger", + name: "Debugger Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are a helpful assistant", + groups: ["read"], + }, + ] + return { + ...actual, + modes, + // Resolve against the mocked mode list above (not the real module modes) so the + // lookup matches exactly what the tests set up. + getModeBySlug: vi.fn().mockImplementation((slug: string) => { + return modes.find((m) => m.slug === slug) ?? null + }), + defaultModeSlug: "code", + } +}) + +// Mock custom instructions +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), +})) + +// Mock zoo-code-auth +vi.mock("../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), + getCachedZooCodeToken: vi.fn(), + handleAuthCallback: vi.fn(), + setZooCodeUserInfo: vi.fn(), + disconnectZooCode: vi.fn(), +})) + +// Mock diff strategy +vi.mock("../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(function () { + return { + getToolDescription: () => "test", + getName: () => "test-strategy", + applyDiff: vi.fn(), + } + }), +})) + +// Mock Terminal +vi.mock("../../../integrations/terminal/Terminal", () => ({ + Terminal: { + defaultShellIntegrationTimeout: 10000, + setShellIntegrationTimeout: vi.fn(), + setShellIntegrationDisabled: vi.fn(), + setCommandDelay: vi.fn(), + setTerminalZshClearEolMark: vi.fn(), + setTerminalZshOhMy: vi.fn(), + setTerminalZshP10k: vi.fn(), + setPowershellCounter: vi.fn(), + setTerminalZdotdir: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +// Mock McpHub and McpServerManager +vi.mock("../../services/mcp/McpHub", () => ({ + McpHub: vi.fn().mockImplementation(function () { + return { + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + } + }), +})) + +vi.mock("../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue({ + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + }), + unregisterProvider: vi.fn(), + }, +})) + +// Mock SkillsManager +vi.mock("../../services/skills/SkillsManager", () => ({ + SkillsManager: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), +})) + +// Mock MarketplaceManager +vi.mock("../../services/marketplace", () => ({ + MarketplaceManager: vi.fn().mockImplementation(function () { + return { + cleanup: vi.fn(), + } + }), +})) + +// Mock ProviderSettingsManager +vi.mock("../../config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: vi.fn().mockImplementation(function () { + return { + saveConfig: vi.fn().mockResolvedValue("test-id"), + listConfig: vi.fn().mockResolvedValue([]), + getProfile: vi.fn().mockResolvedValue({}), + activateProfile: vi.fn().mockImplementation(async (args: { name?: string; id?: string }) => ({ + name: args.name ?? "default", + id: args.id ?? "test-id", + apiProvider: providerIdentifiers.anthropic, + })), + setModeConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + resetAllConfigs: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +// Mock CustomModesManager +vi.mock("../../config/CustomModesManager", () => ({ + CustomModesManager: vi.fn().mockImplementation(function () { + return { + updateCustomMode: vi.fn().mockResolvedValue(undefined), + getCustomModes: vi.fn().mockResolvedValue([]), + resetCustomModes: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), +})) + +// Mock task persistence +vi.mock("../../task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) + +vi.mock("../../task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getAll: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + migrateFromGlobalState: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), + assertValidTransition: vi.fn(), +})) + +// Mock RateLimitClock +vi.mock("../../task/RateLimitClock", () => ({ + createRateLimitClock: vi.fn().mockReturnValue({ + isRateLimited: vi.fn().mockReturnValue(false), + resetTimer: vi.fn(), + }), +})) + +beforeAll(() => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + +/** + * ClineProvider - Parallel Mode Support Tests + * + * These tests verify that the view-local state isolation feature works correctly, + * allowing multiple ClineProvider instances (e.g., in parallel tabs) to maintain + * independent mode, API configuration, and other view-specific settings. + */ +describe("ClineProvider - Parallel Mode Support", () => { + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + const secrets: Record = {} + + mockContext = Object.assign({} as vscode.ExtensionContext, { + extensionPath: "/test/path", + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => { + return globalState[key] + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => { + return Object.keys(globalState) + }), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => { + return secrets[key] + }), + store: vi.fn().mockImplementation((key: string, value: string) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + } as vscode.Uri, + }) + + mockOutputChannel = Object.assign({} as vscode.OutputChannel, { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + }) + }) + + const createMockWebviewView = (postMessage = vi.fn()) => + Object.assign({} as vscode.WebviewView, { + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + }) + + describe("persisted view state pruning edge cases", () => { + it("should drop the entry without updatedAt first when the cap is exceeded", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // An entry written before updatedAt existed ranks below every timestamped entry + // (updatedAt ?? 0) and is the first to fall off the cap. + const states = { + ...Object.fromEntries( + Array.from({ length: 50 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index + 1 }, + ]), + ), + "view-missing": { mode: "mode-legacy" }, + } + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-missing"]).toBeUndefined() + // Surviving entries must retain their complete persisted state, not just + // exist: pin the exact fields of the oldest and newest survivors. + expect(pruned["view-0"]).toEqual({ mode: "mode-0", updatedAt: 1 }) + expect(pruned["view-49"]).toEqual({ mode: "mode-49", updatedAt: 50 }) + + await provider.dispose() + }) + + it("should keep the earliest inserted entries when updatedAt values tie", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Equal timestamps preserve insertion order (stable sort), so the first 50 + // registered views survive and the last 5 fall off the cap. + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [`view-${index}`, { mode: `mode-${index}`, updatedAt: 1 }]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + // Surviving entries must retain their complete persisted state, not just + // exist: a prune that mutates a surviving entry must fail the test. + expect(pruned["view-0"]).toEqual({ mode: "mode-0", updatedAt: 1 }) + expect(pruned["view-49"]).toEqual({ mode: "mode-49", updatedAt: 1 }) + expect(pruned["view-50"]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop corrupt viewStates entries on read so subsequent writes do not reject", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // Corrupt entries (null, or a torn non-object value) can land in the durable map: + // they must be dropped at the read boundary, not throw in the prune sort when a + // mode/profile write re-reads the map fresh. + await mockContext.globalState.update("viewStates", { + "view-null": null, + "view-valid": { mode: "code", updatedAt: 1 }, + }) + + await provider["setViewStateId"]("view-writer") + await provider.saveViewState("mode", "architect") + + // One drop: the exact singular message (a substring assertion cannot tell + // "entry" from "entries"), so the wording is pinned verbatim. + expect(logSpy).toHaveBeenCalledWith("[getPersistedViewStates] dropped 1 invalid viewStates entry") + + // The first fresh read already removed the null entry, so seed two new corrupt + // entries (a torn non-object value and a null) for the next fresh read. + const states = mockContext.globalState.get("viewStates") as Record + // Write through the proxy so the provider's cache and the durable map stay + // in step for the next fresh read. + // Simulate a torn/corrupt durable write: the map type forbids such entries, + // which is exactly what a torn write can leave behind (double assertion). + await provider.contextProxy.setValue("viewStates", { + ...states, + "view-torn": "torn-write", + "view-null2": null, + } as unknown as RooCodeSettings["viewStates"]) + await provider.saveViewState("mode", "build") + + // Two drops: the exact plural message must survive the second fresh read. + expect(logSpy).toHaveBeenCalledWith("[getPersistedViewStates] dropped 2 invalid viewStates entries") + + const statesAfter = mockContext.globalState.get("viewStates") as Record + expect(statesAfter["view-null"]).toBeUndefined() + expect(statesAfter["view-torn"]).toBeUndefined() + expect(statesAfter["view-null2"]).toBeUndefined() + expect(statesAfter["view-valid"]).toEqual({ mode: "code", updatedAt: 1 }) + expect(statesAfter["view-writer"]).toEqual({ mode: "build", updatedAt: expect.any(Number) }) + + // A clean re-read must not log a drop: the log must stay tied to dropped > 0. + await provider.saveViewState("mode", "debugger") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("dropped 0")) + + // Release the provider from the static instance registry so later tests' cross- + // instance broadcasts do not iterate a provider bound to this test's context. + await provider.dispose() + }) + + it("should drop malformed stored entries before pruning", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Storage can hold corrupted entries (undefined holes or non-object values) after + // partial writes: the guard keeps them out of the sorted map so the prune cannot + // read updatedAt off a missing entry. + const states = Object.assign({} as Parameters[0], { + "view-void": undefined, + "view-corrupt": "not-an-object", + "view-good": { mode: "ask", updatedAt: 1 }, + }) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toEqual(["view-good"]) + expect(pruned["view-good"]).toEqual({ mode: "ask", updatedAt: 1 }) + + await provider.dispose() + }) + }) + + describe("durable editor view state retention (#1065)", () => { + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "tab-to-preserve": { mode: "architect" }, + }) + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "tab-to-preserve": { mode: "architect" }, + }) + }) + }) + + describe("profile mutations", () => { + it("should synchronize viewLocalState when activateProviderProfile mutates ContextProxy", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValueOnce({ + name: "new-profile", + id: "new-profile-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/new-model", + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([ + { id: "new-profile-id", name: "new-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + provider["viewLocalState"] = { + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + + await provider.activateProviderProfile({ name: "new-profile" }) + const state = await provider.getState() + + expect(saveViewStateSpy).not.toHaveBeenCalled() + expect(state.currentApiConfigName).toBe("new-profile") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/new-model", + }) + // The activation path clears (not seeds) the view-local overlay: the shared + // store serves the activated settings, so no per-view snapshot can mask + // later shared edits. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + + it("should synchronize viewLocalState when upsertProviderProfile activates a saved profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "test-id", name: "saved-profile", apiProvider: providerIdentifiers.bedrock }, + ]) + + // The activation write is built from the stored record (saveConfig normalization + // contract): repoint getProfile at what saveConfig wrote, mirroring the real + // manager's round-trip. + const savedRecords = new Map>() + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async (configName, config) => { + savedRecords.set(configName, { name: configName, id: "test-id", ...config }) + return "test-id" + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => { + const record = savedRecords.get(String(args.name)) + return (record ?? { name: args.name, id: "test-id" }) as unknown as Awaited< + ReturnType + > + }, + ) + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + provider["viewLocalState"] = { + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + + await provider.upsertProviderProfile("saved-profile", { + apiProvider: providerIdentifiers.bedrock, + awsRegion: "us-east-1", + }) + const state = await provider.getState() + + expect(saveViewStateSpy).not.toHaveBeenCalled() + expect(state.currentApiConfigName).toBe("saved-profile") + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.bedrock, + awsRegion: "us-east-1", + }) + // Same A-semantics contract: the upsert/activation path clears the overlay + // instead of snapshotting the activated settings into it. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + + it("should synchronize viewLocalState when deleteProviderProfile selects a replacement profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "deleted-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "deleted-id", name: "deleted-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + provider["viewLocalState"] = { + currentApiConfigName: "deleted-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Structural cast: the env mock shapes activateProfile results as getProfile results. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + } as unknown as Awaited>) + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: providerIdentifiers.anthropic, + }) + const state = await provider.getState() + + expect(state.currentApiConfigName).toBe("replacement-profile") + expect(state.listApiConfigMeta).toEqual([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // The replacement's settings are served from the shared store through + // getState(); the view-local overlay is cleared on the activation path, so + // it cannot keep serving the deleted profile's configuration. + expect(state.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + }) + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(vi.mocked(provider.providerSettingsManager.deleteConfig)).toHaveBeenCalledWith("deleted-profile") + + await provider.dispose() + }) + + it("should re-point persisted view pins that referenced a deleted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Two views have durable pins; one pins the profile about to be deleted. + await mockContext.globalState.update("viewStates", { + "view-keeps": { mode: "code", currentApiConfigName: "keeper-profile", updatedAt: 1 }, + "view-deleted": { mode: "architect", currentApiConfigName: "doomed-profile", updatedAt: 2 }, + }) + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // The affected pin is re-pointed to the replacement profile; the unrelated pin survives. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-keeps": { mode: "code", currentApiConfigName: "keeper-profile" }, + "view-deleted": { mode: "architect", currentApiConfigName: "keeper-profile" }, + }) + + await provider.dispose() + }) + + it("should keep the re-pointed viewStates entry when the deleting view pins a surviving profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // This view pins a surviving profile (unrelated pin: the fallback branch), + // while the shared selection names the profile about to be deleted. + provider["viewLocalState"] = { + currentApiConfigName: "keeper-profile", + } + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // A sibling's durable pin names the deleted profile: the re-point pass runs + // before the fallback's shared writes, and a stale full-snapshot replay would + // undo it by rewriting the pre-re-point map back into the store. + await mockContext.globalState.update("viewStates", { + "view-deleted": { mode: "architect", currentApiConfigName: "doomed-profile", updatedAt: 2 }, + }) + // Pin the branch itself: a view following a shared selection that names the + // deleted profile would take the activation path, so a pinned view must take + // the fallback. The fallback resolves the survivor's settings through the + // profile manager and writes them to the shared provider settings. + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation( + async (_args: { name?: string; id?: string }) => + ({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "keeper-key", + }) as unknown as Awaited>, + ) + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // The re-pointed sibling entry survives the fallback's targeted writes ... + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "view-deleted": { mode: "architect", currentApiConfigName: "keeper-profile" }, + }) + // ... the shared selection moves to the replacement ... + expect(mockContext.globalState.get("currentApiConfigName")).toBe("keeper-profile") + // ... and this view's own pin is left untouched. + expect(provider["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + // The view took the fallback, not the activation path ... + expect(activateSpy).not.toHaveBeenCalled() + // ... and the shared provider settings carry the survivor's configuration + // instead of the deleted profile's. + expect(provider.contextProxy.getProviderSettings()).toMatchObject({ + openRouterApiKey: "keeper-key", + }) + + await provider.dispose() + }) + + it("should not write viewStates when re-pointing skips a corrupt stored entry", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // The stored map pins the keeper profile and holds a corrupt (null) entry: no pin + // references the doomed profile, so the re-point pass must skip every entry and + // leave the map untouched. + const corruptFixture = { + "view-keeps": { mode: "code", currentApiConfigName: "keeper-profile", updatedAt: 1 }, + "view-null": null, + } + await mockContext.globalState.update("viewStates", corruptFixture) + provider["viewLocalState"] = { + currentApiConfigName: "keeper-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + expect(setValueSpy).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(mockContext.globalState.get("viewStates")).toEqual(corruptFixture) + expect(activateSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should re-point a mode-less legacy pin to a fresh two-field pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // A pre-mode-pinning legacy entry pins the doomed profile and carries an unrelated + // field: the re-pointed entry must be a fresh two-field pin, not a spread of the old one. + await mockContext.globalState.update("viewStates", { + "view-legacy": { currentApiConfigName: "doomed-profile", updatedAt: 2, legacyFlag: true }, + }) + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // The empty viewLocalState takes the unrelated-pin branch (the shared selection + // does not reference the deleted profile), so assert on the re-pointed entry + // rather than the whole map. + expect(mockContext.globalState.get("viewStates")).toEqual( + expect.objectContaining({ + "view-legacy": { currentApiConfigName: "keeper-profile", updatedAt: expect.any(Number) }, + }), + ) + + await provider.dispose() + }) + + it("should clear viewLocalState when resetState resets ContextProxy", async () => { + vi.mocked(vscode.window.showInformationMessage).mockImplementationOnce( + async (_message: string, _options: unknown, ...items: vscode.MessageItem[]) => items[0], + ) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + provider["viewLocalState"] = { + mode: "architect", + currentApiConfigName: "stale-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter }, + } + + await provider.resetState() + + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("should refresh the shared profile list when upsertProviderProfile saves without activating", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const quietEntry: ProviderSettingsEntry = { + id: "quiet-id", + name: "quiet-profile", + apiProvider: providerIdentifiers.openrouter, + } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([quietEntry]) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + const savedId = await provider.upsertProviderProfile( + "quiet-profile", + { apiProvider: providerIdentifiers.openrouter }, + false, + ) + + // The non-activating path persists the refreshed shared profile list under the durable + // key while leaving the current selection and profile activation untouched. + expect(savedId).toBe("test-id") + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual([quietEntry]) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("default") + + await provider.dispose() + }) + + it("should not activate for an unrelated deletion when the deleting view has no profile pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + const setValuesSpy = vi.spyOn(provider.contextProxy, "setValues") + + await provider.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.openrouter, + }) + + // A view without its own profile pin follows the shared selection, which does not + // reference the deleted profile: no activation (an unrelated deletion must not + // rebuild this view's task handler), and only a targeted shared-list write that + // leaves the shared selection and every other key untouched. + expect(activateSpy).not.toHaveBeenCalled() + expect(setValuesSpy).not.toHaveBeenCalled() + expect(mockContext.globalState.get("listApiConfigMeta")).toEqual([ + { id: "keeper-id", name: "keeper-profile", apiProvider: providerIdentifiers.anthropic }, + ]) + expect(mockContext.globalState.get("currentApiConfigName")).toBe("keeper-profile") + + await provider.dispose() + }) + + it("should activate the replacement when the shared selection references the deleted profile and the view has no pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "deleted-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "deleted-id", name: "deleted-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Structural cast: the env mock shapes activateProfile results as getProfile results. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + } as unknown as Awaited>) + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // The shared selection referenced the deleted profile, so the unpinned view takes + // the activation path and adopts the replacement profile. + expect(activateSpy).toHaveBeenCalledWith({ name: "replacement-profile" }) + expect(await provider.getState()).toMatchObject({ currentApiConfigName: "replacement-profile" }) + + await provider.dispose() + }) + + it("should refresh the pinned view through activation when it pinned the deleted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "deleted-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { id: "deleted-id", name: "deleted-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + provider["viewLocalState"] = { + currentApiConfigName: "deleted-profile", + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + } + const listConfigSpy = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // Structural cast: the env mock shapes activateProfile results as getProfile results. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + } as unknown as Awaited>) + const activateSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.deleteProviderProfile({ + id: "deleted-id", + name: "deleted-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // The pinning view must take the activation path with the replacement profile... + expect(activateSpy).toHaveBeenCalledWith({ name: "replacement-profile" }) + // ...and the shared store serves the replacement settings through + // getState(), while the cleared view-local overlay cannot mask them... + expect((await provider.getState()).apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "replacement-key", + }) + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + // ...and the unrelated-pin fallback is ruled out by the activation list + // lookup: the fallback reads the surviving entries from the store instead + // of resolving the activation list. + expect(listConfigSpy).toHaveBeenCalled() + + await provider.dispose() + }) + + it("should re-pin a live sibling view that pins the deleted profile when the deleting view has no pin", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // B pins the doomed profile without an overlay (a fresh pin via setValue). + await providerB.setValue("currentApiConfigName", "doomed-profile") + + // A has no profile pin of its own; the shared selection names the doomed profile. + await providerA.contextProxy.setValue("currentApiConfigName", "doomed-profile") + await providerA.contextProxy.setValue("listApiConfigMeta", [ + { id: "doomed-id", name: "doomed-profile", apiProvider: providerIdentifiers.anthropic }, + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + + // Structural cast: the env mock shapes activateProfile/getProfile results uniformly. + vi.spyOn(providerA.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + } as unknown as Awaited>) + vi.spyOn(providerA.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "replacement-id", name: "replacement-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + // B has an active task built from the doomed profile's settings: the posted + // state prefers the task's own apiConfiguration, so the re-pin must rebuild + // the task's handler and re-point its sticky profile name too. + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "doomed-key" }, + taskApiConfigName: "doomed-profile", + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + } + vi.spyOn(providerB, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + + // The pre-return re-pin resolves the replacement's settings through the manager. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation( + async (_args: { name?: string; id?: string }) => + ({ + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + }) as unknown as Awaited>, + ) + + await providerA.deleteProviderProfile({ + id: "doomed-id", + name: "doomed-profile", + apiProvider: providerIdentifiers.anthropic, + }) + + // B must be re-pointed at the replacement with the replacement's settings, + // not left buffered on the deleted profile's name and configuration. + expect(providerB["viewLocalState"].currentApiConfigName).toBe("replacement-profile") + expect(providerB["viewLocalState"].apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + }) + expect((await providerB.getState()).currentApiConfigName).toBe("replacement-profile") + // The active task's handler is rebuilt with the replacement's settings and + // its sticky profile name re-points to the replacement: without this the + // running conversation keeps sending the deleted profile's configuration. + // objectContaining: the replacement's settings carry the profile id alongside + // the provider fields (the name is stripped, the id is not). + expect(siblingTask.updateApiConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + apiProvider: providerIdentifiers.openrouter, + openRouterBaseUrl: "repl-url", + }), + ) + expect(siblingTask.setTaskApiConfigName).toHaveBeenCalledWith("replacement-profile") + + await providerA.dispose() + await providerB.dispose() + }) + }) + + describe("provider profile activation", () => { + it("should serve the activated profile settings through getState when activating an upserted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1", + }) + + const providerSettings = { + apiProvider: providerIdentifiers.zai, + zaiApiKey: "mock-key", + zaiApiLine: "international_api" as const, + apiModelId: "glm-5.1", + } + // Repoint the manager round-trip: the activation write reads the stored + // record back after saveConfig (normalization contract), so getProfile must + // serve what saveConfig wrote. + const savedRecords = new Map>() + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async (configName, config) => { + savedRecords.set(configName, { name: configName, id: "zai-profile-id", ...config }) + return "zai-profile-id" + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => { + const record = savedRecords.get(String(args.name)) + return (record ?? { name: args.name, id: "zai-profile-id" }) as unknown as Awaited< + ReturnType + > + }, + ) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "default", id: "zai-profile-id", apiProvider: providerIdentifiers.zai }, + ]) + + await provider.upsertProviderProfile("default", providerSettings, true) + + const state = await provider.getState() + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration).toMatchObject(providerSettings) + expect(state.apiConfiguration.apiProvider).toBe("zai") + expect(state.apiConfiguration).not.toHaveProperty("openRouterModelId") + // The upsert/activation path clears the view-local overlay rather than + // snapshotting the activated settings into it, so the shared store remains + // the single source getState() serves. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + + it("should keep a sibling pinned to another profile on its own settings after a different profile activates", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // B pins profile-y without an overlay (a fresh pin via setValue). + await providerB.setValue("currentApiConfigName", "profile-y") + + // A activates profile-x: the shared store takes x's settings. + vi.spyOn(providerA.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "profile-x", + id: "x-id", + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + } as unknown as Awaited>) + vi.spyOn(providerA.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "y-id", name: "profile-y", apiProvider: providerIdentifiers.openrouter }, + { id: "x-id", name: "profile-x", apiProvider: providerIdentifiers.anthropic }, + ]) + // The snapshot resolves each sibling's pinned profile through A's manager. + vi.spyOn(providerA.providerSettingsManager, "getProfile").mockImplementation( + async (args: { name?: string; id?: string }) => + args.name === "profile-y" + ? ({ + name: "profile-y", + id: "y-id", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "y-key", + } as unknown as Awaited>) + : ({ + name: "profile-x", + id: "x-id", + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + } as unknown as Awaited>), + ) + + await providerA.activateProviderProfile({ name: "profile-x" }) + + // A serves x's settings under its own pin... + const aState = await providerA.getState() + expect(aState.currentApiConfigName).toBe("profile-x") + // anthropicBaseUrl is a non-secret PROVIDER_SETTINGS_KEYS key, so the mock shared store serves + // it back through getProviderSettings(); the api key itself is a SECRET_STATE_KEYS entry and + // would not round-trip through the mock, so the fixture avoids it. + expect(aState.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "x-url", + }) + + // ...and B keeps its own pin on its own profile's settings: the snapshot + // written before the shared write must shield B from A's activation. + const bState = await providerB.getState() + expect(bState.currentApiConfigName).toBe("profile-y") + expect(bState.apiConfiguration).toMatchObject({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "y-key", + }) + expect(providerB["viewLocalState"].apiConfiguration).toBeDefined() + + await providerA.dispose() + await providerB.dispose() + }) + + it("should drop the view-local overlay when flat provider settings are written through setValues", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed a pinned overlay (as loadViewState would after a reload). + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "stale-key", + }) + expect(provider["viewLocalState"].apiConfiguration).toMatchObject({ openRouterApiKey: "stale-key" }) + + // A flat provider-settings write reaches the shared store and must not be + // masked by the pinned overlay in getState(). + await provider.setValues({ apiProvider: providerIdentifiers.zai }) + + const state = await provider.getState() + expect(state.apiConfiguration.apiProvider).toBe("zai") + expect(state.apiConfiguration).not.toHaveProperty("openRouterApiKey") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("handleModeSwitch integration", () => { + it("should update viewLocalState.mode when handleModeSwitch is called", async () => { + const postMessage = vi.fn() + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.resolveWebviewView(createMockWebviewView(postMessage)) + + const saveViewStateSpy = vi.spyOn(provider, "saveViewState") + + await provider.handleModeSwitch("architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(saveViewStateSpy).toHaveBeenCalledWith("mode", "architect") + + await provider.dispose() + }) + + it("should post state and skip mode config lookup when API config locking is enabled", async () => { + const postMessage = vi.fn() + mockContext.workspaceState.get = vi.fn().mockImplementation((key: string, fallback?: unknown) => { + return key === "lockApiConfigAcrossModes" ? true : fallback + }) + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const getModeConfigIdSpy = vi.spyOn(provider.providerSettingsManager, "getModeConfigId") + + await provider.resolveWebviewView(createMockWebviewView(postMessage)) + postMessage.mockClear() + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "state", state: expect.objectContaining({ mode: "architect" }) }), + ) + + await provider.dispose() + }) + + it("should activate configured mode profile when switching modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("profile-id") + const profileEntry: ProviderSettingsEntry = { + id: "profile-id", + name: "mode-profile", + apiProvider: providerIdentifiers.openrouter, + } + const profileSettings: ProviderSettingsWithId & { name: string } = { + id: "profile-id", + name: "mode-profile", + apiProvider: providerIdentifiers.openrouter, + } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([profileEntry]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce(profileSettings) + const activateProfileSpy = vi + .spyOn(provider.providerSettingsManager, "activateProfile") + .mockResolvedValueOnce(profileSettings) + + await provider.handleModeSwitch("architect") + + expect(activateProfileSpy).toHaveBeenCalledWith({ name: "mode-profile" }) + + await provider.dispose() + }) + + it("should leave current configuration unchanged for empty mode profiles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("empty-profile-id") + const profileEntry: ProviderSettingsEntry = { id: "empty-profile-id", name: "empty-profile" } + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([profileEntry]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce({ + id: "empty-profile-id", + name: "empty-profile", + }) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + await provider.handleModeSwitch("architect") + + expect(activateProfileSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + + it("should emit ModeChanged event after handleModeSwitch", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const modeChangedSpy = vi.fn() + + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + + await provider.handleModeSwitch("architect") + + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + await provider.dispose() + }) + + // A4 regression: non-focused target task + it("should scope mode switches for non-focused tasks to the task only", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + // Minimal Task double: handleModeSwitch and addClineToStack only touch the fields above. + await provider.addClineToStack(makeTask("focused-task") as unknown as Task) + const backgroundTask = makeTask("background-task") + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "code") + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + await provider.handleModeSwitch("architect", backgroundTask as unknown as Task) + // Task-scoped effects apply to the background task: + expect(backgroundTask.emit).toHaveBeenCalledWith( + RooCodeEventName.TaskModeSwitched, + "background-task", + "architect", + ) + expect(backgroundTask._taskMode).toBe("architect") + // ...but the view-level effects (durable mode pin, broadcast, profile) stay untouched: + expect(provider["viewLocalState"].mode).toBe("code") + expect(modeChangedSpy).not.toHaveBeenCalled() + expect(activateProfileSpy).not.toHaveBeenCalled() + await provider.dispose() + }) + + it("should log and no-op when handleModeSwitch receives an unknown mode slug", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const logSpy = vi.spyOn(provider, "log") + + await provider.handleModeSwitch("bogus-slug") + + // Unknown slugs are rejected before any durable write: nothing is persisted under + // the view and no profile mutation is queued. + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ignoring unknown mode "bogus-slug"')) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + // K1: a no-task switch captures its target before any task is focused; a task gains + // focus before the queued mutation runs. The view-level durable write and the + // ModeChanged broadcast belong to the view and must still happen. + it("should keep the view-level effects when a no-task switch is captured before a task gains focus", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + await provider["setViewStateId"]("k1-view") + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + // Minimal Task double: addClineToStack only touches the fields above, so the + // structural cast stands in for the omitted Task internals. + vi.spyOn(provider.customModesManager, "getCustomModes").mockImplementationOnce(async () => { + await provider.addClineToStack(makeTask("late-focus-task") as unknown as Task) + return [] + }) + + await provider.handleModeSwitch("architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toEqual( + expect.objectContaining({ + "k1-view": expect.objectContaining({ mode: "architect" }), + }), + ) + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + await provider.dispose() + }) + + // K2: a non-focused switch with a saved mode config in the mocks must not load it: + // the task-scoped switch applies to the task only, and the view-level profile load + // stays behind the view-scoped guard. + it("should skip the mode profile load for a non-focused switch even when a saved config exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["resolveWebviewView"](createMockWebviewView()) + const makeTask = (taskId: string) => ({ + taskId, + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn().mockResolvedValue(undefined), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + }) + // Minimal Task double: handleModeSwitch and addClineToStack only touch the fields above. + await provider.addClineToStack(makeTask("focused-task") as unknown as Task) + const backgroundTask = makeTask("background-task") + await provider["setViewStateId"]("k2-view") + await provider.saveViewState("mode", "code") + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + const k2Entry: ProviderSettingsEntry = { + id: "k2-profile-id", + name: "k2-profile", + apiProvider: providerIdentifiers.openrouter, + } + const k2Settings: ProviderSettingsWithId & { name: string } = { + id: "k2-profile-id", + name: "k2-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "k2-key", + } + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValueOnce("k2-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValueOnce([k2Entry]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValueOnce(k2Settings) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile") + + await provider.handleModeSwitch("architect", backgroundTask as unknown as Task) + + // The task-scoped switch still lands on the background task... + expect(backgroundTask.emit).toHaveBeenCalledWith( + RooCodeEventName.TaskModeSwitched, + "background-task", + "architect", + ) + // ...but the view-level profile load stays untouched: no mode profile lookup, no + // activation, no durable list rewrite, no viewStates write, and an unchanged buffer. + expect(provider.providerSettingsManager.getModeConfigId).not.toHaveBeenCalled() + expect(activateProfileSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([]) + expect(modeChangedSpy).not.toHaveBeenCalled() + expect(mockContext.globalState.get("viewStates")).toEqual( + expect.objectContaining({ + "k2-view": expect.objectContaining({ mode: "code" }), + }), + ) + expect(provider["viewLocalState"]).toEqual({ mode: "code" }) + + await provider.dispose() + }) + + it("should roll back the shared mode write when the durable write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The shared store already carries a mode, so the rollback has a concrete value + // to restore. + await provider.contextProxy.setValue("mode", "code") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + // Only the fresh durable mode write fails; the rollback write succeeds. + if (key === "mode" && value !== "code") throw new Error("durable write failed") + }) + // The provider's async initialization can record stray writes on the spy + // before this test's mutation starts; start the assertion window clean. + setValueSpy.mockClear() + + await expect(provider.handleModeSwitch("ask")).rejects.toThrow("durable write failed") + + // The failed write was rolled back to the previous shared mode, so the shared + // store cannot be left with the new mode's half-applied state. + const modeWrites = setValueSpy.mock.calls.filter(([key]) => key === "mode") + expect(modeWrites).toEqual([ + ["mode", "ask"], + ["mode", "code"], + ]) + + await provider.dispose() + }) + + it("should log the rollback failure and rethrow the original error when the rollback also fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "code") + const appendLine = vi.spyOn(mockOutputChannel, "appendLine") + const durableError = new Error("durable write failed") + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key) => { + if (key === "mode") throw durableError + }) + + await expect(provider.handleModeSwitch("ask")).rejects.toThrow("durable write failed") + + // The rollback failure is logged with its own message and the original error + // still propagates: a failed switch must not be swallowed into a logged no-op. + expect(appendLine).toHaveBeenCalledWith(expect.stringContaining("Failed to roll back shared mode")) + expect(appendLine).toHaveBeenCalledWith(expect.stringContaining('Failed to persist mode "ask"')) + + await provider.dispose() + }) + }) + + describe("multi-instance isolation", () => { + it("should maintain independent state across three instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider3 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1.saveViewState("mode", "code") + await provider1.saveViewState("currentApiConfigName", "profile-1") + await provider2.saveViewState("mode", "architect") + await provider2.saveViewState("currentApiConfigName", "profile-2") + await provider3.saveViewState("mode", "debugger") + await provider3.saveViewState("currentApiConfigName", "profile-3") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + const state3 = await provider3.getState() + + expect(state1.mode).toBe("code") + expect(state1.currentApiConfigName).toBe("profile-1") + expect(state2.mode).toBe("architect") + expect(state2.currentApiConfigName).toBe("profile-2") + expect(state3.mode).toBe("debugger") + expect(state3.currentApiConfigName).toBe("profile-3") + + await provider1.dispose() + await provider2.dispose() + await provider3.dispose() + }) + + it("should handle mode switch in one instance without affecting others", async () => { + const postMessage1 = vi.fn() + const postMessage2 = vi.fn() + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1.resolveWebviewView(createMockWebviewView(postMessage1)) + await provider2.resolveWebviewView(createMockWebviewView(postMessage2)) + await provider1.saveViewState("mode", "code") + await provider2.saveViewState("mode", "debugger") + + await provider1.handleModeSwitch("architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + expect(provider2["viewLocalState"].mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should keep resetting siblings when one sibling's state post fails", async () => { + vi.mocked(vscode.window.showInformationMessage).mockImplementationOnce( + async (_message: string, _options: unknown, ...items: vscode.MessageItem[]) => items[0], + ) + const caller = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const failingSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const laterSibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await caller.resolveWebviewView(createMockWebviewView()) + await laterSibling.resolveWebviewView(createMockWebviewView()) + await failingSibling.saveViewState("mode", "architect") + await laterSibling.saveViewState("mode", "debugger") + // State generation throws for the failing sibling mid-reset (e.g. through a + // settings-file write in customModesManager.getCustomModes): the broadcast must + // still reach the later sibling instead of aborting. + vi.spyOn(failingSibling, "postStateToWebview").mockRejectedValue(new Error("state generation failed")) + const logSpy = vi.spyOn(caller, "log") + + await caller.resetState() + + // The failing sibling's buffer was cleared before its post failed ... + expect(failingSibling["viewLocalState"]).toEqual({}) + // ... the broadcast continued to the later sibling and cleared it too ... + expect(laterSibling["viewLocalState"]).toEqual({}) + // ... and the failure was logged by the caller. + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("failed to post reset state to a sibling view")) + + await caller.dispose() + await failingSibling.dispose() + await laterSibling.dispose() + }) + + it("should order the broadcast clear after an in-flight view-state save", async () => { + const caller = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await caller.resolveWebviewView(createMockWebviewView()) + await saver.resolveWebviewView(createMockWebviewView()) + + // Stall the saver's durable write so the broadcast's clear lands while it is + // still in the serialized write queue: a direct (un-queued) clear would let + // the save run after the clear and re-create its captured per-view pin. The + // stalled write still reaches storage once the gate opens, so the storage + // assertion below only passes if the clear actually ran after the save. + let resolveWrite: (value: undefined) => void = () => {} + const writeGate = new Promise((resolve) => (resolveWrite = resolve)) + // The proxy's setValue is already a vi.fn: vi.spyOn on it would return the same + // function, so a delegating spy would recurse into itself. Wrap the mock's + // existing implementation instead: gate the non-clear write, then delegate to + // the original store write so the stalled save still reaches storage. + const setValueMock = vi.mocked(saver.contextProxy.setValue) + const originalSetValueImplementation = setValueMock.getMockImplementation() + setValueMock.mockImplementation(async (key, value): Promise => { + if (key === "viewStates" && value !== undefined) { + await writeGate + } + await originalSetValueImplementation?.(key, value) + }) + const setValueSpy = setValueMock + + const save = saver.saveViewState("mode", "architect") + await vi.waitFor(() => expect(setValueSpy).toHaveBeenCalledWith("viewStates", expect.anything())) + + const broadcast = caller.broadcastResetToAllInstances() + + // The clear is queued behind the in-flight save, so the broadcast only settles + // once the stalled write completes. + resolveWrite(undefined) + await save + await broadcast + + // The clear won: the save's captured pin was not resurrected after the reset. + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(saver["viewLocalState"]).toEqual({}) + + await caller.dispose() + await saver.dispose() + }) + + it("clears the buffer of a first-constructed saving provider after a queued reset", async () => { + // The saver is constructed first so the broadcast reaches its buffer before the + // caller does: with the clear queued behind the in-flight save, the buffer must + // still end empty and the durable map cleared even though the save writes its + // captured pin back into the buffer when its durable write completes. + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const caller = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await saver.resolveWebviewView(createMockWebviewView()) + await caller.resolveWebviewView(createMockWebviewView()) + + let resolveWrite: (value: undefined) => void = () => {} + const writeGate = new Promise((resolve) => (resolveWrite = resolve)) + // The proxy's setValue is already a vi.fn: vi.spyOn on it would return the same + // function, so a delegating spy would recurse into itself. Wrap the mock's + // existing implementation instead: gate the non-clear write, then delegate to + // the original store write so the stalled save still reaches storage. + const setValueMock = vi.mocked(saver.contextProxy.setValue) + const originalSetValueImplementation = setValueMock.getMockImplementation() + setValueMock.mockImplementation(async (key, value): Promise => { + if (key === "viewStates" && value !== undefined) { + await writeGate + } + await originalSetValueImplementation?.(key, value) + }) + const setValueSpy = setValueMock + + const save = saver.saveViewState("mode", "architect") + await vi.waitFor(() => expect(setValueSpy).toHaveBeenCalledWith("viewStates", expect.anything())) + + const broadcast = caller.broadcastResetToAllInstances() + + resolveWrite(undefined) + await save + await broadcast + + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(saver["viewLocalState"]).toEqual({}) + + await caller.dispose() + await saver.dispose() + }) + }) + + describe("_clearViewLocalState", () => { + it("should clear all view-local state values", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + }) + + // Call _clearViewLocalState + provider["_clearViewLocalState"]() + + // All values should be cleared + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("should cause getState to fall back to contextProxy values after clear", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + + let state = await provider.getState() + expect(state.mode).toBe("architect") + + // Clear viewLocalState + provider["_clearViewLocalState"]() + + // getState should now fall back to contextProxy (global) state + state = await provider.getState() + expect(state.mode).toBe("code") // Default from mock context + + await provider.dispose() + }) + + it("should be safe to call on empty viewLocalState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Should not throw even if viewLocalState is already empty + expect(provider["_clearViewLocalState"]()).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 93543435e2..5b2544fe8f 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,11 +27,15 @@ 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 type { MdmService } from "../../../services/mdm/MdmService" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -419,7 +424,63 @@ 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", () => { + it("reports hasResolvedView once a view or panel is resolved", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Headless API callers must not wait on a readiness barrier nothing will release. + expect(provider.hasResolvedView).toBe(false) + + await provider.resolveWebviewView(mockWebviewView) + + expect(provider.hasResolvedView).toBe(true) + await provider.dispose() + }) + beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { const task: any = { @@ -456,6 +517,13 @@ describe("ClineProvider", () => { let mockPostMessage: any let updateGlobalStateSpy: any + afterEach(() => { + // Tests construct providers directly and many never dispose them; clear the + // static registry so a leaked instance cannot claim stable view state ids + // ahead of a later test. + ClineProvider["activeInstances"] = new Set() + }) + beforeEach(() => { vi.clearAllMocks() @@ -593,6 +661,18 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) + + expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) + }) + + it("returns undefined when no live instance owns the view", () => { + expect(ClineProvider.getInstanceForView({} as vscode.WebviewView)).toBeUndefined() + }) + }) + test("reports an unresolved webview as not visible", () => { expect(provider.isViewVisible).toBe(false) }) @@ -1013,7 +1093,7 @@ describe("ClineProvider", () => { }) test("does not reload full model details when the LM Studio model is already loaded", async () => { - vi.mocked(hasLoadedFullDetails).mockReturnValue(true) + vi.mocked(hasLoadedFullDetails).mockReturnValueOnce(true) await provider.performPreparationTasks({ apiConfiguration: { @@ -1260,6 +1340,26 @@ describe("ClineProvider", () => { await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() }) + test("postMessageToWebview does not await the webview ack", async () => { + await provider.resolveWebviewView(mockWebviewView) + + let releaseAck!: () => void + const ack = new Promise((resolve) => { + releaseAck = resolve + }) + mockPostMessage.mockImplementationOnce(() => ack) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // The caller must not wait for the renderer ack: a webview page remounted or disposed + // while the post is in flight never acknowledges it, and awaiting that promise would + // wedge every caller on the task critical path. + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledWith(message) + releaseAck() + }) + test("postMessageToWebview leaves originalContent of file-edit tool messages out of the state it posts", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -1379,143 +1479,3230 @@ describe("ClineProvider", () => { }) test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { - const mdmService = { + // Structural double: the post path only reads these two members, and + // MdmService cannot be constructed as a plain object; Object.assign + // keeps this a single structural assertion. + const mdmService = Object.assign({} as MdmService, { requiresCloudAuth: vi.fn().mockReturnValue(true), isCompliant: vi.fn().mockReturnValue({ compliant: false, reason: "auth required" }), - } as any + }) + + provider = new ClineProvider( + mockContext, + 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(), + ) + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await provider["setViewStateId"]("stable-sidebar-view") + + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("rolls the shared value back when the durable view-state write fails on setValue", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("rollback-view") + + await provider.setValue("mode", "code") + expect(provider.contextProxy.getValue("mode")).toBe("code") + + // Fail only the durable viewStates write: rejecting the first contextProxy.setValue + // call would fail the shared write itself, so the rollback branch would never run. + const originalSetValue = provider.contextProxy.setValue.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + if (key === "viewStates") throw new Error("persist failed") + return originalSetValue(key, value) + }) + + await expect(provider.setValue("mode", "architect")).rejects.toThrow("persist failed") + + // The shared write landed before the durable viewStates write failed: it must be + // rolled back so the cache cannot run ahead of storage. + expect(provider.contextProxy.getValue("mode")).toBe("code") + + await provider.dispose() + }) + + it("rolls the shared values back when the durable view-state write fails on setValues", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("rollback-view") + + await provider.setValues({ mode: "code" }) + expect(provider.contextProxy.getValue("mode")).toBe("code") + + // Fail only the durable viewStates write: rejecting the first contextProxy.setValue + // call would fail the shared write itself, so the rollback branch would never run. + const originalSetValue = provider.contextProxy.setValue.bind(provider.contextProxy) + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key, value) => { + if (key === "viewStates") throw new Error("persist failed") + return originalSetValue(key, value) + }) + + await expect(provider.setValues({ mode: "architect" })).rejects.toThrow("persist failed") + + expect(provider.contextProxy.getValue("mode")).toBe("code") + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("loadViewState", () => { + it("should keep viewLocalState empty when no stable per-view values exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await vi.waitFor(() => { + expect(provider["viewLocalState"]).toEqual({}) + }) + + const state = await provider.getState() + // No per-view entry exists and the proxy's global-state cache is empty + // (initialize() is never called in this fixture; only "taskHistory" passes + // through to the context store), so getState() falls back to the shared + // defaults: mode "code" (defaultModeSlug) and currentApiConfigName "default". + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + + // Register a stable id so the load is not skipped by the ownership guard: + // only entries this instance authored are loaded under a temporary name. + await provider["setViewStateId"]("stable-sidebar-view") + + provider["viewLocalState"] = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValue").mockImplementation(() => { + throw new Error("load failed") + }) + + await provider["loadViewState"]() + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 54 }) + expect(pruned["view-5"]).toEqual({ mode: "mode-5", updatedAt: 5 }) + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("__proto__") + + // "__proto__" is rejected before assignment so a per-view entry can never be + // keyed through the Object.prototype setter: the temporary id stays active and + // nothing is persisted under the reserved name. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("restores the previous view id when the registration write fails so a later launch retries", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id so the re-key has real work to do. + // Author the pre-launch entry through the real write path so this instance owns it. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) + + await expect(provider["setViewStateId"]("stable-sidebar-view")).rejects.toThrow("storage down") + + // The failed id must not stick: the provider keeps its previous (temporary) + // id so a later launch retries registration and the load instead of the + // guard early-returning for an id that was never persisted. + expect(provider["viewStateId"]).toBe(provider.viewId) + + // A later retry succeeds once the storage write works again, and the + // pre-launch entry lands under the registered id. + setValueSpy.mockRestore() + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewStateId"]).toBe("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + + await provider.dispose() + }) + + it("resolves viewStateReadiness once the stable id is registered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const readiness = provider.viewStateReadiness + + const settled = readiness.then( + () => true, + () => false, + ) + + await provider["setViewStateId"]("ready-view") + + expect(await settled).toBe(true) + + await provider.dispose() + }) + + it("resolves viewStateReadiness when the provider disposes before the id is registered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const readiness = provider.viewStateReadiness + + const settled = readiness.then( + () => true, + () => false, + ) + + await provider.dispose() + + expect(await settled).toBe(true) + }) + + it("settles viewStateReadiness when the launch carries no usable view id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const readiness = provider.viewStateReadiness + + const settled = readiness.then( + () => true, + () => false, + ) + + // The CLI launch sends the registration message without a view-state id: the + // early return must still settle readiness so API-driven task creation does + // not fall through to its timeout waiting for a state that never registers. + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + + expect(await settled).toBe(true) + expect(provider["viewStateId"]).toBe(provider.viewId) + + await provider.dispose() + }) + + it("settles viewStateReadiness even when first read after the id is registered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("late-reader-view") + + // First read happens after registration: a lazily created promise would hold a + // fresh, never-resolving resolver here (the registration's resolve call was a + // no-op), so settling proves the promise was created eagerly in the constructor. + const settled = provider.viewStateReadiness.then( + () => true, + () => false, + ) + expect(await settled).toBe(true) + + await provider.dispose() + }) + }) + + describe("view state persistence edge cases", () => { + it("should read viewStates from the ContextProxy cache when not fresh", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + expect(provider["getPersistedViewStates"]()).toEqual({ "stable-sidebar-view": { mode: "architect" } }) + await provider.dispose() + }) + + it("should treat a corrupted non-object viewStates value as an empty map", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A string in storage is corrupt: the fresh-read guard must not spread it. + await mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + }) + await save("mode", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("mode") + await provider.saveViewState("mode", "architect") + await save("currentApiConfigName", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + mode: "architect", + updatedAt: expect.any(Number), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("should rekey a pre-launch entry under the temporary id to the registered stable id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed through the write path so this instance is the author of the entry under + // its temporary id; an entry authored by another session must not be adopted. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + await provider.dispose() + }) + + it("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Author the temporary entry through the write path so this instance owns it, + // then add a stable entry that already exists in storage. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + await mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("a b/c") + expect(provider["viewStateId"]).toBe("a_b_c") + await provider["setViewStateId"](undefined) + await provider["setViewStateId"](" ") + expect(provider["viewStateId"]).toBe("a_b_c") + logSpy.mockClear() + await provider["setViewStateId"]("a_b_c") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should reapply fields mutated while the load is in flight and keep persisted values for untouched fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutations and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // Selections made while the profile lookup is in flight must survive the load. + await provider.saveViewState("mode", "architect") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // Dirty fields win over the loaded state; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-y" }, + }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted mode authoritative when the pre-load buffer is untouched", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "code") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should not restore a persisted mode whose custom mode no longer exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // Persist a mode too: without it the load has no mode to resurrect, so the + // cleared-field assertion below would pass even if the loaded value clobbered + // the in-flight clear. + await provider.saveViewState("currentApiConfigName", "cfg-a") + await provider.saveViewState("mode", "code") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight clear and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + + // The user clears the mode while the load is in flight. + await provider.saveViewState("mode", undefined) + resolveProfile({ name: "cfg-a" }) + await load + + // The cleared field must stay absent rather than keeping the persisted or + // pre-load value; the untouched field keeps the persisted value. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the mode is mutated while the profile lookup is in flight. + await provider.saveViewState("mode", "architect") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should reapply an independently mutated profile name when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + // The in-flight selection wins over the loaded state. + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-b") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.dispose() + }) + + it("should reapply an independently mutated apiConfiguration when the load settles", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // Only the apiConfiguration is mutated while the profile lookup is in flight. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-y", + }) + expect(provider["viewLocalState"].currentApiConfigName).toBe("cfg-a") + await provider.dispose() + }) + + it("should keep every persisted field authoritative when the load is untouched", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + await provider.saveViewState("mode", "code") + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // No mutation while the lookup is in flight: the persisted values must win. + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "code", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + + it("should persist known modes and reject unknown or non-string modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the slugs under test. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "refactor" ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValues({ mode: "refactor" }) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + await provider.setValues({ mode: "bogus-mode" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode must be rejected before persistence; Object.assign + // keeps this a single structural assertion, so it must not reach global + // state or the buffer. + await provider.setValues(Object.assign({} as RooCodeSettings, { mode: 42 })) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should reject unknown or non-string modes written through setValue", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; narrow it to the values under + // test. 42 resolves on purpose: the non-string case must be rejected by the typeof + // check alone, not by a failed slug lookup. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: unknown) => + slug === "refactor" || slug === 42 ? { slug } : undefined) as typeof modesModule.getModeBySlug) + try { + await provider.setValue("mode", "refactor") + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // The updateSettings webview handler routes every key through setValue: + // an unknown mode must be rejected there too, not only in setValues. + await provider.setValue("mode", "bogus-mode") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode must be rejected before persistence (double assertion: the + // type excludes non-strings), so it must not reach global state or the buffer — + // even though the slug lookup above resolves it. + await provider.setValue("mode", 42 as unknown as RooCodeSettings["mode"]) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Ignoring invalid mode "42"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + it("should apply setValue mutations to global state and keep or clear the right buffer fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const apiConfiguration = { apiProvider: providerIdentifiers.openrouter } + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider.saveViewState("apiConfiguration", apiConfiguration) + // A mutation of an unrelated key reaches global state without dropping buffered fields. + await provider.setValue("writeDelayMs", 500) + expect(mockContext.globalState.get("writeDelayMs")).toBe(500) + expect(provider.getValues().writeDelayMs).toBe(500) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toBe(apiConfiguration) + await provider.setValue("mode", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.setValue("currentApiConfigName", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("currentApiConfigName") + await provider.dispose() + }) + + it("should remove the buffered apiConfiguration when it is cleared", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await provider.saveViewState("apiConfiguration", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await save("apiConfiguration", null) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.dispose() + }) + + it("should clear viewLocalState and the persisted entry when resetting state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace customModesManager with a test double (the real reset writes to disk). + provider.customModesManager = { resetCustomModes: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } + // The modal answer is a string label; the last-typed vscode overload expects a + // MessageItem. A double assertion is the last-resort cast here (AGENTS.md): the + // production path compares the answer against the string label directly, so the + // runtime value must stay a string and cannot be a structural MessageItem double. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + t("common:answers.yes") as unknown as vscode.MessageItem, + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.resetState() + expect(provider["viewLocalState"]).toEqual({}) + // F4 cross-instance broadcast clears the entire durable viewStates map, so the + // key is removed (undefined) rather than left as an empty object. + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + await provider.dispose() + }) + + it("should clear a sibling's view-local state and post sibling state during a cross-instance resetState", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const post1 = vi.spyOn(provider1, "postStateToWebview").mockResolvedValue(undefined) + const post2 = vi.spyOn(provider2, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace customModesManager with a test double (the real reset writes to disk). + provider1.customModesManager = { resetCustomModes: vi.fn().mockResolvedValue(undefined), dispose: vi.fn() } + // The modal answer is a string label; the last-typed vscode overload expects a MessageItem. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + t("common:answers.yes") as unknown as vscode.MessageItem, + ) + + await provider2.saveViewState("mode", "architect") + await provider1.resetState() + + // The sibling's in-memory view-local cache must be cleared by the broadcast. + expect(provider2["viewLocalState"]).toEqual({}) + // The sibling receives exactly one posted state (from the broadcast); the caller + // receives exactly one (its final reset post), never a double post from the broadcast. + expect(post2).toHaveBeenCalledTimes(1) + expect(post1).toHaveBeenCalledTimes(1) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should roll back the shared write when setValue per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValue("mode", "code")).rejects.toThrow("storage unavailable") + // The shared store must not move ahead of the per-view record that failed. + expect(provider.getValue("mode")).toBe("architect") + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + it("should roll back the shared writes when setValues per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + await provider.contextProxy.setValue("currentApiConfigName", "current-config") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValues({ mode: "code", currentApiConfigName: "other-config" })).rejects.toThrow( + "storage unavailable", + ) + expect(provider.getValue("mode")).toBe("architect") + expect(provider.getValue("currentApiConfigName")).toBe("current-config") + expect(provider["viewLocalState"].mode).toBeUndefined() + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + }) + + describe("provider profile mutations", () => { + it("should sync the view-local buffer when activating a profile over a loaded view state", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("currentApiConfigName", "old-profile") + + const profile: ProviderSettingsEntry = { + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openrouter, + } + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue(profile), + listConfig: vi.fn().mockResolvedValue([profile]), + setModeConfig: vi.fn(), + getProfile: vi.fn().mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider.getValues().currentApiConfigName).toBe("old-profile") + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The buffer must track the activated profile so getValues() agrees with the proxy. + expect(provider.getValues().currentApiConfigName).toBe("new-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") + await provider.dispose() + }) + + it("should 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(), + } + 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(), + } + 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(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // A stale view-local apiConfiguration (as loaded from a view state) that keeps + // shadowing the fresh profile's settings in getState() unless the mutation + // path clears this view's buffer overlay. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + expect(provider.getValues().apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "stale-key", + }) + + await provider.upsertProviderProfile("fresh-profile", { apiProvider: providerIdentifiers.openrouter }) + + // The constructed state must serve the fresh profile's shared settings, not + // the stale view-local overlay. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + // Same masking guard as the activation case: later shared edits stay visible. + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openrouter, + apiKey: "edited-key", + }) + const stateAfterEdit = await provider.getState({ includeTaskHistory: false }) + expect(stateAfterEdit.apiConfiguration.apiKey).toBe("edited-key") + await provider.dispose() + }) + + it("should sync the view-local buffer when deleting the current profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + // @ts-ignore - Replace providerSettingsManager with a test double: deleting the + // current profile now activates the fallback, which reads its settings. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + + await provider.deleteProviderProfile(oldProfile) + + // This view pinned the deleted profile, so the deletion routes through the + // activation path, which writes the profile list itself (re-reading the + // entries from the settings store): exactly one list write may occur, and a + // stray compensating pre-write for a view that pins the deleted profile would + // be a second one. + const listWrites = setValueSpy.mock.calls.filter(([key]) => key === "listApiConfigMeta") + expect(listWrites).toHaveLength(1) + + // 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 keep an unrelated view-local pin when the shared selection points at the deleted profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const deletedProfile: ProviderSettingsEntry = { + name: "shared-victim", + id: "victim-id", + apiProvider: providerIdentifiers.openrouter, + } + const replacementProfile: ProviderSettingsEntry = { + name: "replacement-profile", + id: "replacement-id", + apiProvider: providerIdentifiers.anthropic, + } + const pinnedProfile: ProviderSettingsEntry = { + name: "view-pin", + id: "pin-id", + apiProvider: providerIdentifiers.zai, + } + // The shared selection points at the profile being deleted while this view pins + // an unrelated profile that must survive the deletion. + await provider.contextProxy.setValue("listApiConfigMeta", [ + deletedProfile, + replacementProfile, + pinnedProfile, + ]) + await provider.contextProxy.setValue("currentApiConfigName", "shared-victim") + // Persist this view's pin through the durable map so the load applies it to the + // buffer without touching the shared slot. + await provider.contextProxy.setValue("viewStates", { + "pinning-view": { currentApiConfigName: "view-pin", updatedAt: Date.now() }, + }) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockResolvedValue(undefined), + activateProfile: vi.fn().mockResolvedValue(replacementProfile), + listConfig: vi.fn().mockResolvedValue([replacementProfile, pinnedProfile]), + setModeConfig: vi.fn(), + getProfile: vi.fn().mockResolvedValue(pinnedProfile), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("pinning-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("view-pin") + + await provider.deleteProviderProfile(deletedProfile) + + // The pin survives in the buffer: the deletion must not route through the + // replacement activation for an unrelated view pin. The shared slot is + // repaired to the replacement so sibling views stop reading the deleted + // name, while this view's buffer keeps its pin. + expect(provider.getValues().currentApiConfigName).toBe("view-pin") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("replacement-profile") + // The shared list sync drops the deleted entry. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([replacementProfile, pinnedProfile]) + await provider.dispose() + }) + + it("leaves the durable pin and the shared list untouched when the settings deletion fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + // Persist this view's pin under the profile being deleted so a failed deletion + // must leave it intact. + await provider.contextProxy.setValue("viewStates", { + "pinning-view": { currentApiConfigName: "old-profile", updatedAt: Date.now() }, + }) + // @ts-ignore - Replace providerSettingsManager with a double whose deletion fails. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockRejectedValue(new Error("storage down")), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("pinning-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("old-profile") + + await expect(provider.deleteProviderProfile(oldProfile)).rejects.toThrow("storage down") + + // The deletion rejected, so no compensating write may have landed: the durable + // pin still names the profile that still exists, the shared list is intact, and + // the shared selection is untouched. + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "pinning-view": { currentApiConfigName: "old-profile" }, + }) + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([oldProfile, keeperProfile]) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + + await provider.dispose() + }) + + it("prunes the stale list entry and re-points the pin when the secret is already gone", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("viewStates", { + "pinning-view": { currentApiConfigName: "old-profile", updatedAt: Date.now() }, + }) + // @ts-ignore - Replace providerSettingsManager with a double whose deletion + // reports the secret as already gone: an idempotent no-op that must still prune + // the stale list entry and re-point the pin. + provider.providerSettingsManager = { + deleteConfig: vi.fn().mockRejectedValue(new ProviderSettingsNotFoundError("missing")), + activateProfile: vi.fn().mockResolvedValue(keeperProfile), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("pinning-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("old-profile") + + await provider.deleteProviderProfile(oldProfile) + + // The not-found rejection is an idempotent success: the compensating writes run, + // and the log records that the stale list entry was pruned only. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keeperProfile]) + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "pinning-view": { currentApiConfigName: "keeper-profile" }, + }) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(expect.stringContaining("not found")) + + await provider.dispose() + }) + + it("reconfigures a view pinned to the deleted profile even when the global selection points elsewhere", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + // The global selection points at the keeper profile, but this view's buffer + // is still pinned to the profile being deleted (it loaded it earlier): + // the nested overlay still carries the deleted profile's configuration. + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + provider["viewLocalState"].currentApiConfigName = "old-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double: the pinned + // view takes the activation path, so the survivor's settings are read through + // activateProfile. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + listConfig: vi.fn().mockResolvedValue([keeperProfile]), + setModeConfig: vi.fn(), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + + await provider.deleteProviderProfile(oldProfile) + + // The pin still names the deleted profile, so the view is reconfigured through + // the activation path: the shared provider keys take the surviving profile's + // settings and the view's stale overlay is cleared, so no key of the deleted + // profile survives in getState(). + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("does not re-point a pinned sibling's task that follows another profile when its profile is deleted", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + // The sibling pins the profile being deleted, but its active task + // follows a different sticky profile: the deletion must re-point the + // sibling's buffer to the surviving profile without switching that + // conversation's handler or rewriting its persisted profile name. + sibling["viewLocalState"].currentApiConfigName = "old-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + taskId: "sibling-task", + setTaskApiConfigName: vi.fn(), + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The sibling's view buffer is still re-pointed to the surviving + // profile... + expect(sibling["viewLocalState"].currentApiConfigName).toBe("keeper-profile") + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + // ...but the task follows another profile: neither the handler rebuild + // nor the sticky-name rewrite may touch it. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(siblingTask.setTaskApiConfigName).not.toHaveBeenCalled() + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + await provider.dispose() + } + }) + + it("leaves the view buffer untouched when the deleted profile is neither globally active nor view-pinned", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + const otherProfile: ProviderSettingsEntry = { + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile, otherProfile]) + // The global selection and this view's pin both name surviving profiles: + // the deletion must not reconfigure this view's settings, and the + // pinned view's nested overlay must survive byte-for-byte. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // No reconfiguration: the guard must stay false when neither the global + // selection nor the view pin names the deleted profile, so the view + // keeps its own pin and its nested overlay while the shared store + // reports the global selection. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("other-profile") + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "pinned-profile-secret", + }) + await provider.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider2.saveViewState("mode", "debugger") + await provider1.saveViewState("mode", "architect") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const saveViewState1 = provider1.saveViewState.bind(provider1) + const saveViewState2 = provider2.saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await provider.saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy: 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", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("profile-b-id"), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-b", { apiProvider: providerIdentifiers.anthropic }, true) + + const state = await provider.getState({ includeTaskHistory: false }) + + // Activating profile B must clear profile A's buffered overlay so the shared + // settings (written by setProviderSettings) win; otherwise getState would + // report profile B's name with profile A's provider. + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted view-state write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.contextProxy.setValue("viewStates", { + "other-view": { mode: "code", updatedAt: 1 }, + }) + // The next durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["savePersistedViewState"]({ mode: "architect", updatedAt: 2 })).rejects.toThrow( + "durable write failed", + ) + + // The in-memory cache must match durable storage: an entry that never + // reached disk cannot stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "other-view": { mode: "code", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a re-key write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id through the real write + // path, so the re-key has a real entry to move and this instance authored it. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The re-key's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["rekeyPersistedViewStateEntry"]("stable-sidebar-view")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the stable entry never reached disk, + // and the retry path depends on the temporary entry still being visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + }) + // Authorship survives the failure so a later registration can re-key again. + expect(provider["wroteUnderTemporaryViewStateId"]).toBe(true) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("upserts activation writes from the stored normalized record, not the raw payload", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + // The stored record is the normalized shape saveConfig persists: the schema + // parse strips the stray cross-provider field and keeps the stored model. + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + id: "profile-id", + name: "profile-a", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-a", { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "raw-model", + openRouterApiKey: "cross-provider-stray", + }) + + // The shared store receives the stored record: the raw payload's stray + // cross-provider field must not reach it (or the sibling overlays and + // task handler refreshed from the same value). + expect(setProviderSettingsSpy).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + await provider.dispose() + }) + + it("restores the cached viewStates map when a clear write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The clear's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["clearPersistedViewState"]()).rejects.toThrow("durable write failed") + + // The cache must match durable storage: the entry was never removed on + // disk, so it must stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted re-point write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + + // The re-point's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["repointPersistedViewStates"]("profile-old", "profile-new")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the re-pointed pins never reached disk, + // so the pre-re-point map must stay visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("re-points live views pinned to a renamed profile onto the new name", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "profile-old") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "old-key", + }) + + await provider["repointViewLocalStateAfterProfileRename"]("profile-old", "profile-new", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "renamed-key", + }) + + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-new") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "renamed-key", + }) + const viewStates = mockContext.globalState.get("viewStates") as Record> + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-new") + await provider.dispose() + }) + it("re-points persisted pins of closed views onto the renamed profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A closed view's durable pin names the profile about to be renamed; no + // live instance carries it, so only the persisted map can repair it. + await provider.contextProxy.setValue("viewStates", { + "closed-view": { currentApiConfigName: "profile-old", mode: "code", updatedAt: 1 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "profile-old") + + await provider["repointViewLocalStateAfterProfileRename"]("profile-old", "profile-new", { + apiProvider: providerIdentifiers.openrouter, + }) + + const viewStates = mockContext.globalState.get("viewStates") as Record> + // The closed view must launch onto the renamed profile instead of dangling at + // the deleted name and being repaired to whatever the shared selection became + // in the meantime. + expect(viewStates["closed-view"].currentApiConfigName).toBe("profile-new") + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-new") + await provider.dispose() + }) + + it("clears this view's buffered apiConfiguration when directly activating a profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the per-view buffer with profile A's settings, as loadViewState would + // after a restart with a pinned profile. + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "profile-a-key", + }) + + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The direct-activation path (activateProviderProfileUnlocked) carries its own + // overlay-clearing call, distinct from upsertProviderProfile's. + await provider.activateProviderProfile({ name: "profile-b" }) + + const state = await provider.getState({ includeTaskHistory: false }) + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + + it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const contextProxyAccess = provider.contextProxy as { + setValues: (values: Partial) => Promise + } + await contextProxyAccess.setValues({ + mode: "debugger", + currentApiConfigName: "shared-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }, + customModePrompts: { code: { roleDefinition: "shared" } }, + }) + + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "view-profile") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + + const values = provider.getValues() + + expect(values.mode).toBe("architect") + expect(values.currentApiConfigName).toBe("view-profile") + expect(values.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "view-key", + }) + expect(values.customModePrompts).toEqual({ code: { roleDefinition: "shared" } }) + + await provider.dispose() + }) + + it("should keep flat provider settings out of the view-local buffer", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + + await provider.setValues({ + apiProvider: providerIdentifiers.bedrock, + awsUseApiKey: true, + awsApiKey: "mock-key", + awsRegion: "us-east-1", + apiModelId: "anthropic.claude-opus-4-8-20261215-v1:0", + awsBedrockEndpoint: "http://127.0.0.1:4567", + awsBedrockEndpointEnabled: true, + }) + + // Flat provider-settings keys are shared settings: they must flow through + // the ContextProxy only and must not be merged into the view-local buffer, + // which would turn them into a per-view override masking later shared + // updates from other views. The pre-existing overlay is older than this + // shared write, so the flat write drops it instead of being masked by it. + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.bedrock) + expect(provider.contextProxy.getValue("awsBedrockEndpoint")).toBe("http://127.0.0.1:4567") + + await provider.dispose() + }) + + it("drops the view-local apiConfiguration overlay when setValues receives flat provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + 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, + }) + + const state = await provider.getState() + + // Flat provider-settings keys are shared settings: they reach the shared store, + // where other views and new tasks read them ... + const shared = provider.contextProxy.getValues() + expect(shared.apiProvider).toBe("bedrock") + expect(shared.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + // ... and they take effect in this view as well: the pinned profile's + // overlay is older than the shared write, so the flat write drops it and + // getState() serves the new shared values instead of the stale pin. + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("persisted view state", () => { + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + + await provider.dispose() + }) + + it("should persist setValues mutations for view-local API profile", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.setValues({ currentApiConfigName: "profile-from-set-values" }) + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "profile-from-set-values" }, + }) + + await provider.dispose() + }) + + it("should drop an unknown mode from setValues while keeping valid modes", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("should keep a mode whose custom definition arrives in the same setValues payload", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // createTask and setConfiguration call setValues(configuration) before + // registering configuration.customModes: the payload's own modes must count + // as valid even though the manager has not registered them yet. + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string, customModes?: { slug: string }[]) => + customModes?.some((mode) => mode.slug === slug) + ? { slug } + : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ + customModes: [{ slug: "new-mode", name: "New Mode", roleDefinition: "test", groups: ["read"] }], + mode: "new-mode", + }) + + expect(provider.contextProxy.getValue("mode")).toBe("new-mode") + expect(provider["viewLocalState"].mode).toBe("new-mode") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("deregisters the provider even when MCP cleanup rejects during dispose", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // An MCP client whose disposal rejects used to abort dispose() before + // the registry cleanup below it: the provider stayed in activeInstances + // while _disposed was already set, so no later retry could repair the + // registration. Deregistration now runs in a finally path. + provider["mcpHub"] = { + unregisterClient: vi.fn().mockRejectedValue(new Error("mcp disposal down")), + } as unknown as ClineProvider["mcpHub"] + expect(ClineProvider.getAllInstances()).toContain(provider) + + await expect(provider.dispose()).rejects.toThrow("mcp disposal down") + + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + + it("restores the previous mode when custom-mode registration fails during createTask", async () => { + await provider.setValues({ mode: "architect" }) + // setValues mirrors the write into the view buffer; drop it so this case + // models a view that never pinned a mode of its own. + delete provider["viewLocalState"].mode + provider["customModesManager"].updateCustomMode = vi.fn().mockRejectedValue(new Error("registration down")) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + mode: "reviewer", + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // The payload's mode reached shared + per-view state through setValues + // before registration failed; the failure path must roll both back. + expect(provider.getValues().mode).toBe("architect") + // The view had no pin before, and an unset pin must stay unset rather + // than becoming an explicit copy of the shared value. + expect(provider["viewLocalState"].mode).toBeUndefined() + }) + + it("restores the view's own mode pin separately when custom-mode registration fails", async () => { + await provider.setValues({ mode: "code" }) + provider["viewLocalState"].mode = "architect" + provider["customModesManager"].updateCustomMode = vi.fn().mockRejectedValue(new Error("registration down")) + + await expect( + provider.createTask( + undefined, + undefined, + undefined, + {}, + { + mode: "reviewer", + customModes: [ + { slug: "reviewer", name: "Reviewer", roleDefinition: "Reviews code", groups: [] }, + ], + }, + ), + ).rejects.toThrow("registration down") + + // Shared and per-view selections roll back to their own prior values: + // the view keeps its architect pin, the shared store keeps code. + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("mode")).toBe("code") + expect(provider.getValues().mode).toBe("architect") + }) + + it("deregisters the provider when an early cleanup step throws during dispose", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // The first cleanup step throwing used to skip the deregistration below + // it: the provider stayed in activeInstances while _disposed was already + // set, so no later retry could repair the registration. The finally now + // covers the whole disposal operation. + provider["taskScheduler"] = { + cancelQueued: vi.fn(() => { + throw new Error("early cleanup boom") + }), + } as unknown as ClineProvider["taskScheduler"] + expect(ClineProvider.getAllInstances()).toContain(provider) + + await expect(provider.dispose()).rejects.toThrow("early cleanup boom") + + expect(ClineProvider.getAllInstances()).not.toContain(provider) + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab panel/with.dots and spaces") + await provider.setValue("mode", "architect") + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + tab_panel_with_dots_and_spaces: { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("tab panel/with.dots and spaces") + + await provider.dispose() + }) + + it("should persist queued writes under the viewStateId active when the change was made", async () => { + let releaseFirstWrite!: () => void + const firstWriteStarted = new Promise((resolve) => { + mockContext.globalState.update = vi + .fn() + .mockImplementationOnce((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + resolve() + return new Promise((writeResolve) => { + releaseFirstWrite = writeResolve + }) + }) + .mockImplementation((key: string, value: unknown) => { + mockContext.globalState.get = vi + .fn() + .mockImplementation((lookupKey: string) => (lookupKey === key ? value : undefined)) + return Promise.resolve() + }) + }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + const firstSave = provider.saveViewState("mode", "architect") + await firstWriteStarted + await provider["setViewStateId"]("view-b") + releaseFirstWrite() + await firstSave + + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "architect" }, + }) + expect(provider.contextProxy.getValue("viewStates")).not.toHaveProperty("view-b") + + await provider.dispose() + }) + + it("should preserve persisted viewStates entry when an editor provider is disposed during teardown", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("tab-to-preserve") + await provider.saveViewState("mode", "architect") + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + + await provider.dispose() + + expect(provider.contextProxy.getValue("viewStates")).toHaveProperty("tab-to-preserve") + }) + + it("should read viewStates fresh from storage so out-of-proxy writes are not clobbered", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("view-a") + await provider.saveViewState("mode", "architect") + + // Simulate a concurrent writer (another view's provider) updating the shared + // map directly in storage, bypassing this proxy's cache. + const stored = (await mockContext.globalState.get>("viewStates")) ?? {} + await mockContext.globalState.update("viewStates", { + ...stored, + "view-b": { mode: "debug", updatedAt: 1 }, + }) + + await provider.saveViewState("mode", "code") + + // The serialized write must have merged on top of the fresh storage value, not + // on top of this proxy's stale cache. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "view-a": { mode: "code" }, + "view-b": { mode: "debug" }, + }) + + await provider.dispose() + }) + + it("should re-key durable viewStates entries from the temporary pre-launch view id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A change made before the stable id is registered persists under the + // temporary id so it is not lost; registration re-keys it to the stable id. + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + [provider.viewId]: { mode: "architect" }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "debugger") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + + await provider.dispose() + }) + + it("should drop the temporary viewStates entry when a stable entry already exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // A stable entry already exists (e.g. persisted by an earlier registration); + // it must win over this instance's temporary entry. + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + // Author the temporary entry through the real write path so this instance + // owns it: unowned entries are never re-keyed. + await provider.saveViewState("mode", "architect") + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("debugger") + + await provider.dispose() + }) + + it("should discard a stale loadViewState when a newer view id is registered during the load", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + // Seed persisted entries under both ids through the proxy so the loads + // observe them via the cached read path: the temporary entry holds a + // pre-registration selection, the stable entry the post-registration one. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, + }) + + // Hang the temporary entry's profile lookup so that load is still in flight + // when the stable id is registered. + let releaseGhost!: () => void + const ghostLoad = new Promise((resolve) => { + releaseGhost = resolve + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( + ghostLoad.then(() => + Object.assign({} as Awaited>, { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }), + ), + ) - 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", () => { @@ -3041,11 +6228,19 @@ describe("ClineProvider", () => { getProfile: vi.fn().mockResolvedValue(profile), } as any + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch to architect mode await provider.handleModeSwitch("architect") - // Verify mode was updated - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify saved config was loaded expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") @@ -3067,19 +6262,25 @@ describe("ClineProvider", () => { setModeConfig: vi.fn(), } as any - // Mock the ContextProxy's getValue method to return the current config name + // Seed the shared current config name: the mode-default recording reads the + // view's effective profile, which falls back to the shared slot when this + // view pins no profile of its own. const contextProxy = (provider as any).contextProxy - const getValueSpy = vi.spyOn(contextProxy, "getValue") - getValueSpy.mockImplementation((key: any) => { - if (key === "currentApiConfigName") return "current-config" - return undefined - }) + await contextProxy.setValue("currentApiConfigName", "current-config") + + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") // Switch to architect mode await provider.handleModeSwitch("architect") - // Verify mode was updated - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify current config was saved as default for new mode expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "current-id") @@ -3146,8 +6347,10 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("non-existent-mode", expect.any(Array)) - // Verify fallback to default mode - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "code") + // Verify fallback to default mode, view-locally: history restore no longer + // writes the shared global mode + expect(provider["viewLocalState"].mode).toBe("code") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "code") expect(logSpy).toHaveBeenCalledWith( "Mode 'non-existent-mode' from history no longer exists. Falling back to default mode 'code'.", ) @@ -3219,8 +6422,9 @@ describe("ClineProvider", () => { expect(mockCustomModesManager.getCustomModes).toHaveBeenCalled() expect(getModeBySlug).toHaveBeenCalledWith("custom-mode", expect.any(Array)) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "custom-mode") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("custom-mode") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "custom-mode") expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("no longer exists")) // Verify history item mode was not changed @@ -3267,8 +6471,9 @@ describe("ClineProvider", () => { // Initialize with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was preserved - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was preserved view-locally (no shared global mode write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") // Verify history item mode was not changed expect(historyItem.mode).toBe("architect") @@ -4333,6 +7538,11 @@ describe("getTelemetryProperties", () => { const { CloudService } = await import("@roo-code/cloud") const mockCloudService = { isAuthenticated: vi.fn().mockReturnValue(true), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -4351,6 +7561,11 @@ describe("getTelemetryProperties", () => { const { CloudService } = await import("@roo-code/cloud") const mockCloudService = { isAuthenticated: vi.fn().mockReturnValue(false), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -4392,6 +7607,11 @@ describe("getTelemetryProperties", () => { isAuthenticated: vi.fn().mockImplementation(() => { throw new Error("Authentication check error") }), + + // Provider dispose detaches the settings listener; later tests construct + // and dispose providers against this surviving instance getter. + on: vi.fn(), + off: vi.fn(), } // Update the existing mock @@ -5966,12 +9186,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }), true, ) - expect(saveConfig).toHaveBeenCalledWith( + // The non-active profile now goes through upsertProviderProfile so the views + // pinned to it are refreshed, not just the file on disk. + expect(upsertSpy).toHaveBeenCalledWith( "Backup Zoo", expect.objectContaining({ zooSessionToken: "new-token", zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1", }), + false, ) }) @@ -6068,4 +9291,254 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) }) }) + describe("profile save without activation and overlay completeness", () => { + it("refreshes views pinned to a profile saved without activation", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling loaded the profile earlier: its buffer pins the name and + // carries the settings snapshot taken at that load. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + // The sibling has an active task built from the pre-save settings. + // Task is a class; the double assertion is the only way to attach a + // minimal double without the full provider/services graph. + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "old-key" }, + taskApiConfigName: "saved-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + // A save that does not activate still rewrites the profile; the pinned + // sibling must not keep serving the pre-save snapshot. + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + + // The active task's handler must be rebuilt with the saved settings, + // not just the view buffer. + expect(siblingTask.updateApiConfiguration).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + } + }) + + it("does not rebuild a pinned sibling's task that runs under another profile", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + const sibling = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + // The sibling pins the saved profile, but its active task follows a + // different sticky profile: the save must refresh the view overlay + // without switching that conversation to the saved profile's settings. + sibling["viewLocalState"].currentApiConfigName = "saved-profile" + sibling["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "old-key", + } + vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined) + + const siblingTask = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(sibling, "getCurrentTask").mockReturnValue( + siblingTask as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + // The pinned view's overlay still catches up with the saved settings... + expect(sibling["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + // ...but the task runs under another profile: its handler must not be + // switched to the saved profile's settings. + expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() + // Wait on the serialized view-state write queue that every + // viewStates mutation goes through, so queued persistence finishes + // while the console spies are still installed and teardown has no + // pending writes. + await ClineProvider["persistedViewStateWriteQueue"] + } finally { + await sibling.dispose() + } + }) + + it("does not rebuild the acting view's task when it runs under another profile", async () => { + // The acting view pins the saved profile while its own task follows a + // different sticky profile: the non-activating save refreshes the view + // overlay but must leave the task's handler alone. + const task = { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiKey: "other-key" }, + taskApiConfigName: "other-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + task as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["viewLocalState"].currentApiConfigName = "saved-profile" + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("saved-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.upsertProviderProfile( + "saved-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, + false, + ) + + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + expect(task.updateApiConfiguration).not.toHaveBeenCalled() + }) + it("does not leak shared provider keys into a pinned view's api configuration", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + // The shared store sits on a profile with keys the pinned profile never + // sets; spreading the overlay over the shared keys would report a mixed + // configuration (e.g. another profile's model id and region). + await provider.contextProxy.setValue("apiProvider", providerIdentifiers.bedrock) + await provider.contextProxy.setValue("apiModelId", "anthropic.claude-v1") + await provider.contextProxy.setValue("awsRegion", "us-east-1") + provider["viewLocalState"].currentApiConfigName = "pinned-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "k", + } + + // The cloud doubles in this suite make getState() log its defensive catch + // branches; silence them so teardown has no pending console writes. + vi.spyOn(console, "error").mockImplementation(() => {}) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.apiConfiguration.apiKey).toBe("k") + expect(state.apiConfiguration.apiModelId).toBeUndefined() + expect(state.apiConfiguration.awsRegion).toBeUndefined() + + // Let any deferred logging settle while the spies are installed. + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + it("leaves a task following another sticky profile untouched when an activation preserves foreign tasks", async () => { + // Defensive: the doubles in this suite can leave console writes pending + // at worker teardown. + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + + // The current task follows a different sticky profile than the one the + // launch-time repair activates. + const task = { + apiConfiguration: { apiProvider: providerIdentifiers.openai, apiKey: "task-key" }, + taskApiConfigName: "task-profile", + updateApiConfiguration: vi.fn(), + } + vi.spyOn(provider, "getCurrentTask").mockReturnValue( + task as unknown as ReturnType, + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "shared-profile", + id: "id-shared", + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn(), + } + + await provider.activateProviderProfile( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + + // The repair wrote the shared selection through the activation path but + // left the foreign task's handler and captured settings alone. + expect(task.updateApiConfiguration).not.toHaveBeenCalled() + expect(task.apiConfiguration.apiProvider).toBe(providerIdentifiers.openai) + + // Control: the same activation without preserveForeignTask does switch + // the task, so the skip above is attributable to the flag. + await provider.activateProviderProfile( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false }, + ) + expect(task.updateApiConfiguration).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "shared-key", + }) + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 22aa4d7e97..d1f88603ed 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -121,6 +121,8 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + on: vi.fn(), + off: vi.fn(), } }, }, @@ -208,6 +210,44 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) +// Shared typed doubles for the mode-switch contract: one definition keeps every +// test aligned when the Task/HistoryItem contract changes. The literal is +// asserted as Partial so private members (_taskMode, saveClineMessages) +// stay out of the Object.assign intersection type, which would otherwise +// collapse to never. +function createModeSwitchTask(overrides: Partial & Record = {}): Task { + return 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, + overrides, + ) +} + +function createTaskHistoryItem(overrides: Partial = {}): HistoryItem { + return { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + ...overrides, + } +} + describe("ClineProvider - Sticky Mode", () => { let provider: ClineProvider let mockContext: vscode.ExtensionContext @@ -221,9 +261,32 @@ describe("ClineProvider - Sticky Mode", () => { } } + afterEach(async () => { + // Tests construct providers directly and the suite never disposes most of + // them; dispose the per-test provider first (dispose unregisters it from + // static registries such as McpServerManager's provider set, which the + // reset below does not clear), then clear the instance registry so a + // leaked instance cannot claim a stable view state id ahead of a later + // test. + await provider.dispose() + ClineProvider["activeInstances"] = new Set() + }) + beforeEach(async () => { vi.clearAllMocks() + // The "mode deletion between sessions" test overrides the module-level + // getModeBySlug mock to return undefined; vi.clearAllMocks() does not clear + // mock implementations, so restore the factory default per-test. handleModeSwitch + // validates slugs through getModeBySlug, so later tests rely on the default. + const { getModeBySlug } = await import("../../../shared/modes") + vi.mocked(getModeBySlug).mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }) + if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) } @@ -363,11 +426,19 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch mode await provider.handleModeSwitch("architect") - // Verify mode was updated in global state - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) // Verify task history was updated with new mode expect(updateTaskHistorySpy).toHaveBeenCalledWith( @@ -382,7 +453,7 @@ describe("ClineProvider - Sticky Mode", () => { // Create a mock task with initial mode const mockTask = { taskId: "test-task-id", - taskMode: "code", // Initial mode + _taskMode: "code", // Initial mode emit: vi.fn(), saveClineMessages: vi.fn(), clineMessages: [], @@ -416,8 +487,8 @@ describe("ClineProvider - Sticky Mode", () => { // Switch mode await provider.handleModeSwitch("architect") - // Verify task's _taskMode property was updated (using private property) - expect((mockTask as any)._taskMode).toBe("architect") + // Verify task's _taskMode property was updated (accessed via bracket notation) + expect(mockTask["_taskMode"]).toBe("architect") // Verify emit was called with taskModeSwitched event expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") @@ -467,6 +538,633 @@ describe("ClineProvider - Sticky Mode", () => { }), ) }) + + it("should sync the view-local mode buffer when switching modes after a restored view state", async () => { + // Simulate the history-restore path: saveViewState is what + // createTaskWithHistoryItem uses to pin a saved mode into the + // view-local buffer, leaving a stale mode there until the next mutation. + await provider.saveViewState("mode", "code") + + // Global-only mode switch with no active task. + await provider.handleModeSwitch("architect") + + // The durable global write still happens... + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + + // ...and the in-memory buffer must not keep serving the stale restored + // mode: getValues() merges viewLocalState on top of the ContextProxy + // values, so an unsynced buffer would hide the fresh mode from consumers. + expect(provider["viewLocalState"].mode).toBe("architect") + + // The durable per-view write must land too: a regression that left the + // persisted entry on the stale restored mode would reload it on restart. + // setValue awaits the serialized write queue, so the entry is settled here. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] + expect(persisted.mode).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + }) + + it("bails out before any task write when the mutation signal is already aborted", async () => { + const mockTask = createModeSwitchTask({ _taskMode: undefined as string | undefined }) + + 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 () => { + const mockTask = createModeSwitchTask() + + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The history write settles only after the abort lands: the first call is + // controlled, the rollback call resolves immediately. + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await switchPromise + + // The persisted new mode is rolled back to the pre-switch item. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: "test-task-id", mode: "architect" }), + ) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith(2, historyItem) + // No partial mode state: the task keeps its previous mode, nothing was + // emitted, and the durable provider mode write never happened. + expect(mockTask["_taskMode"]).toBe("code") + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + + it("rolls back the task mode and history when the view mode pin write fails", async () => { + const mockTask = createModeSwitchTask() + + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The task-history write lands; the view's durable mode pin write then + // rejects. The caller (submitUserMessage) logs the failure and delivers the + // message, so the task-side writes must not stand. + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + vi.spyOn(provider, "saveViewState").mockRejectedValueOnce(new Error("pin write failed")) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "pin write failed", + ) + + // No partial application: the task keeps its previous mode and the history + // entry is restored, so the delivered message cannot run the new task mode + // against the view's unchanged mode and mode-linked API configuration. + expect(mockTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).toHaveBeenLastCalledWith(historyItem) + }) + + it("rolls back every mode-switch write when the mode-linked profile activation fails", async () => { + const mockTask = createModeSwitchTask() + + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The task, pin, and shared writes all land; the mode-linked profile + // activation then rejects. The caller (submitUserMessage) logs the failure + // and delivers the message, so no write may stand: the message must run the + // pre-switch mode against the unchanged profile, not a half-applied switch + // that pairs the new task mode with the old profile's settings. + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + // Seed both pre-switch mode stores explicitly: the pin buffer and the + // shared ContextProxy value, so the restore assertions have a value that + // differs from the switched "architect". + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockRejectedValue( + new Error("activation read failed"), + ) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation read failed", + ) + + // No partial application: the task keeps its previous mode, the history + // entry is restored, and the view's durable pin and the shared mode are + // rolled back too. + expect(mockTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).toHaveBeenLastCalledWith(historyItem) + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider.getValue("mode")).toBe("code") + }) + + it("keeps the committed mode switch when the post-activation state refresh fails", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The mode-linked profile resolves and its activation lands (writing the + // shared profile like the real activation does); only the trailing state + // post rejects. By then the switch has fully committed - ModeChanged was + // emitted and the activation persisted the new profile - so a UI refresh + // failure must not roll the mode back: that would pair the old mode with + // the new profile, the very mismatch the reconcile exists to prevent. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + provider["activateProviderProfileUnlocked"] = vi.fn().mockImplementation(async () => { + await provider.contextProxy.setValue("currentApiConfigName", "architect-profile") + }) + vi.spyOn(provider, "postStateToWebview").mockRejectedValue(new Error("post failed")) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("architect-profile") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("keeps the switched mode when the mode-linked activation fails after writing", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The mode-linked activation lands its shared writes and only then rejects + // (e.g. its own state post or setModeConfig step failing). A mode-only rollback + // would pair the old mode with the profile the activation already switched to, + // the mismatch the reconcile exists to prevent: the switch must stand and the + // error must still surface. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + provider["activateProviderProfileUnlocked"] = vi.fn().mockImplementation(async (_args, options) => { + // The real activation flips the committed-switch guard when it writes + // the activated profile; mirror that here. + options?.onSharedWriteStart?.() + await provider.contextProxy.setValue("currentApiConfigName", "architect-profile") + throw new Error("activation step failed") + }) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation step failed", + ) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("architect-profile") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("keeps the switched mode when the mutation signal aborted before the failure", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The pin and shared writes land; then the mutation timeout aborts this + // switch and a later step rejects. The queue has already advanced past the + // timed-out switch, so a newer mutation may own the mode stores: the late + // rollback must not replay the pre-switch values over them. + const controller = new AbortController() + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + controller.abort() + throw new Error("late activation read failure") + }) + await provider.addClineToStack(mockTask) + + await expect( + provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal), + ).rejects.toThrow("late activation read failure") + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("does not report ModeChanged for a mode switch that gets reconciled", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + const emitSpy = vi.spyOn(provider, "emit") + + // The task, pin, and shared writes land and the activation read then rejects: + // the reconcile rolls every store back, so listeners must not be left tracking + // the rolled-back mode: ModeChanged must not have fired for it, and the task's + // TaskModeSwitched must be re-emitted with the restored mode. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockRejectedValue( + new Error("activation read failed"), + ) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation read failed", + ) + + expect(emitSpy).not.toHaveBeenCalledWith("modeChanged", "architect") + expect(mockTask.emit).toHaveBeenCalledWith("taskModeSwitched", "test-task-id", "architect") + expect(mockTask.emit).toHaveBeenLastCalledWith("taskModeSwitched", "test-task-id", "code") + }) + + it("keeps the committed switch when the lock-path state refresh fails", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // With the workspace profile lock on the switch commits without a mode-linked + // activation; the trailing post now runs outside the reconciled region, so its + // failure must leave the committed switch standing instead of rolling the mode + // stores back. + vi.spyOn(provider["context"].workspaceState, "get").mockImplementation((key: unknown, def?: unknown) => + key === "lockApiConfigAcrossModes" ? true : def, + ) + vi.spyOn(provider, "postStateToWebview").mockRejectedValue(new Error("post failed")) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") + expect(updateTaskHistorySpy).not.toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("records the mode default from the view's pinned profile, not the shared slot", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The view pins profile-x while a sibling view's activation moved the shared + // slot to profile-y. Recording the mode default from the shared slot would make + // profile-y the default for the new mode in every view, though this view was + // running profile-x. + await provider.setValue("currentApiConfigName", "profile-x") + await provider.contextProxy.setValue("currentApiConfigName", "profile-y") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "x-id", name: "profile-x", apiProvider: providerIdentifiers.openrouter }, + { id: "y-id", name: "profile-y", apiProvider: providerIdentifiers.anthropic }, + ]) + const setModeConfig = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask) + + expect(setModeConfig).toHaveBeenCalledWith("architect", "x-id") + }) + + it("reconciles the switch when the mode-linked profile lookup fails", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The REAL activation is exercised: its profile lookup throws before the + // activated profile is written, so the committed-switch guard must still be + // off (the callback fires only after activateProfile resolves) and the + // reconcile must restore every mode write. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockRejectedValue( + new Error("profile not found"), + ) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "profile not found", + ) + + expect(provider.getValue("mode")).toBe("code") + expect(provider["viewLocalState"].mode).toBe("code") + expect(mockTask["_taskMode"]).toBe("code") + expect(updateTaskHistorySpy).toHaveBeenCalledWith(expect.objectContaining({ mode: "code" })) + }) + + it("keeps the switch when the activation fails after the profile write", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The real activation resolves its profile lookup - flipping the + // committed-switch guard at that point - and only a later shared write + // fails: the activated profile is already in place, so the switch must + // be kept rather than reconciled. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + id: "architect-profile-id", + name: "architect-profile", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("set-mode-config failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "set-mode-config failed", + ) + + expect(provider.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("architect-profile") + // The kept switch must leave the task state and history on the + // committed mode too: rolling the task-side writes back here would + // diverge the task from the mode stores. + expect(mockTask["_taskMode"]).toBe("architect") + expect(updateTaskHistorySpy).toHaveBeenLastCalledWith(expect.objectContaining({ mode: "architect" })) + }) + + it("reports ModeChanged for a switch the catch keeps after activation wrote", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The activation writes the activated profile and only then rejects: the catch + // keeps the switch, and listeners plus the webview must still learn the + // committed mode before the error surfaces. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "architect-profile-id", name: "architect-profile", apiProvider: providerIdentifiers.openrouter }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + id: "architect-profile-id", + apiProvider: providerIdentifiers.openrouter, + }) + const emitSpy = vi.spyOn(provider, "emit") + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["activateProviderProfileUnlocked"] = vi.fn().mockImplementation(async (_args, options) => { + options?.onSharedWriteStart?.() + await provider.contextProxy.setValue("currentApiConfigName", "architect-profile") + throw new Error("activation step failed") + }) + await provider.addClineToStack(mockTask) + + await expect(provider["handleModeSwitchUnlocked"]("architect", mockTask)).rejects.toThrow( + "activation step failed", + ) + + expect(emitSpy).toHaveBeenCalledWith("modeChanged", "architect") + expect(postSpy).toHaveBeenCalled() + expect(provider.getValue("mode")).toBe("architect") + }) + + it("reports the committed mode when the signal aborts after the mode writes", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The pin and shared mode writes land, then the mutation signal aborts during + // the activation reads. The activation is skipped, but the committed mode + // must still reach listeners and the webview through the emit/post tail. + const emitSpy = vi.spyOn(provider, "emit") + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const controller = new AbortController() + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + controller.abort() + return undefined + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + expect(provider.getValue("mode")).toBe("architect") + expect(emitSpy).toHaveBeenCalledWith("modeChanged", "architect") + expect(postSpy).toHaveBeenCalled() + }) + + it("stays silent when a newer mutation supersedes the aborted switch", async () => { + const mockTask = createModeSwitchTask() + const historyItem = createTaskHistoryItem() + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + await provider.saveViewState("mode", "code") + await provider.contextProxy.setValue("mode", "code") + + // The abort lands after this switch's mode writes, and a newer mutation + // immediately moves the mode stores to its own mode: this switch's commit + // is superseded, so announcing it would clobber the newer owner - the + // emit/post tail must stay silent. + const emitSpy = vi.spyOn(provider, "emit") + const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const controller = new AbortController() + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => { + controller.abort() + await provider.setValue("mode", "ask") + return undefined + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + await provider.addClineToStack(mockTask) + + await provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + expect(provider.getValue("mode")).toBe("ask") + expect(emitSpy).not.toHaveBeenCalledWith("modeChanged", "architect") + expect(postSpy).not.toHaveBeenCalled() + }) + it("keeps the cancellation result when the abort rollback write itself fails", async () => { + const mockTask = createModeSwitchTask() + + const historyItem = createTaskHistoryItem() + 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 = createModeSwitchTask() + + 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 () => { + const mockTask = createModeSwitchTask({ _taskMode: undefined as string | undefined }) + + 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 +1185,14 @@ describe("ClineProvider - Sticky Mode", () => { mode: "architect", // Saved mode } - // Mock updateGlobalState to track mode updates - const updateGlobalStateSpy = vi.spyOn(provider as any, "updateGlobalState").mockResolvedValue(undefined) + await provider["setViewStateId"]("stable-test-view") // Initialize task with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was restored via updateGlobalState - expect(updateGlobalStateSpy).toHaveBeenCalledWith("mode", "architect") + // Verify mode was restored into the view-local pin (no shared global write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) it("should use current mode if history item has no saved mode", async () => { @@ -691,11 +1389,19 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") + // Switch mode - should not throw await expect(provider.handleModeSwitch("architect")).resolves.not.toThrow() - // Verify mode was still updated in global state - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + // Verify mode was still updated in durable per-view state + expect(mockContext.globalState.update).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + ["stable-test-view"]: expect.objectContaining({ mode: "architect" }), + }), + ) }) it("should handle null/undefined mode gracefully", async () => { @@ -771,7 +1477,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") @@ -859,6 +1567,9 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve([]) }) + // Register a stable view id so the durable per-view writes are persisted + await provider["setViewStateId"]("stable-test-view") + // Clear previous calls to globalState.update vi.mocked(mockContext.globalState.update).mockClear() @@ -871,12 +1582,16 @@ describe("ClineProvider - Sticky Mode", () => { await Promise.all(switches) - // Find the last mode update call - const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - const lastModeCall = modeCalls[modeCalls.length - 1] + // Find the last durable view state update call + const viewStateCalls = vi + .mocked(mockContext.globalState.update) + .mock.calls.filter((call) => call[0] === "viewStates") + const lastViewStateCall = viewStateCalls[viewStateCalls.length - 1] // Verify the last mode switch wins - expect(lastModeCall).toEqual(["mode", "code"]) + expect(lastViewStateCall?.[1]).toMatchObject({ + ["stable-test-view"]: { mode: "code" }, + }) // Verify task history was updated with final mode const lastCall = updateTaskHistorySpy.mock.calls[updateTaskHistorySpy.mock.calls.length - 1] @@ -937,16 +1652,14 @@ describe("ClineProvider - Sticky Mode", () => { await savePromise // Task should have the new mode - expect((mockTask as any)._taskMode).toBe("architect") + expect(mockTask["_taskMode"]).toBe("architect") }) }) describe("Mode switch failure scenarios", () => { - it("should handle invalid mode gracefully", async () => { + it("should ignore invalid modes", async () => { await provider.resolveWebviewView(mockWebviewView) - // The provider actually does switch to invalid modes - // This test should verify that behavior const mockTask = { taskId: "test-task-id", _taskMode: "code", @@ -960,14 +1673,23 @@ describe("ClineProvider - Sticky Mode", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) + // Register a stable view id so a durable write would be observable + await provider["setViewStateId"]("stable-test-view") + // Clear previous calls vi.mocked(mockContext.globalState.update).mockClear() - // Try to switch to invalid mode - it will actually switch - await provider.handleModeSwitch("invalid-mode" as any) + // Simulate an unknown slug: the module mock resolves nothing for it. + // (The outer beforeEach restores the mock's default return per test, so + // this override does not leak into later tests.) + const { getModeBySlug } = await import("../../../shared/modes") + vi.mocked(getModeBySlug).mockReturnValue(undefined) + + // An unknown mode slug is ignored: no durable write and no in-memory change + await provider.handleModeSwitch("invalid-mode") - // The mode WILL be updated to invalid-mode (this is the actual behavior) - expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "invalid-mode") + expect(mockContext.globalState.update).not.toHaveBeenCalled() + expect(mockTask["_taskMode"]).toBe("code") }) it("should handle errors during mode switch gracefully", async () => { @@ -1220,22 +1942,35 @@ describe("ClineProvider - Sticky Mode", () => { // Clear any previous calls vi.clearAllMocks() + // Deterministically wait for the restore's early durable mode write: await the + // write itself (delegating to the real implementation) instead of guessing a + // timer, so the mid-init switch is issued only after the restore's early write. + const originalSaveViewState = provider.saveViewState.bind(provider) + const earlyModeWriteSettled = new Promise((resolve) => { + vi.spyOn(provider, "saveViewState").mockImplementation(async (key, value) => { + await originalSaveViewState(key, value) + if (key === "mode") { + resolve() + } + }) + }) + // Start initialization const initPromise = provider.createTaskWithHistoryItem(historyItem) + // In production the user's switch is issued after the restore's early write, and + // both durable writes then land in that order, so the mid-init switch wins. + await earlyModeWriteSettled + // Try to switch mode during initialization await provider.handleModeSwitch("code") // Wait for initialization to complete await initPromise - // Check all mode update calls - const modeCalls = vi.mocked(mockContext.globalState.update).mock.calls.filter((call) => call[0] === "mode") - - // Based on the actual behavior, the mode switch to "code" happens and persists - // The history mode restoration doesn't override it - const lastModeCall = modeCalls[modeCalls.length - 1] - expect(lastModeCall).toEqual(["mode", "code"]) + // Both mutations now land in the view-local buffer. The history restore runs + // early (before the slow getTaskWithId), so the mid-init switch to "code" wins. + expect(provider["viewLocalState"].mode).toBe("code") }) it("should handle rapid task switches during mode changes", async () => { @@ -1276,4 +2011,148 @@ describe("ClineProvider - Sticky Mode", () => { }) }) }) + + describe("persisted view state keys", () => { + it("does not resolve a view id that names an Object.prototype key through the chain", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed through the durable write path so later writes land in the same + // backing map the assertions read: a frozen get() literal would make every + // post-write assertion read the seed no matter what the provider wrote. + await provider.contextProxy.setValue("viewStates", { "stable-test-view": { mode: "architect" } }) + + const states = provider["getPersistedViewStates"]({ fresh: true }) + expect(Object.getPrototypeOf(states)).toBeNull() + // A view id such as "constructor" must read as an absent entry, not as the + // inherited Object function, otherwise the rekey deletes the temporary entry + // and never writes it under the stable key. + expect(states["constructor"]).toBeUndefined() + expect(states["stable-test-view"]).toEqual({ mode: "architect" }) + + // Registering such an id must not make an inherited member look like a + // persisted entry: the lookup still reports an absent entry, so the rekey keeps the + // temporary entry instead of deleting it. + await provider["setViewStateId"]("constructor") + expect(provider["getPersistedViewStates"]({ fresh: true })["constructor"]).toBeUndefined() + }) + + it("does not load a temporary-id entry that an earlier session wrote", async () => { + // Temporary names restart from 0 on every host start, so storage can still hold + // an entry under this instance's name from a previous session. Seed the entry + // through the ContextProxy before the provider is constructed so the ordinary + // loadViewState read (which goes through the proxy cache) actually sees it. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + await proxy.setValue("viewStates", { + [tempId]: { mode: "architect", currentApiConfigName: "stale-profile" }, + }) + + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + // Await the load itself instead of a fixed timer: the negative + // assertion must run after the load has definitely finished. + await fresh["loadViewState"]() + + // The unowned temporary-id entry must not be adopted... + expect(fresh["viewLocalState"]).toStrictEqual({}) + + // ...and the shared values still win, so the stale entry cannot reach getState(). + const state = await fresh.getState() + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).not.toBe("stale-profile") + }) + + it("does not merge a stale temporary-id entry into this instance's first durable write", async () => { + // A first write under an unowned temporary id claims the slot: fields left by an + // earlier session under the same name must not be adopted (and later re-keyed to + // the stable id) by merging into the colliding entry. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + await proxy.setValue("viewStates", { [tempId]: { currentApiConfigName: "old-profile", updatedAt: 1 } }) + + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + await fresh.saveViewState("mode", "code") + + const states = fresh["getPersistedViewStates"]({ fresh: true }) + expect(states[tempId]).toEqual({ mode: "code", updatedAt: expect.any(Number) }) + expect(fresh["viewLocalState"].currentApiConfigName).toBeUndefined() + }) + + it("does not mark the temporary id as authored when the durable write fails", async () => { + // Authorship must be recorded only after the write lands: a failed persistence + // that still marked the id owned would let a later load adopt an entry that was + // never actually stored. + const tempId = `sidebar-${ClineProvider["nextViewId"]}` + const proxy = new ContextProxy(mockContext) + const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) + expect(fresh.viewId).toBe(tempId) + + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string, _value: unknown) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(fresh.saveViewState("mode", "code")).rejects.toThrow("storage unavailable") + expect(fresh["wroteUnderTemporaryViewStateId"]).toBe(false) + + mockContext.globalState.update = originalUpdate + }) + + it("does not adopt an entry under the temporary name that this instance never wrote", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Temporary ids restart from 0 on every host start, so the same name can + // belong to a different session. An entry this instance never wrote must not + // be adopted as this view's state. + // Seed through the durable write path so the assertions below read the + // same backing map a re-key adoption would write to: a frozen get() + // literal made the negative assertion unable to fail. + await provider.contextProxy.setValue("viewStates", { [provider.viewId]: { mode: "architect" } }) + + await provider["setViewStateId"]("stable-test-view") + + const states = provider["getPersistedViewStates"]({ fresh: true }) + expect(states[provider.viewId]).toEqual({ mode: "architect" }) + expect(states["stable-test-view"]).toBeUndefined() + }) + }) + + describe("setViewStateId input validation", () => { + it("ignores a non-string viewStateId from a crafted message", async () => { + const before = provider["viewStateId"] + // A crafted postMessage can carry any JSON value despite the declared type. + await provider.setViewStateId(42 as unknown as string) + expect(provider["viewStateId"]).toBe(before) + }) + + it("ignores an oversized viewStateId", async () => { + const before = provider["viewStateId"] + await provider.setViewStateId("x".repeat(200)) + expect(provider["viewStateId"]).toBe(before) + }) + + it("refuses a viewStateId already claimed by another live view", async () => { + const other = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await other.setViewStateId("claimed-by-other-view") + await provider.setViewStateId("claimed-by-other-view") + expect(provider["viewStateId"]).not.toBe("claimed-by-other-view") + expect(other["viewStateId"]).toBe("claimed-by-other-view") + } finally { + await other.dispose() + } + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index ccf243f1e1..dae89bc954 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1010,4 +1010,126 @@ describe("ClineProvider - Sticky Provider Profile", () => { ) }) }) + + describe("deleteProviderProfile", () => { + it("removes the stored profile so a dangling mode mapping can no longer re-activate it", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed the stored profile settings: the default profile plus a child + // profile (mirroring the cross-profile subtasks e2e scenario), with the + // "ask" mode mapped to the child profile. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + const childId = await provider.providerSettingsManager.saveConfig("subtask-child-profile", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1-mini", + }) + await provider.providerSettingsManager.setModeConfig("ask", childId) + + // The UI-facing list mirrors the store, as maintained by upsert/activate. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "subtask-child-profile", id: childId, apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "subtask-child-profile", + }) + + await provider.deleteProviderProfile({ + name: "subtask-child-profile", + id: childId, + apiProvider: providerIdentifiers.openrouter, + }) + + // The deleted profile's settings are gone from the manager store. + const remaining = await provider.providerSettingsManager.listConfig() + expect(remaining.some((config) => config.name === "subtask-child-profile")).toBe(false) + await expect( + provider.providerSettingsManager.getProfile({ name: "subtask-child-profile" }), + ).rejects.toThrow(/subtask-child-profile.*not found/) + + // The mode mapping still points at the deleted id, but it no longer + // resolves to a stored profile, so handleModeSwitch falls through to the + // current configuration instead of re-activating the deleted profile. + const savedConfigId = await provider.providerSettingsManager.getModeConfigId("ask") + expect(savedConfigId).toBe(childId) + expect(remaining.find(({ id }) => id === savedConfigId)).toBeUndefined() + + // The context was repointed at the surviving profile. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("treats an already-gone secret as success so the stale list entry is still pruned", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Only the default profile exists in the store: the ghost profile's secret + // was already gone (e.g. pruned by an earlier run) but its list entry + // survived. + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "ghost-profile", + }) + + // The manager's "not found" rejection must not surface to the caller ... + await expect( + provider.deleteProviderProfile({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.openrouter, + }), + ).resolves.not.toThrow() + + // ... it prunes the stale list entry and repoints the selection. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default"]) + }) + + it("still refuses to delete the last stored profile when a stale list entry remains", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const defaultId = await provider.providerSettingsManager.saveConfig("default", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "mock-key", + openRouterModelId: "openai/gpt-4.1", + }) + // A ghost list entry survives next to the only real profile, so the + // provider-level "last profile" guard does not fire: the refusal must come + // from the settings store itself. + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { name: "default", id: defaultId, apiProvider: providerIdentifiers.openrouter }, + { name: "ghost-profile", id: "ghost-id", apiProvider: providerIdentifiers.openrouter }, + ], + currentApiConfigName: "default", + }) + + await expect( + provider.deleteProviderProfile({ + name: "default", + id: defaultId, + apiProvider: providerIdentifiers.openrouter, + }), + ).rejects.toThrow("Cannot delete the last remaining configuration") + + // Nothing was repointed or pruned. + const values = provider.contextProxy.getValues() + expect(values.currentApiConfigName).toBe("default") + expect(values.listApiConfigMeta?.map((entry) => entry.name)).toEqual(["default", "ghost-profile"]) + }) + }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d5c72de742..10aa40d109 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -72,12 +72,14 @@ vi.mock("@roo-code/telemetry", () => ({ hasInstance: vi.fn().mockReturnValue(false), instance: { updateTelemetryState: vi.fn(), + captureCustomModeCreated: vi.fn(), + captureModeSettingChanged: vi.fn(), captureTelemetrySettingsChanged: vi.fn(), }, }, })) -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, RooCodeSettings } from "@roo-code/types" import * as vsCodeLmProvider from "../../../api/providers/vscode-lm" import * as copilotProvider from "../../../api/providers/github-copilot" @@ -110,8 +112,10 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + saveViewState: vi.fn(), customModesManager: { getCustomModes: vi.fn(), + updateCustomMode: vi.fn(), deleteCustomMode: vi.fn(), }, context: { @@ -126,6 +130,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(), @@ -133,6 +147,7 @@ const mockClineProvider = { getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -863,6 +878,193 @@ import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" +describe("webviewMessageHandler - webviewDidLaunch", () => { + // Single structural view of the provider members this suite reassigns at runtime: + // the class type declares several of them as getters / readonly, so the fixture + // type is the writable view of the same object (no cast through unknown needed). + type LaunchProviderFixture = { + setViewStateId: (viewStateId: string) => Promise + workspaceTracker: { initializeFilePaths: () => Promise } + providerSettingsManager: { + listConfig: () => Promise + hasConfig: (name: string) => Promise + } + activateProviderProfile: ( + options: { name: string }, + activateOptions?: { + persistModeConfig?: boolean + persistTaskHistory?: boolean + preserveForeignTask?: boolean + }, + ) => Promise + getMcpHub: () => unknown + getStateToPostToWebview: () => Promise<{ telemetrySetting: string }> + } + const double = mockClineProvider as LaunchProviderFixture + + beforeEach(() => { + vi.clearAllMocks() + // The launch handler sets this plain property on the double; vi.clearAllMocks() + // does not reset it, so clear a leak from a preceding test to keep the + // mark-launched assertion below meaningful. + mockClineProvider.isViewLaunched = false + + 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. toHaveBeenCalledWith proves both + // lookups happened in any order; the call order below proves the order itself. + expect(double.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(mockClineProvider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("shared-profile") + const hasConfigCalls = vi + .mocked(mockClineProvider.providerSettingsManager.hasConfig) + .mock.calls.map((call) => call[0]) + expect(hasConfigCalls.indexOf("view-local-profile")).toBeLessThan(hasConfigCalls.indexOf("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 the view through activation 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 re-pin goes through the activation path so the view adopts the shared + // choice's provider settings, not the invalid profile's stale settings; the + // handler itself performs no global write. The launch-time repair must skip + // both persistence steps so it cannot rewrite the current mode's saved + // profile or the task's sticky profile. + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledTimes(1) + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "currentApiConfigName", + "shared-profile", + ) + expect(mockClineProvider.saveViewState).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.activateProviderProfile).toHaveBeenCalledTimes(1) + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalledWith({ name: "first-listed" }) + expect(mockClineProvider.saveViewState).not.toHaveBeenCalled() + }) + + it("keeps the shared global selection when the first listed profile has no name", async () => { + // Legacy profile shape: the first listed entry carries no name. The still-valid + // shared global selection must be re-pinned through activation, not cleared by + // the repair branch. + double.providerSettingsManager.listConfig = vi + .fn() + .mockResolvedValue([{ 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)) + + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledTimes(1) + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith( + { name: "shared-profile" }, + { persistModeConfig: false, persistTaskHistory: false, preserveForeignTask: true }, + ) + // The repair branch would have cleared the selection with undefined. + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("currentApiConfigName", undefined) + }) + + it("records the legacy repair without activating a profile when no name is listed", async () => { + double.providerSettingsManager.listConfig = vi + .fn() + .mockResolvedValue([{ apiProvider: providerIdentifiers.anthropic }]) + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockResolvedValue(false) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The legacy repair still records the (empty) selection, but does not activate a + // profile that has no name. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", undefined) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("logs and continues launch when view-state registration fails", async () => { + double.setViewStateId = vi.fn().mockRejectedValue(new Error("storage down")) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + // The failed registration is logged ... + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("view-state registration failed")) + // ... launch handling still posts the initial state ... + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + // ... and marks the view as launched. + expect(mockClineProvider.isViewLaunched).toBe(true) + }) +}) + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -1918,6 +2120,9 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { expect(ensureDcgInstalled).toHaveBeenCalledWith("/mock/global/storage") expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true) + // The updateSettings flow must route through the provider-level mutation path so the + // durable view pin write stays ordered with the shared write. + expect(mockClineProvider.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true) expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() }) @@ -1972,6 +2177,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, @@ -2565,6 +2784,291 @@ describe("zooCodeSignOut", () => { }) }) +describe("webviewMessageHandler - renameApiConfiguration", () => { + // Double assertion: the spec-wide provider double only declares the members + // most tests drive; this structural widening keeps these tests free of any. + const provider = mockClineProvider as unknown as { + providerSettingsManager: { + getProfile: ReturnType + saveConfig: ReturnType + deleteConfig: ReturnType + hasConfig: ReturnType + } + repointViewLocalStateAfterProfileRename: ReturnType + activateProviderProfile: ReturnType + } + // Capture the pre-suite values for the members this suite reassigns: + // vi.clearAllMocks() only resets call history - it never restores property + // assignments, so without this restore the rename doubles leak into every + // later suite in this file. + const originalRenameMembers = { + providerSettingsManager: provider.providerSettingsManager, + repointViewLocalStateAfterProfileRename: provider.repointViewLocalStateAfterProfileRename, + activateProviderProfile: provider.activateProviderProfile, + } + + afterEach(() => { + Object.assign(mockClineProvider, originalRenameMembers) + }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("re-points views pinned to the old name before the old name is deleted", async () => { + const order: string[] = [] + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async () => { + order.push("delete") + }), + } + const repoint = vi.fn(async () => { + order.push("repoint") + }) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + expect(repoint).toHaveBeenCalledWith("old-profile", "new-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + }) + // The re-point must precede the deletion: a pinned view switches while the + // old name still resolves, instead of dangling for a window. + expect(order).toEqual(["repoint", "delete"]) + }) + + it("rolls the new profile back when the re-point fails", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const repoint = vi.fn().mockRejectedValueOnce(new Error("repoint failed")) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // A failed re-point must abort before deleting the old name: deleting it + // while views still pin it would dangle every affected view. The rename's + // own durable write is rolled back instead - views back to the old name and + // the new record removed - so a failed rename cannot leave a duplicate. + expect(repoint).toHaveBeenCalledTimes(2) + expect(repoint).toHaveBeenLastCalledWith("new-profile", "old-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + }) + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalledWith("old-profile") + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(provider.activateProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("restores the old pin and removes the new profile when the deletion fails", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // A half-finished rename must not leave views pinned to the new name while + // both profiles exist: re-point them back and drop the new record. + expect(repoint).toHaveBeenCalledWith("new-profile", "old-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + }) + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(provider.activateProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("re-points views with the stored record instead of the raw webview payload", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValueOnce({ id: "profile-id" }).mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // saveConfig normalizes the record; the views and task handlers must receive + // that stored copy, not the raw webview payload with its unvalidated fields. + expect(repoint).toHaveBeenCalledWith("old-profile", "new-profile", { + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + }) + + it("rejects a rename onto an existing profile without saving", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(true), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + provider.repointViewLocalStateAfterProfileRename = vi.fn().mockResolvedValue(undefined) + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "Foo", newName: "foo" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // The webview's name validation treats a case-only difference from the + // current profile as the current profile, so a case-only collision would + // otherwise overwrite the existing record - and the delete-failure rollback + // would then delete a profile the rename never owned. + expect(provider.providerSettingsManager.saveConfig).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("removes the new profile when the normalized read-back fails", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi + .fn() + .mockResolvedValueOnce({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }) + .mockRejectedValueOnce(new Error("read failed")), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // The save landed but the normalized record is unreadable: the new record + // is removed so the failed rename cannot leave both names resolving to the + // same id, and no view is re-pointed onto an unverifiable record. + expect(repoint).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("new-profile") + expect(provider.activateProviderProfile).not.toHaveBeenCalled() + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) + + it("restores the previously stored settings when the deletion fails", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi + .fn() + .mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "old-model", + }) + .mockResolvedValueOnce({ + id: "profile-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "new-model", + }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + const repoint = vi.fn().mockResolvedValue(undefined) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "new-model" }, + }) + + // Views rolled back to oldName must serve what oldName actually stores: the + // attempted settings were never persisted under the old name. + expect(repoint).toHaveBeenLastCalledWith("new-profile", "old-profile", { + apiProvider: providerIdentifiers.anthropic, + id: "profile-id", + apiModelId: "old-model", + }) + }) + + it("logs a failed rename compensation", async () => { + provider.providerSettingsManager = { + hasConfig: vi.fn().mockResolvedValue(false), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn(async (name: string) => { + if (name === "old-profile") { + throw new Error("delete failed") + } + }), + } + const repoint = vi.fn(async (from: string) => { + if (from === "new-profile") { + throw new Error("compensation failed") + } + }) + provider.repointViewLocalStateAfterProfileRename = repoint + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // When the compensation itself fails both names can remain stored; the log + // must record that, not only the original deletion error. + expect(mockClineProvider.log).toHaveBeenCalledWith( + expect.stringContaining("Rename compensation failed after delete rejection"), + ) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Error rename api configuration")) + }) +}) + describe("webviewMessageHandler - kimiCodeSignIn", () => { beforeEach(() => { vi.clearAllMocks() @@ -2802,6 +3306,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 @@ -2969,6 +3492,17 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) + + afterEach(() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) describe("webviewMessageHandler - collapseCommandOutputByDefault", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5596b90e67..702d454d78 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -591,7 +591,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) @@ -640,17 +653,53 @@ 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) through the activation path so the view adopts + // the shared choice's settings: a name-only re-pin would leave the + // invalid profile's stale apiConfiguration in place. The activation + // writes the shared slot back with the same value, so the global + // selection itself is left untouched, and the first listed profile's + // name (absent on legacy shapes) is irrelevant to this branch. + // Skip both persistence steps: this is a launch-time repair of this + // view's own selection, and persisting would silently rewrite the + // current mode's saved profile (setModeConfig) and the task's sticky + // profile, which every other view reads. Also leave a current task that follows + // a different sticky profile alone: this repair must not swap that task's API + // handler (preserveForeignTask). + await provider.activateProviderProfile( + { name: globalConfigName }, + { + persistModeConfig: false, + persistTaskHistory: false, + preserveForeignTask: true, + }, + ) + // 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 + } } } } @@ -700,6 +749,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 @@ -867,7 +917,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() @@ -1660,9 +1712,7 @@ export const webviewMessageHandler = async ( // containment as a literal and escape only after that later decode. if (fromMarkdown) { const decoded = decodeUntrustedPathToStable(rawPath) - // Stryker disable next-line ConditionalExpression,BlockStatement: hostile non-stabilizing encodings are unreachable from the webview (its posts are plain link targets); the bound is defensive if (decoded === null) { - // Stryker disable next-line CallExpression: hostile non-stabilizing encodings are pinned by the direct decodeUntrustedPathToStable bound test; the webview never posts such values rejectOutsideWorkspace() break } @@ -2448,14 +2498,90 @@ export const webviewMessageHandler = async ( break } - // Load the old configuration to get its ID. - const { id } = await provider.providerSettingsManager.getProfile({ name: oldName }) + // The webview's name validation treats a case-only difference from the + // current profile as the current profile, so a case-only collision reaches + // here as a rename onto an existing record. Reject it instead of + // overwriting that profile: the rollback below would otherwise delete a + // record the rename never owned. + if (await provider.providerSettingsManager.hasConfig(newName)) { + throw new Error(`A profile named '${newName}' already exists`) + } + + // Load the old configuration to get its ID, and keep its full stored + // record: a rollback must re-point views to the settings oldName actually + // persists, not to the payload the rename attempted. + const { name: _previousName, ...previousRecord } = + await provider.providerSettingsManager.getProfile({ name: oldName }) + const { id } = previousRecord // Create a new configuration with the new name and old ID. await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) - // Delete the old configuration. - await provider.providerSettingsManager.deleteConfig(oldName) + // saveConfig normalizes the record (legacy downgrades, schema parse): hand + // the stored copy to the views and task handlers instead of the raw + // webview payload, so unvalidated fields cannot bypass the profile-schema + // normalization on the way into view state. When the read-back fails the + // rename cannot proceed on a trustworthy record: remove the new record + // before reporting so the failed rename cannot leave both names resolving + // to the same id. + let normalizedRenamed: typeof previousRecord + try { + const { name: _renamedName, ...renamedRecord } = + await provider.providerSettingsManager.getProfile({ name: newName }) + normalizedRenamed = renamedRecord + } catch (error) { + try { + await provider.providerSettingsManager.deleteConfig(newName) + } catch (cleanupError) { + provider.log( + `Rename rollback failed to remove '${newName}': ${JSON.stringify(cleanupError, Object.getOwnPropertyNames(cleanupError), 2)}`, + ) + } + throw error + } + + // Re-point live views pinned to the old name before it stops + // resolving: the rename keeps the id, so their pins should follow the + // record instead of dangling, and their overlays must serve the + // renamed settings rather than the pre-rename copy. A failed re-point can + // leave views already following newName, so undo this rename's own + // durable write before reporting: the reverse re-point returns the views + // that moved (the others never left oldName) and the new record goes + // away, leaving the pre-rename state intact. + try { + await provider.repointViewLocalStateAfterProfileRename(oldName, newName, normalizedRenamed) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await provider.providerSettingsManager.deleteConfig(newName) + } catch (compensationError) { + provider.log( + `Rename compensation failed after re-point rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) + } + throw error + } + + // Delete the old configuration. A failure here would leave both names + // resolving and every live view pinned to the new name: undo the re-point + // with the previously stored record - not the attempted payload, which was + // never persisted under the old name - and drop the new record so the + // pre-rename state survives intact. A failed compensation is logged: both + // names can then remain stored, and the log is the only trace of that + // inconsistency. + try { + await provider.providerSettingsManager.deleteConfig(oldName) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) + await provider.providerSettingsManager.deleteConfig(newName) + } catch (compensationError) { + provider.log( + `Rename compensation failed after delete rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) + } + throw error + } // Re-activate to update the global settings related to the // currently activated provider profile. diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0dd477ab2d..134b238f21 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 @@ -614,11 +609,6 @@ "count": 24 } }, - "core/config/ContextProxy.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "core/config/CustomModesManager.ts": { "@typescript-eslint/no-explicit-any": { "count": 5 @@ -1016,12 +1006,12 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 196 + "count": 193 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 27 + "count": 23 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index f4ed8af469..ddc0340dc8 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" import type * as vscode from "vscode" +import { providerIdentifiers } from "@roo-code/types" + import { API } from "../api" import type { ClineProvider } from "../../core/webview/ClineProvider" import { makeClineProviderFactory } from "../../test-utils/provider" @@ -15,11 +17,15 @@ describe("API - configuration", () => { const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const getProfile = vi.fn().mockRejectedValue(new Error("profile not found")) + const getModeConfigId = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), + getValue: vi.fn(), + setValues, contextProxy: { setValues }, - providerSettingsManager: { saveConfig, setModeConfig }, + providerSettingsManager: { saveConfig, setModeConfig, getProfile, getModeConfigId }, postStateToWebview, } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel @@ -48,11 +54,15 @@ describe("API - configuration", () => { const saveConfig = vi.fn().mockResolvedValue("default-id") const setModeConfig = vi.fn().mockResolvedValue(undefined) const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const getProfile = vi.fn().mockRejectedValue(new Error("profile not found")) + const getModeConfigId = vi.fn().mockResolvedValue(undefined) const provider = { context: {}, on: vi.fn(), + getValue: vi.fn(), + setValues, contextProxy: { setValues }, - providerSettingsManager: { saveConfig, setModeConfig }, + providerSettingsManager: { saveConfig, setModeConfig, getProfile, getModeConfigId }, postStateToWebview, } as unknown as ClineProvider const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel @@ -63,4 +73,235 @@ describe("API - configuration", () => { expect(setModeConfig).not.toHaveBeenCalled() expect(postStateToWebview).toHaveBeenCalledOnce() }) + + it("flattens the nested view-local apiConfiguration and strips its secrets", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "architect", + currentApiConfigName: "view-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4o", + apiKey: "nested-secret-key", + openRouterApiKey: "nested-openrouter-secret", + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(getValues).toHaveBeenCalledOnce() + expect(configuration.mode).toBe("architect") + expect(configuration.currentApiConfigName).toBe("view-profile") + expect(configuration.apiProvider).toBe(providerIdentifiers.openrouter) + expect(configuration.openRouterModelId).toBe("openai/gpt-4o") + expect(configuration).not.toHaveProperty("apiConfiguration") + expect(configuration).not.toHaveProperty("apiKey") + expect(configuration).not.toHaveProperty("openRouterApiKey") + }) + + it("strips legacy retired-provider credential fields from the flattened surface", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "retired-view-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.anthropic, + groqApiKey: "legacy-groq-secret", + huggingFaceSecurityToken: "legacy-hf-secret", + openRouterModelId: "openai/gpt-4o", + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + // Retired providers keep their legacy fields through passthrough; the + // credential-shaped names must not reach the public API. + expect(configuration).not.toHaveProperty("groqApiKey") + expect(configuration).not.toHaveProperty("huggingFaceSecurityToken") + expect(configuration.openRouterModelId).toBe("openai/gpt-4o") + }) + it("drops shared flat provider settings when a view-local profile is present", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + // Flat provider keys belonging to whichever profile owns the shared store. + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + anthropicBaseUrl: "https://shared.example/v1", + // A non-provider shared setting: must survive the strip. + customInstructions: "keep me", + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4o", + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + // The view-local snapshot wins outright; the shared store's provider keys + // the profile never defined must not leak through. + expect(configuration.apiProvider).toBe(providerIdentifiers.openrouter) + expect(configuration.apiModelId).toBeUndefined() + expect(configuration.anthropicBaseUrl).toBeUndefined() + expect(configuration.openRouterModelId).toBe("openai/gpt-4o") + expect(configuration.customInstructions).toBe("keep me") + expect(configuration.mode).toBe("code") + expect(configuration.currentApiConfigName).toBe("view-profile") + }) + + it("falls back to the shared provider when a view-local profile pins none", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + // The shared store's provider selection: a legacy/partial pinned + // profile carries no apiProvider, and getState() falls back to this. + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + anthropicBaseUrl: "https://shared.example/v1", + apiConfiguration: { + apiModelId: "gpt-4o", + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + // The shared provider survives as the fallback (matching getState()), + // while every other shared provider-setting key stays stripped. + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(configuration.apiModelId).toBe("gpt-4o") + expect(configuration.anthropicBaseUrl).toBeUndefined() + }) + + it("falls back to the shared provider when a view-local profile sets apiProvider to undefined", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + apiProvider: providerIdentifiers.anthropic, + // Explicit undefined in the nested profile: the flatten spread must not + // let it clobber the retained shared fallback (getState() uses ??). + apiConfiguration: { + apiProvider: undefined, + apiModelId: "gpt-4o", + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(configuration.apiModelId).toBe("gpt-4o") + }) + + it("omits credential-bearing keys that SECRET_STATE_KEYS does not name", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "view-profile", + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + apiKey: "sk-secret", + openAiHeaders: { Authorization: "Bearer header-token" }, + vertexJsonCredentials: JSON.stringify({ token: "svc" }), + vertexKeyFile: "C:/secrets/vertex.json", + zooSessionToken: "zoo-session-token", + mimoApiKey: "mimo-key", + poeApiKey: "poe-key", + qwenCodeOauthPath: "C:/secrets/qwen-oauth.json", + geminiCliOAuthPath: "C:/secrets/gemini-oauth.json", + modelTemperature: 0.5, + }, + }) + // Structural double: API.getConfiguration() only reads sidebarProvider.getValues() + // from the provider; the double assertion adapts this minimal shape to the + // constructor's ClineProvider parameter (same pattern as the tests above). + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration.apiKey).toBeUndefined() + expect(configuration.openAiHeaders).toBeUndefined() + expect(configuration.vertexJsonCredentials).toBeUndefined() + expect(configuration.vertexKeyFile).toBeUndefined() + expect(configuration.zooSessionToken).toBeUndefined() + expect(configuration.mimoApiKey).toBeUndefined() + expect(configuration.poeApiKey).toBeUndefined() + expect(configuration.qwenCodeOauthPath).toBeUndefined() + expect(configuration.geminiCliOAuthPath).toBeUndefined() + expect(configuration.modelTemperature).toBe(0.5) + expect(configuration.apiProvider).toBe(providerIdentifiers.openai) + }) + + it("keeps shared flat provider settings when no view-local profile is present", () => { + const getValues = vi.fn().mockReturnValue({ + mode: "code", + currentApiConfigName: "shared-profile", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-sonnet-4-5", + }) + // Structural double: same pattern as the tests above. + const provider = { + context: {}, + on: vi.fn(), + getValues, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider, makeClineProviderFactory()) + + const configuration = api.getConfiguration() + + expect(configuration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(configuration.apiModelId).toBe("claude-sonnet-4-5") + }) }) diff --git a/src/extension/__tests__/api-set-configuration.spec.ts b/src/extension/__tests__/api-set-configuration.spec.ts new file mode 100644 index 0000000000..a553c5e021 --- /dev/null +++ b/src/extension/__tests__/api-set-configuration.spec.ts @@ -0,0 +1,490 @@ +import { describe, expect, it, vi } from "vitest" +import { makeClineProviderFactory } from "../../test-utils/provider" + +import { providerIdentifiers } from "@roo-code/types" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" +import type { OutputChannel } from "vscode" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +vi.mock("../../integrations/terminal/Terminal", () => ({ + Terminal: { + getTerminalProfile: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + closeIdleTerminals: vi.fn(), + }, +})) + +describe("API.setConfiguration", () => { + it("routes configuration through ClineProvider.setValues so view-local state stays in sync", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + setValues: vi.fn().mockResolvedValue(undefined), + getValue: vi.fn(), + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + saveConfig: vi.fn().mockResolvedValue("default-id"), + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + const configuration = { + apiProvider: providerIdentifiers.bedrock, + currentApiConfigName: "default", + awsRegion: "us-east-1", + apiModelId: "us.anthropic.claude-haiku-4-5-20251001-v1:0", + } + + await api.setConfiguration(configuration) + + // Exact side-effect counts: a regression that repeats any of these operations + // (duplicate settings write, duplicate profile save, duplicate webview post) fails. + expect(provider.setValues).toHaveBeenCalledTimes(1) + expect(provider.setValues).toHaveBeenCalledWith(configuration) + expect(provider.contextProxy.setValues).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledTimes(1) + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", configuration) + expect(provider.postStateToWebview).toHaveBeenCalledTimes(1) + }) + + it("merges the existing profile so a partial save keeps omitted settings", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn(), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + getProfile: vi.fn().mockResolvedValue({ + name: "default", + id: "default-id", + apiProvider: providerIdentifiers.openai, + apiModelId: "gpt-4o", + }), + saveConfig: vi.fn().mockResolvedValue("default-id"), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + await api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.anthropic, + }) + + // saveConfig replaces the stored record: the partial values must land on + // top of the existing profile so settings the caller omitted survive. + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("default", { + id: "default-id", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "gpt-4o", + currentApiConfigName: "default", + }) + }) + + it("reports a failed shared-settings restore on the first write path", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn(() => undefined), + setValues: vi + .fn() + .mockRejectedValueOnce(new Error("shared write failed")) + .mockRejectedValueOnce(new Error("shared restore failed")), + contextProxy: { setValues: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + saveConfig: vi.fn().mockResolvedValue("default-id"), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + const failure = await api + .setConfiguration({ currentApiConfigName: "default", apiProvider: providerIdentifiers.openai }) + .then( + () => undefined, + (error: unknown) => error as Error & { restoreFailures?: string[] }, + ) + + expect(failure?.restoreFailures).toHaveLength(1) + expect(failure?.message).toContain("restore the shared settings") + expect((failure?.cause as Error).message).toBe("shared write failed") + }) + + it("reports a failed shared-settings restore on the profile-save path", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn(() => undefined), + setValues: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("shared restore failed")), + contextProxy: { setValues: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + saveConfig: vi.fn().mockRejectedValue(new Error("profile write failed")), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + const failure = await api + .setConfiguration({ currentApiConfigName: "default", apiProvider: providerIdentifiers.openai }) + .then( + () => undefined, + (error: unknown) => error as Error & { restoreFailures?: string[] }, + ) + + expect(failure?.restoreFailures).toHaveLength(1) + expect(failure?.message).toContain("restore the shared settings") + expect((failure?.cause as Error).message).toBe("profile write failed") + }) + + it("compensates the landed writes when a later write fails", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn((key: string) => (key === "currentApiConfigName" ? "old-name" : undefined)), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + getModeConfigId: vi.fn().mockResolvedValue("old-mode-id"), + saveConfig: vi.fn().mockResolvedValue("default-id"), + setModeConfig: vi.fn().mockRejectedValue(new Error("mode write failed")), + deleteConfig: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + await expect( + api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openai, + modeApiConfigs: { code: "new-id" }, + }), + ).rejects.toThrow("mode write failed") + + // Every landed write is undone: the mode mapping returns to its prior id, the + // profile this call created is removed (it did not exist before), and the + // shared values are restored through the same provider setter. + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("code", "old-mode-id") + expect(provider.providerSettingsManager.deleteConfig).toHaveBeenCalledWith("default") + expect(provider.setValues).toHaveBeenCalledTimes(2) + expect(provider.setValues.mock.calls[1][0]).toEqual({ + currentApiConfigName: "old-name", + apiProvider: undefined, + modeApiConfigs: undefined, + }) + }) + + it("reports every failed compensation on the error instead of only logging it", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn((key: string) => (key === "currentApiConfigName" ? "old-name" : undefined)), + setValues: vi + .fn() + .mockResolvedValueOnce(undefined) // the forward write lands + .mockRejectedValueOnce(new Error("shared restore failed")), // the compensation does not + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + getModeConfigId: vi.fn().mockResolvedValue("old-mode-id"), + saveConfig: vi.fn().mockResolvedValue("default-id"), + // The forward mode write fails, and the mode restore fails too. + setModeConfig: vi + .fn() + .mockRejectedValueOnce(new Error("mode write failed")) + .mockRejectedValueOnce(new Error("mode restore failed")), + deleteConfig: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + const failure = await api + .setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openai, + modeApiConfigs: { code: "new-id" }, + }) + .then( + () => undefined, + (error: unknown) => error as Error & { restoreFailures?: string[] }, + ) + + // Both failed restores are reported on the thrown error, not just logged, so a + // caller can tell the compensation is incomplete. + expect(failure).toBeInstanceOf(Error) + expect(failure?.message).toContain("compensation step(s) also failed") + expect(failure?.message).toContain("restore the mode mapping for code") + expect(failure?.message).toContain("restore the shared settings") + expect(failure?.restoreFailures).toHaveLength(2) + // The original write failure is preserved as the cause. + expect((failure?.cause as Error).message).toBe("mode write failed") + }) + + it("leaves the stored profile alone when a transient read failure makes the lookup inconclusive", async () => { + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn((key: string) => (key === "currentApiConfigName" ? "old-name" : undefined)), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("transient read failure")), + hasConfig: vi.fn().mockResolvedValue(true), + getModeConfigId: vi.fn().mockResolvedValue("old-mode-id"), + saveConfig: vi.fn().mockResolvedValue("default-id"), + setModeConfig: vi.fn().mockRejectedValue(new Error("mode write failed")), + deleteConfig: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const appendLine = vi.fn() + // enableLogging so the compensation notice reaches the output channel. + const api = new API( + Object.assign({} as OutputChannel, { appendLine }), + provider, + makeClineProviderFactory(), + undefined, + true, + ) + + await expect( + api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openai, + modeApiConfigs: { code: "new-id" }, + }), + ).rejects.toThrow("mode write failed") + + // The profile exists (hasConfig confirms it) but its read failed: deleting + // would destroy an existing profile and there is no snapshot to restore, so + // compensation leaves the stored record alone and says why. + expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledOnce() + expect(appendLine).toHaveBeenCalledWith( + expect.stringContaining('profile lookup for "default" was inconclusive'), + ) + }) + it("restores the shared values when the first shared write fails partway", async () => { + // The durable state visible to the caller once every write has settled. + const stored: Record = {} + let sharedWriteCount = 0 + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn((key: string) => (key === "currentApiConfigName" ? "old-name" : undefined)), + // ContextProxy.setValues persists keys one at a time: a rejection here can + // mean a durable subset of the payload already landed. The first call + // durably persists currentApiConfigName before failing; the restoration + // call succeeds and its payload lands in `stored`. + setValues: vi.fn(async (values: Record) => { + sharedWriteCount++ + if (sharedWriteCount === 1) { + stored.currentApiConfigName = values.currentApiConfigName + throw new Error("shared write failed") + } + Object.assign(stored, values) + }), + contextProxy: { + setValues: vi.fn().mockResolvedValue(undefined), + }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("default-id"), + setModeConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + await expect( + api.setConfiguration({ currentApiConfigName: "default", apiProvider: providerIdentifiers.openai }), + ).rejects.toThrow("shared write failed") + + // Compensation runs from the first write: the fields whose durable write may + // have landed are restored, and the later profile save never runs. + expect(provider.setValues).toHaveBeenCalledTimes(2) + expect(provider.setValues.mock.calls[1][0]).toEqual({ + currentApiConfigName: "old-name", + apiProvider: undefined, + }) + expect(provider.providerSettingsManager.saveConfig).not.toHaveBeenCalled() + // The restoration call succeeded: the field the failed write had persisted is + // back to the prior selection, and no other field of the failed payload + // remains stored. + expect(stored).toEqual({ currentApiConfigName: "old-name", apiProvider: undefined }) + expect(provider.postStateToWebview).not.toHaveBeenCalled() + }) + it("serializes overlapping calls so snapshots cannot compensate each other", async () => { + const calls: string[] = [] + let releaseSave: (() => void) | undefined + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn(), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { setValues: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + getProfile: vi.fn().mockImplementation(async () => { + calls.push("snapshot") + throw new Error("profile not found") + }), + hasConfig: vi.fn().mockResolvedValue(false), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockImplementation(async () => { + calls.push("save") + await new Promise((resolve) => { + releaseSave = resolve + }) + return "default-id" + }), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + const first = api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.anthropic, + }) + await vi.waitFor(() => expect(calls).toEqual(["snapshot", "save"])) + const second = api.setConfiguration({ + currentApiConfigName: "default", + apiProvider: providerIdentifiers.openai, + }) + // The second call must wait for the first to settle before taking its snapshot: + // unserialized calls snapshot and compensate against each other's writes. + await new Promise((resolve) => setImmediate(resolve)) + expect(calls).toEqual(["snapshot", "save"]) + releaseSave!() + // The second call now parks in its own save; release it too. + await vi.waitFor(() => expect(calls).toEqual(["snapshot", "save", "snapshot", "save"])) + releaseSave!() + await Promise.all([first, second]) + expect(calls).toEqual(["snapshot", "save", "snapshot", "save"]) + }) + + it("awaits every mode write before compensating the landed mappings", async () => { + const calls: string[] = [] + let landArchitect: (() => void) | undefined + const provider = Object.assign({} as ClineProvider, { + context: {}, + on: vi.fn(), + getValue: vi.fn(), + setValues: vi.fn().mockResolvedValue(undefined), + contextProxy: { setValues: vi.fn().mockResolvedValue(undefined) }, + providerSettingsManager: { + getProfile: vi.fn().mockRejectedValue(new Error("profile not found")), + hasConfig: vi.fn().mockResolvedValue(false), + deleteConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn().mockResolvedValue("default-id"), + getModeConfigId: vi + .fn() + .mockImplementation(async (mode: string) => + mode === "code" ? "old-code" : mode === "architect" ? "old-architect" : undefined, + ), + setModeConfig: vi.fn().mockImplementation(async (mode: string, configId: string) => { + if (configId === "new-code") { + calls.push("write:code") + throw new Error("mode write failed") + } + if (configId === "new-architect") { + calls.push("write:architect") + await new Promise((resolve) => { + landArchitect = resolve + }) + calls.push("landed:architect") + return + } + calls.push(`restore:${mode}`) + }), + }, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + const api = new API( + Object.assign({} as OutputChannel, { appendLine: vi.fn() }), + provider, + makeClineProviderFactory(), + ) + + const pending = api.setConfiguration({ + currentApiConfigName: "default", + modeApiConfigs: { code: "new-code", architect: "new-architect" }, + }) + await vi.waitFor(() => expect(calls).toContain("write:architect")) + landArchitect!() + await expect(pending).rejects.toThrow("mode write failed") + await new Promise((resolve) => setImmediate(resolve)) + // The in-flight architect write settled before the compensation restored the + // mappings, so no mode write can land after its own restore. + expect(calls.indexOf("landed:architect")).toBeLessThan(calls.indexOf("restore:code")) + }) +}) diff --git a/src/extension/__tests__/api-start-new-task.spec.ts b/src/extension/__tests__/api-start-new-task.spec.ts index e33f2b3553..6d7c2da625 100644 --- a/src/extension/__tests__/api-start-new-task.spec.ts +++ b/src/extension/__tests__/api-start-new-task.spec.ts @@ -21,6 +21,7 @@ function createProvider(taskId: string) { postStateToWebview: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), createTask: vi.fn().mockResolvedValue(task), + dispose: vi.fn().mockResolvedValue(undefined), }) } @@ -96,6 +97,41 @@ describe("API - startNewTask routing", () => { expect(other.createTask).not.toHaveBeenCalled() }) + it("creates a task without a configuration without writing settings", async () => { + const taskId = await api.startNewTask({ text: "no configuration" }) + + // Omitted configuration means "use whatever the provider already has": no settings + // write, and the provider receives undefined so it cannot overwrite defaults. + expect(sidebar.createTask).toHaveBeenCalledExactlyOnceWith( + "no configuration", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 0 }, + undefined, + ) + expect(taskId).toBe("sidebar-task") + }) + + it("disposes only the provider it created when the new-tab task creation fails", async () => { + editor.createTask.mockRejectedValueOnce(new Error("task creation failed")) + + await expect(api.startNewTask({ text: "new task", newTab: true })).rejects.toThrow("task creation failed") + + // The provider this call created must not linger in the active-instance list; the + // pre-existing sidebar provider is not this call's to tear down. + expect(editor.dispose).toHaveBeenCalledOnce() + expect(sidebar.dispose).not.toHaveBeenCalled() + }) + + it("disposes the created new-tab provider when a post fails before the task starts", async () => { + editor.postMessageToWebview.mockRejectedValueOnce(new Error("webview post failed")) + + await expect(api.startNewTask({ text: "new task", newTab: true })).rejects.toThrow("webview post failed") + + expect(editor.dispose).toHaveBeenCalledOnce() + expect(sidebar.dispose).not.toHaveBeenCalled() + }) + it("propagates tab-opening failures without starting a task in another chat", async () => { const error = new Error("Cannot open editor chat") vi.mocked(providerFactory.createInNewTab).mockRejectedValueOnce(error) diff --git a/src/extension/__tests__/api-task-control.spec.ts b/src/extension/__tests__/api-task-control.spec.ts new file mode 100644 index 0000000000..6aef5ffb40 --- /dev/null +++ b/src/extension/__tests__/api-task-control.spec.ts @@ -0,0 +1,660 @@ +import { EventEmitter } from "events" +import { makeClineProviderFactory } from "../../test-utils/provider" + +import { describe, expect, it, vi, beforeEach, type Mock } from "vitest" +import * as vscode from "vscode" + +import { RooCodeEventName, type ModeConfig, type RooCodeSettings } from "@roo-code/types" + +import { API } from "../api" +import { ClineProvider } from "../../core/webview/ClineProvider" + +const { openClineInNewTabMock } = vi.hoisted(() => ({ + openClineInNewTabMock: vi.fn(), +})) + +vi.mock("vscode", () => ({ + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + ExtensionMode: { + Development: 1, + ExtensionDevelopment: 2, + Test: 3, + Production: 4, + }, +})) + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +vi.mock("../../activate/registerCommands", () => ({ + openClineInNewTab: openClineInNewTabMock, +})) + +vi.mock("../../integrations/terminal/Terminal", () => ({ + Terminal: { + getTerminalProfile: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +vi.mock("../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + closeIdleTerminals: vi.fn(), + }, +})) + +type CreatedTask = { + taskId: string +} + +type ProviderDouble = EventEmitter & { + // Minimal context surface exercised by the task-control tests: the mode guard + // reads extensionMode and getGlobalState/setGlobalState read/update globalState. + context: { + extensionMode: vscode.ExtensionMode + globalState: { + get: (key: string) => unknown + update?: (key: string, value: unknown) => Promise + } + } + evictCurrentTask: Mock<() => Promise> + postStateToWebview: Mock<() => Promise> + postMessageToWebview: Mock<(message: unknown) => Promise> + createTask: Mock<(...args: unknown[]) => Promise> + getCurrentTaskStack: Mock<() => string[]> + getCurrentTask: Mock<() => undefined> + getState: Mock<() => Promise<{ customModes?: ModeConfig[] }>> + handleModeSwitch: Mock<(mode: string, targetTask?: unknown) => Promise> + viewLaunched: boolean + hasResolvedView: boolean + isDisposed: boolean + viewStateReadiness: Promise + taskHistoryStore: Map +} + +type TaskDouble = EventEmitter & { + taskId: string + parentTaskId?: string + approveAsk: Mock<() => void> + denyAsk: Mock<() => void> + handleWebviewAskResponse: Mock<(response: "messageResponse", text?: string, images?: string[]) => void> +} + +const configuration: RooCodeSettings = {} + +function asClineProvider(provider: ProviderDouble): ClineProvider { + // ClineProvider has private members, so a structural test double requires an unknown bridge. + return provider as unknown as ClineProvider +} + +function createProvider(taskId = "task-1"): ProviderDouble { + const provider = new EventEmitter() as ProviderDouble + // The default double leaves extensionMode unset (never Production), so the test-only + // guard stays inert for the task-control tests. + provider.context = {} as ProviderDouble["context"] + provider.evictCurrentTask = vi.fn().mockResolvedValue(undefined) + provider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + provider.createTask = vi.fn().mockResolvedValue({ taskId }) + provider.getCurrentTaskStack = vi.fn().mockReturnValue([]) + provider.getCurrentTask = vi.fn().mockReturnValue(undefined) + provider.getState = vi.fn().mockResolvedValue({ customModes: [] }) + provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) + provider.viewLaunched = true + provider.hasResolvedView = true + provider.isDisposed = false + provider.viewStateReadiness = Promise.resolve() + provider.taskHistoryStore = new Map() + return provider +} + +function createTask(taskId: string): TaskDouble { + const task = new EventEmitter() as TaskDouble + task.taskId = taskId + task.approveAsk = vi.fn() + task.denyAsk = vi.fn() + task.handleWebviewAskResponse = vi.fn() + return task +} + +// The merged API creates editor tabs through ClineProviderFactory; route the factory's +// createInNewTab through the registerCommands mock so the tab-creation assertions keep +// exercising the same seam (payload and resolved provider). +const makeTabCreatingFactory = (provider: { context: unknown }, channel: unknown) => { + const factory = makeClineProviderFactory() + factory.createInNewTab = vi + .fn() + .mockImplementation(async () => openClineInNewTabMock({ context: provider.context, outputChannel: channel })) + return factory +} +describe("API task controls", () => { + let outputChannel: vscode.OutputChannel + let sidebarProvider: ProviderDouble + let api: API + + beforeEach(() => { + vi.clearAllMocks() + outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + sidebarProvider = createProvider("sidebar-task") + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + ) + }) + + describe("startNewTask", () => { + it("reverts and closes existing editors before opening a new tab unless preserveOpenTabs is true", async () => { + const newTabProvider = createProvider("new-tab-task") + openClineInNewTabMock.mockResolvedValue(newTabProvider) + + const taskId = await api.startNewTask({ configuration, text: "new task", newTab: true }) + + expect(taskId).toBe("new-tab-task") + expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "workbench.action.files.revert") + expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(2, "workbench.action.closeAllEditors") + expect(openClineInNewTabMock).toHaveBeenCalledWith({ + context: sidebarProvider.context, + outputChannel, + }) + expect(newTabProvider.evictCurrentTask).toHaveBeenCalledOnce() + expect(newTabProvider.createTask).toHaveBeenCalledWith( + "new task", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 0 }, + configuration, + ) + }) + + it("opens a new tab without revert or close commands when preserveOpenTabs is true", async () => { + const newTabProvider = createProvider("preserved-tab-task") + openClineInNewTabMock.mockResolvedValue(newTabProvider) + + const taskId = await api.startNewTask({ + configuration, + text: "keep editors", + newTab: true, + preserveOpenTabs: true, + }) + + expect(taskId).toBe("preserved-tab-task") + expect(vscode.commands.executeCommand).not.toHaveBeenCalled() + expect(openClineInNewTabMock).toHaveBeenCalledWith({ + context: sidebarProvider.context, + outputChannel, + }) + expect(newTabProvider.createTask).toHaveBeenCalledWith( + "keep editors", + undefined, + undefined, + { consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, toolRepetitionSoftLimit: 0 }, + configuration, + ) + }) + + it("fails task creation when the view state is not ready within the readiness bound", async () => { + // A readiness promise that never settles: the bound must reject task creation + // instead of letting createTask run against stale shared defaults. + sidebarProvider.viewStateReadiness = new Promise(() => {}) + + await expect(api.startNewTask({ configuration, text: "new task" })).rejects.toThrow( + "Timed out waiting for the view state to become ready", + ) + expect(sidebarProvider.createTask).not.toHaveBeenCalled() + }) + + it("cancels the readiness timeout when the view becomes ready within the bound", async () => { + vi.useFakeTimers() + let resolveReady!: () => void + sidebarProvider.viewStateReadiness = new Promise((resolve) => { + resolveReady = resolve + }) + + const started = api.startNewTask({ configuration, text: "ready task" }) + await vi.advanceTimersByTimeAsync(50) + resolveReady() + + try { + await expect(started).resolves.toBe("sidebar-task") + // The readiness bound's timer must be gone once readiness wins: a live timer + // would later reject an unawaited promise and surface as an unhandled + // rejection. + expect(vi.getTimerCount()).toBe(0) + // No late rejection can fire after the bound either. + await vi.advanceTimersByTimeAsync(3000) + } finally { + vi.useRealTimers() + } + }) + + it("continues with the shared-state fallback when the webview never launches", async () => { + // Headless API callers create tasks without ever launching a webview: the + // readiness bound expiring must not reject them - the pre-existing + // shared-state fallback still applies. + vi.useFakeTimers() + sidebarProvider.viewLaunched = false + sidebarProvider.hasResolvedView = false + sidebarProvider.viewStateReadiness = new Promise(() => {}) + + const started = api.startNewTask({ configuration, text: "headless task" }) + // No timer advance: the headless fast path must not wait for the readiness bound at + // all. Advancing here would let a regression back into the timeout fallback pass. + + try { + await expect(started).resolves.toBe("sidebar-task") + } finally { + vi.useRealTimers() + } + }) + + it("still rejects when the webview launched but the readiness bound expires", async () => { + // A launched view whose readiness never settles is a real failure: the + // task must not consume shared defaults behind a live view. + vi.useFakeTimers() + sidebarProvider.viewStateReadiness = new Promise(() => {}) + + const started = api.startNewTask({ configuration, text: "stuck view task" }) + // Attach the rejection handler before advancing: with fake timers the + // bound fires during the advance, and a handler attached afterwards would + // see the rejection briefly unhandled. + const settled = expect(started).rejects.toThrow("Timed out waiting for the view state to become ready") + await vi.advanceTimersByTimeAsync(3000) + + try { + await settled + } finally { + vi.useRealTimers() + } + }) + + it("fails task creation when the provider is disposed while the readiness wait is pending", async () => { + // Disposal resolves viewStateReadiness to release waiters (see ClineProvider.dispose) + // and marks the provider disposed first: the wait must then fail instead of + // creating a task against the disposed view. + let resolveReady!: () => void + sidebarProvider.viewStateReadiness = new Promise((resolve) => { + resolveReady = resolve + }) + + const started = api.startNewTask({ configuration, text: "new task" }) + await new Promise((resolve) => setTimeout(resolve, 50)) + sidebarProvider.isDisposed = true + resolveReady() + + await expect(started).rejects.toThrow( + "The provider was disposed while waiting for the view state to become ready", + ) + expect(sidebarProvider.createTask).not.toHaveBeenCalled() + }) + }) + + describe("task ask registry", () => { + it("returns false when approving an unknown task", async () => { + await expect(api.approveTaskAsk("missing-task")).resolves.toBe(false) + }) + + it("registers tasks on TaskCreated and approves a task by id", async () => { + const task = createTask("task-to-approve") + + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect(api.approveTaskAsk(task.taskId)).resolves.toBe(true) + expect(task.approveAsk).toHaveBeenCalledOnce() + }) + it("denies a registered task by id", async () => { + const task = createTask("task-to-deny") + + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect(api.denyTaskAsk(task.taskId)).resolves.toBe(true) + expect(task.denyAsk).toHaveBeenCalledOnce() + }) + + it("returns false when denying an unknown or de-registered task", async () => { + const task = createTask("task-denied-lifecycle") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, {}, {}) + + // Unknown id, and an id whose registry entry was removed on completion. + await expect(api.denyTaskAsk("missing-task")).resolves.toBe(false) + await expect(api.denyTaskAsk(task.taskId)).resolves.toBe(false) + }) + + it("removes completed, aborted, and unfocused tasks from the registry", async () => { + const completedTask = createTask("completed-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, completedTask) + completedTask.emit(RooCodeEventName.TaskCompleted, completedTask.taskId, {}, {}) + + await expect(api.approveTaskAsk(completedTask.taskId)).resolves.toBe(false) + + const abortedTask = createTask("aborted-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, abortedTask) + abortedTask.emit(RooCodeEventName.TaskAborted) + + await expect(api.approveTaskAsk(abortedTask.taskId)).resolves.toBe(false) + + const unfocusedTask = createTask("unfocused-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, unfocusedTask) + unfocusedTask.emit(RooCodeEventName.TaskUnfocused) + + await expect(api.approveTaskAsk(unfocusedTask.taskId)).resolves.toBe(false) + }) + + it("ignores a late teardown event after the registry entry was already removed", async () => { + const task = createTask("late-teardown-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + task.emit(RooCodeEventName.TaskCompleted, task.taskId, {}, {}) + + // The completed event already removed the entry. A late duplicate teardown event + // must be a silent no-op, not a crash on the missing entry. + expect(() => task.emit(RooCodeEventName.TaskAborted)).not.toThrow() + await expect(api.approveTaskAsk(task.taskId)).resolves.toBe(false) + }) + + it("re-emits TaskStarted and records it in the message log for a registered task", async () => { + // The per-task TaskStarted listener re-emits the event on the API and records it + // through fileLog: spy on the prototype so the message content is observable + // (fileLog is a no-op without the enableLogging flag). + // fileLog is private: target the spy on the prototype shape directly; the + // `"fileLog" as keyof API` cast pushes the spyOn overload union past the + // compiler complexity ceiling after the setConfiguration queue landed. + const fileLogSpy = vi + .spyOn(API.prototype as unknown as { fileLog: (message: string) => Promise }, "fileLog") + .mockResolvedValue(undefined) + const task = createTask("task-to-start") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + const started = new Promise((resolve) => { + api.once(RooCodeEventName.TaskStarted, (taskId: string) => resolve(taskId)) + }) + task.emit(RooCodeEventName.TaskStarted) + + await expect(started).resolves.toBe("task-to-start") + expect(fileLogSpy).toHaveBeenCalledWith(expect.stringContaining("taskStarted -> task-to-start")) + fileLogSpy.mockRestore() + }) + }) + + describe("selectTaskFollowupSuggestion", () => { + it("returns false when the task is unknown", async () => { + await expect( + api.selectTaskFollowupSuggestion({ taskId: "missing-task", answer: "Use this" }), + ).resolves.toBe(false) + }) + + it("responds to the task without switching modes when no mode is provided", async () => { + const task = createTask("task-without-mode") + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect(api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Continue" })).resolves.toBe( + true, + ) + + expect(sidebarProvider.getState).not.toHaveBeenCalled() + expect(sidebarProvider.handleModeSwitch).not.toHaveBeenCalled() + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Continue") + }) + + it("switches to a valid built-in mode before responding", async () => { + const task = createTask("task-built-in-mode") + // Deferred mode switch: the follow-up answer must stay pending while the + // switch is, so the assertion below can observe the in-between state. + let releaseModeSwitch: () => void = () => {} + const modeSwitchSettled = new Promise((resolve) => { + releaseModeSwitch = resolve + }) + sidebarProvider.handleModeSwitch.mockImplementation(() => modeSwitchSettled) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + const selecting = api.selectTaskFollowupSuggestion({ + taskId: task.taskId, + answer: "Use architect", + mode: "architect", + }) + + // Flush microtasks so the flow reaches the (still pending) mode-switch await. + for (let i = 0; i < 8; i++) { + await Promise.resolve() + } + + expect(sidebarProvider.getState).toHaveBeenCalledOnce() + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + // The mode switch must be awaited before answering: while it is still + // pending, the task's ask stays unanswered. + expect(task.handleWebviewAskResponse).not.toHaveBeenCalled() + + releaseModeSwitch() + await expect(selecting).resolves.toBe(true) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Use architect") + }) + + it("responds without switching modes and logs when the requested mode is invalid", async () => { + const task = createTask("task-invalid-mode") + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + undefined, + true, + ) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Use invalid", mode: "not-a-mode" }), + ).resolves.toBe(true) + + expect(sidebarProvider.getState).toHaveBeenCalledOnce() + expect(sidebarProvider.handleModeSwitch).not.toHaveBeenCalled() + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Use invalid") + expect(outputChannel.appendLine).toHaveBeenCalledWith( + '[API#selectTaskFollowupSuggestion] ignoring unknown mode "not-a-mode" for task task-invalid-mode', + ) + }) + + it("treats custom modes from the task provider state as valid", async () => { + const task = createTask("task-custom-mode") + const customMode: ModeConfig = { + slug: "custom-review", + name: "Custom Review", + roleDefinition: "Review the implementation", + groups: ["read"], + } + sidebarProvider.getState.mockResolvedValue({ customModes: [customMode] }) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Review it", mode: customMode.slug }), + ).resolves.toBe(true) + + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith(customMode.slug, task) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Review it") + }) + }) +}) + +describe("API task controls - per-view review fixes", () => { + let outputChannel: vscode.OutputChannel + let sidebarProvider: ProviderDouble + let api: API + + beforeEach(() => { + vi.clearAllMocks() + outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + sidebarProvider = createProvider("sidebar-task") + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + ) + }) + + it("keeps the new registration when a replaced task instance tears down", async () => { + const staleTask = createTask("replaced-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, staleTask) + + // A new instance reusing the same taskId replaces the stale registration. + const freshTask = createTask("replaced-task") + sidebarProvider.emit(RooCodeEventName.TaskCreated, freshTask) + + // The stale instance teardown must not drop the new registration. + staleTask.emit(RooCodeEventName.TaskAborted) + await expect(api.approveTaskAsk("replaced-task")).resolves.toBe(true) + + freshTask.emit(RooCodeEventName.TaskUnfocused) + await expect(api.approveTaskAsk("replaced-task")).resolves.toBe(false) + }) + + it("still delivers the follow-up answer when the mode switch fails", async () => { + const task = createTask("task-failing-switch") + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + undefined, + true, + ) + sidebarProvider.handleModeSwitch.mockRejectedValueOnce(new Error("persist failed")) + sidebarProvider.emit(RooCodeEventName.TaskCreated, task) + + await expect( + api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Deliver anyway", mode: "architect" }), + ).resolves.toBe(true) + + expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith("architect", task) + expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Deliver anyway") + expect(outputChannel.appendLine).toHaveBeenCalledWith( + "[API#selectTaskFollowupSuggestion] mode switch failed for task task-failing-switch: persist failed", + ) + }) + + describe("test-only API production guard", () => { + function withProductionContext() { + return { + extensionMode: vscode.ExtensionMode.Production, + globalState: { get: vi.fn() }, + } + } + + it("rejects the task ask and global-state surface in production mode", async () => { + sidebarProvider.context = withProductionContext() + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + ) + + // Pin the method name in each message: the guard builds the error from the + // method argument, and a partial match would leave the argument untested. + await expect(api.approveTaskAsk("task-1")).rejects.toThrow("approveTaskAsk is a test-only API") + await expect(api.denyTaskAsk("task-1")).rejects.toThrow("denyTaskAsk is a test-only API") + await expect(api.selectTaskFollowupSuggestion({ taskId: "task-1", answer: "yes" })).rejects.toThrow( + "selectTaskFollowupSuggestion is a test-only API", + ) + expect(() => api.getGlobalState("mode")).toThrow("getGlobalState is a test-only API") + // setGlobalState is async: the guard rejects instead of throwing synchronously. + await expect(api.setGlobalState("mode", "ask")).rejects.toThrow("setGlobalState is a test-only API") + }) + + it("keeps the task ask and global-state surface available outside production mode", async () => { + const get = vi.fn().mockReturnValue("code") + const update = vi.fn().mockResolvedValue(undefined) + sidebarProvider.context = { + extensionMode: vscode.ExtensionMode.Test, + globalState: { get, update }, + } + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + ) + + expect(api.getGlobalState("mode")).toBe("code") + expect(get).toHaveBeenCalledWith("mode") + await api.setGlobalState("mode", "ask") + expect(update).toHaveBeenCalledWith("mode", "ask") + await expect(api.approveTaskAsk("missing-task")).resolves.toBe(false) + }) + + it("rejects PII keys and malformed values before touching global-state storage", async () => { + const get = vi.fn().mockReturnValue("stored") + const update = vi.fn().mockResolvedValue(undefined) + sidebarProvider.context = { + extensionMode: vscode.ExtensionMode.Test, + globalState: { get, update }, + } + api = new API( + outputChannel, + asClineProvider(sidebarProvider), + makeTabCreatingFactory(sidebarProvider, outputChannel), + ) + + // Task history carries user PII and stays unreachable even though it is a + // plain GlobalState key. + expect(() => api.getGlobalState("taskHistory")).toThrow("PII-bearing key") + await expect(api.setGlobalState("taskHistory", [])).rejects.toThrow("PII-bearing key") + // Values are runtime data: a malformed value is rejected before storage. + await expect(api.setGlobalState("alwaysAllowWrite", {} as never)).rejects.toThrow("invalid value for key") + // Provider-settings keys have no global-settings schema entry. + await expect(api.setGlobalState("apiModelId", "x")).rejects.toThrow("without a value schema") + expect(update).not.toHaveBeenCalled() + expect(get).not.toHaveBeenCalled() + // An allowed key with a valid value still works. + await api.setGlobalState("mode", "ask") + expect(update).toHaveBeenCalledWith("mode", "ask") + // Non-strict object schemas strip unknown fields only in the parsed + // value: the API must persist the parsed data, not the raw payload. + await api.setGlobalState("viewStates", { + "view-a": { mode: "debug", apiKey: "stray-secret" }, + } as never) + expect(update).toHaveBeenLastCalledWith("viewStates", { "view-a": { mode: "debug" } }) + }) + }) + + describe("startNewTask view-state readiness", () => { + it("waits for the provider view state to load before creating the task", async () => { + let resolveReadiness: () => void = () => {} + const readiness = new Promise((resolve) => { + resolveReadiness = resolve + }) + const newTabProvider = createProvider("new-tab-task") + newTabProvider.viewStateReadiness = readiness + openClineInNewTabMock.mockResolvedValue(newTabProvider) + + const started = api.startNewTask({ configuration, text: "wait for view state", newTab: true }) + + // The task must not start while the view persisted state is still loading. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(newTabProvider.createTask).not.toHaveBeenCalled() + + resolveReadiness() + + await expect(started).resolves.toBe("new-tab-task") + expect(newTabProvider.createTask).toHaveBeenCalled() + }) + }) + + describe("startNewTask listener idempotency", () => { + it("registers provider listeners once when the same provider is reused", async () => { + const sharedProvider = createProvider("shared-task") + openClineInNewTabMock.mockResolvedValue(sharedProvider) + + await api.startNewTask({ configuration, text: "one", newTab: true }) + await api.startNewTask({ configuration, text: "two", newTab: true }) + + // Re-registering the same provider must not stack duplicate listeners. + expect(sharedProvider.listenerCount(RooCodeEventName.TaskCompleted)).toBe(1) + + let completions = 0 + api.on(RooCodeEventName.TaskCompleted, () => { + completions++ + }) + sharedProvider.emit(RooCodeEventName.TaskCompleted, "shared-task") + + expect(completions).toBe(1) + }) + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 6a8ed8dfdd..da30b4117b 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -8,35 +8,59 @@ import pWaitFor from "p-wait-for" import { type RooCodeAPI, + type RooCodeTestOnlyApi, + type GlobalState, type RooCodeSettings, type RooCodeEvents, type ProviderSettings, type ProviderSettingsEntry, type TaskEvent, + type TaskLike, type CreateTaskOptions, type TaskApiConversationHistorySequence, type WebviewThemeFixture, RooCodeEventName, TaskCommandName, isSecretStateKey, + isSensitiveProviderConfigKey, + isGlobalStateKey, + globalSettingsSchema, + PROVIDER_SETTINGS_KEYS, IpcOrigin, IpcMessageType, } from "@roo-code/types" import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" -import type { Mode } from "../shared/modes" -import type { ClineProvider } from "../core/webview/ClineProvider" +import { getAllModes, type Mode } from "../shared/modes" +import { ClineProvider } from "../core/webview/ClineProvider" import type { ClineProviderFactory } from "../core/webview/ClineProviderFactory" +import type { Task } from "../core/task/Task" import { readTaskMessages } from "../core/task-persistence/taskMessages" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { getCommands } from "../services/command/commands" -export class API extends EventEmitter implements RooCodeAPI { +type TaskAskController = { + approveAsk(): void + denyAsk(): void + handleWebviewAskResponse(response: "messageResponse", text?: string, images?: string[]): void +} + +type RegisteredTask = { + task: TaskAskController + provider: ClineProvider +} + +export class API extends EventEmitter implements RooCodeAPI, RooCodeTestOnlyApi { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider + private readonly context: vscode.ExtensionContext private readonly ipc?: IpcServer + private readonly tasksById = new Map() + + // Providers whose listeners are already wired (startNewTask is idempotent per provider). + private readonly registeredProviders = new WeakSet() private readonly log: (...args: unknown[]) => void private logfile?: string @@ -51,6 +75,7 @@ export class API extends EventEmitter implements RooCodeAPI { this.outputChannel = outputChannel this.sidebarProvider = provider + this.context = provider.context if (enableLogging) { this.log = (...args: unknown[]) => { @@ -85,7 +110,15 @@ export class API extends EventEmitter implements RooCodeAPI { this.log( `[API] StartNewTask -> ${command.data.text}, ${JSON.stringify(command.data.configuration)}`, ) - await this.startNewTask(command.data) + try { + await this.startNewTask(command.data) + } catch (error) { + // IpcServer does not await async listeners: an uncaught rejection here + // would surface as an unhandled rejection in the extension host. + this.log( + `[API] StartNewTask failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } break case TaskCommandName.CancelTask: this.log(`[API] CancelTask`) @@ -174,19 +207,27 @@ export class API extends EventEmitter implements RooCodeAPI { text, images, newTab, + preserveOpenTabs, }: { - configuration: RooCodeSettings + configuration?: RooCodeSettings text?: string images?: string[] newTab?: boolean + preserveOpenTabs?: boolean }) { let provider: ClineProvider + // A provider created for this call and never handed a task must not outlive the + // failed call in the active-instance list. + let createdInNewTab = false if (newTab) { - await vscode.commands.executeCommand("workbench.action.files.revert") - await vscode.commands.executeCommand("workbench.action.closeAllEditors") + if (!preserveOpenTabs) { + await vscode.commands.executeCommand("workbench.action.files.revert") + await vscode.commands.executeCommand("workbench.action.closeAllEditors") + } provider = await this.providerFactory.createInNewTab() + createdInNewTab = true this.registerListeners(provider) } else { await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) @@ -194,27 +235,80 @@ export class API extends EventEmitter implements RooCodeAPI { provider = this.sidebarProvider } - await provider.evictCurrentTask() - await provider.postStateToWebview() - await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) + try { + // A new task must not consume shared default mode/profile state before the view's + // stable state id is registered and its persisted view state loaded: webviewDidLaunch + // may not have arrived yet. Bound the wait so a view that never launches cannot + // hang task creation. + // + // Skip the wait when there is nothing to wait for: a sidebar provider that never + // resolved a view cannot launch a webview (the headless API path), so waiting would + // only burn the full bound before the existing shared-state fallback runs. A new + // tab always creates a view. + // A launch message (`viewLaunched`) proves a webview exists even when the provider's + // view reference was cleared, so it keeps the barrier too. + if (newTab || provider.hasResolvedView || provider.viewLaunched) { + // Reaching this block means a webview exists or is being created, so the bound is + // a real failure condition: reject task creation instead of falling back to the + // shared state, which would start the task against defaults the view never loaded. + let readinessTimer: ReturnType | undefined + await Promise.race([ + provider.viewStateReadiness, + new Promise((_, reject) => { + readinessTimer = setTimeout( + () => reject(new Error("Timed out waiting for the view state to become ready")), + 3000, + ) + }), + ]).finally(() => { + clearTimeout(readinessTimer) + }) + } - // API-driven tasks never stop to ask the user, so the hard stop is - // effectively unlimited. The soft repetition tier is disabled too: - // without a reachable hard stop it would reject every identical call - // with no escalation. - const options: CreateTaskOptions = { - consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, - toolRepetitionSoftLimit: 0, - } + // Disposal resolves viewStateReadiness to release waiters: a provider torn down while + // the wait was pending must fail task creation rather than create a task against the + // disposed view (its posts and task registration would be orphaned). + if (provider.isDisposed) { + throw new Error("The provider was disposed while waiting for the view state to become ready") + } + await provider.evictCurrentTask() + await provider.postStateToWebview() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await provider.postMessageToWebview({ type: "invoke", invoke: "newChat", text, images }) + + // API-driven tasks never stop to ask the user, so the hard stop is + // effectively unlimited. The soft repetition tier is disabled too: + // without a reachable hard stop it would reject every identical call + // with no escalation. + const options: CreateTaskOptions = { + consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER, + toolRepetitionSoftLimit: 0, + } - const task = await provider.createTask(text, images, undefined, options, configuration) + const task = await provider.createTask(text, images, undefined, options, configuration) - if (!task) { - throw new Error("Failed to create task due to policy restrictions") - } + if (!task) { + throw new Error("Failed to create task due to policy restrictions") + } - return task.taskId + return task.taskId + } catch (error) { + if (createdInNewTab && !provider.isDisposed) { + // Only the provider this call created is ours to tear down: the sidebar provider + // and any task attached to it are left untouched. Disposal also untracks the + // instance, so an aborted open-in-editor call cannot leave a ghost tab behind. + try { + await provider.dispose() + } catch (disposeError) { + this.log( + `[API#startNewTask] failed to dispose the aborted new-tab provider: ${ + disposeError instanceof Error ? disposeError.message : String(disposeError) + }`, + ) + } + } + throw error + } } public async resumeTask(taskId: string): Promise { @@ -382,6 +476,99 @@ export class API extends EventEmitter implements RooCodeAPI { this.sidebarProvider.getCurrentTask()?.approveAsk() } + /** + * The test-only surface (task ask control by ID and raw global-state reads) must not + * be reachable from production callers: VS Code only reports ExtensionMode.Production on the extension context for installed extensions, so test hosts and the e2e + * harness keep access. + */ + private assertTestOnlyApi(method: string): void { + if (this.context.extensionMode === vscode.ExtensionMode.Production) { + throw new Error(`${method} is a test-only API and is unavailable in production mode`) + } + } + + /** + * Approves the pending ask for a specific task by its ID. + * + * @returns Whether a registered task with the given ID was found and approved. + */ + public async approveTaskAsk(taskId: string): Promise { + this.assertTestOnlyApi("approveTaskAsk") + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + entry.task.approveAsk() + return true + } + + /** + * Denies the pending ask for a specific task by its ID. + * + * @returns Whether a registered task with the given ID was found and denied. + */ + public async denyTaskAsk(taskId: string): Promise { + this.assertTestOnlyApi("denyTaskAsk") + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + entry.task.denyAsk() + return true + } + + /** + * Answers a task's pending ask with a follow-up suggestion, optionally switching that + * task's provider to the suggestion's mode before responding. + * + * @returns Whether a registered task with the given ID was found and answered. + */ + public async selectTaskFollowupSuggestion({ + taskId, + answer, + mode, + }: { + taskId: string + answer: string + mode?: string + }): Promise { + this.assertTestOnlyApi("selectTaskFollowupSuggestion") + const entry = this.tasksById.get(taskId) + + if (!entry) { + return false + } + + if (mode) { + try { + const { customModes } = await entry.provider.getState() + const isValidMode = getAllModes(customModes).some((modeConfig) => modeConfig.slug === mode) + + if (isValidMode) { + // entry.task is the registered Task instance (TaskAskController narrows it + // to the ask-response surface); pass it explicitly so the switch is scoped to + // this task rather than the provider's currently focused task. + await entry.provider.handleModeSwitch(mode, entry.task as Task) + } else { + this.log(`[API#selectTaskFollowupSuggestion] ignoring unknown mode "${mode}" for task ${taskId}`) + } + } catch (error) { + // A failed mode switch must not swallow the follow-up answer: the task's + // pending ask would otherwise stay unanswered. + this.log( + `[API#selectTaskFollowupSuggestion] mode switch failed for task ${taskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + entry.task.handleWebviewAskResponse("messageResponse", answer) + return true + } + public isReady() { return this.sidebarProvider.viewLaunched } @@ -408,7 +595,29 @@ export class API extends EventEmitter implements RooCodeAPI { } } + /** + * Removes a task's registration only if the registered entry still belongs to this + * task instance: a replaced instance reusing the same taskId must not be dropped by + * the previous instance's teardown events. + */ + private removeRegisteredTask(task: TaskLike): void { + const entry = this.tasksById.get(task.taskId) + + // The stored controller is this exact instance (registered above with a cast to + // the ask-response surface), so reference equality is the right identity check. + if (entry?.task === (task as unknown as TaskAskController)) { + this.tasksById.delete(task.taskId) + } + } + private registerListeners(provider: ClineProvider) { + // Idempotent per provider: repeated or concurrent startNewTask calls against a + // reused tab provider must not stack duplicate listeners (duplicate API events, + // duplicate file logging, and duplicate per-task listener sets). + if (this.registeredProviders.has(provider)) { + return + } + provider.on(RooCodeEventName.TaskCompleted, async (taskId, tokenUsage, toolUsage) => { const historyItem = provider.taskHistoryStore.get(taskId) this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage, { @@ -421,6 +630,8 @@ export class API extends EventEmitter implements RooCodeAPI { }) provider.on(RooCodeEventName.TaskCreated, (task) => { + this.tasksById.set(task.taskId, { task: task as unknown as TaskAskController, provider }) + // Task Lifecycle task.on(RooCodeEventName.TaskStarted, async () => { @@ -428,8 +639,16 @@ export class API extends EventEmitter implements RooCodeAPI { await this.fileLog(`[${new Date().toISOString()}] taskStarted -> ${task.taskId}\n`) }) + // #1452 moved the TaskCompleted emit and file-log to the provider-level + // listener above. This per-task listener remains only for the reference-safe + // registry removal: the provider-level handler receives the taskId but not the + // task instance, so it cannot verify the entry still belongs to this instance. + task.on(RooCodeEventName.TaskCompleted, () => { + this.removeRegisteredTask(task) + }) task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) + this.removeRegisteredTask(task) }) task.on(RooCodeEventName.TaskFocused, () => { @@ -438,6 +657,7 @@ export class API extends EventEmitter implements RooCodeAPI { task.on(RooCodeEventName.TaskUnfocused, () => { this.emit(RooCodeEventName.TaskUnfocused, task.taskId) + this.removeRegisteredTask(task) }) task.on(RooCodeEventName.TaskActive, () => { @@ -529,6 +749,7 @@ export class API extends EventEmitter implements RooCodeAPI { provider.on(RooCodeEventName.TaskDelegationResumed, (parentTaskId, childTaskId) => { ;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) }) + this.registeredProviders.add(provider) } // Logging @@ -579,24 +800,277 @@ export class API extends EventEmitter implements RooCodeAPI { // Global Settings Management public getConfiguration(): RooCodeSettings { - return Object.fromEntries( - Object.entries(this.sidebarProvider.getValues()).filter(([key]) => !isSecretStateKey(key)), + // getValues() merges view-local state, whose apiConfiguration is a nested object that + // can carry provider secrets (e.g. apiKey). Flatten the provider settings onto the top + // level (the pre-existing flat shape) so the secret filter removes them before return. + const values = this.sidebarProvider.getValues() + const { apiConfiguration, ...rest } = values + // A view-local profile is a complete snapshot, not a patch over the shared + // store - getState() resolves it the same way. Drop the shared flat + // provider-settings keys before flattening, or a key the view's profile + // never defined (e.g. another profile's apiModelId or base URL) leaks in + // from whichever profile owns the shared store. The shared apiProvider is + // the one exception: getState() falls back to it when a pinned profile + // carries no provider of its own, so keep that single key as a base the + // profile's own apiProvider (when present) still overrides. + const shared = apiConfiguration + ? Object.fromEntries( + Object.entries(rest).filter( + ([key]) => key === "apiProvider" || !PROVIDER_SETTINGS_KEYS.some((k) => k === key), + ), + ) + : rest + // Secrets never leave the flattened surface: SECRET_STATE_KEYS covers the + // *ApiKey shapes, and SENSITIVE_PROVIDER_CONFIG_KEYS covers credential + // values that use other name shapes (header maps, session tokens, + // service-account JSON, credential-file paths). + // Retired providers keep their legacy fields through passthrough, and those + // names (e.g. groqApiKey, huggingFaceSecurityToken) predate the explicit + // secret lists and appear in neither. Treat credential-shaped key names as + // secret on this flattened surface so a retired profile cannot expose them. + const legacyCredentialKeyRe = /(?:ApiKey|ApiToken|SecurityToken|AccessToken|SessionToken|Credentials|Secret)$/ + const configuration = Object.fromEntries( + Object.entries({ ...shared, ...apiConfiguration }).filter( + ([key]) => + !isSecretStateKey(key) && !isSensitiveProviderConfigKey(key) && !legacyCredentialKeyRe.test(key), + ), ) + // An explicit apiProvider: undefined inside the nested profile clobbers the + // retained shared fallback through the spread above; restore it the same way + // getState() does with ?? so the provider never disappears from the result. + if (configuration.apiProvider === undefined && shared.apiProvider !== undefined) { + configuration.apiProvider = shared.apiProvider + } + return configuration + } + + // Serializes setConfiguration calls; see the method comment. + private configurationWriteQueue: Promise = Promise.resolve() + + public async setConfiguration(values: RooCodeSettings): Promise { + // Overlapping calls snapshot and compensate against each other's writes: + // the whole snapshot/write/compensate group runs through a per-instance queue + // so each call sees the settled result of the previous one. + const run = this.configurationWriteQueue.then(() => this.applyConfiguration(values)) + // The queue must survive failures: chain the settled-only view of this run. + this.configurationWriteQueue = run.catch(() => {}) + return run } - public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) - await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) + private async applyConfiguration(values: RooCodeSettings): Promise { + // The writes below are sequential, not transactional. Snapshot what each write + // overwrites so a later failure can compensate the writes that already landed; + // the original error is what callers see. + const previousValues = Object.fromEntries( + Object.keys(values).map((key) => [key, this.sidebarProvider.getValue(key as keyof RooCodeSettings)]), + ) as RooCodeSettings + const profileName = values.currentApiConfigName || "default" + let previousProfile: ProviderSettings | undefined + let hadPreviousProfile = false + let profileLookupInconclusive = false + try { + const { name: _name, ...profile } = await this.sidebarProvider.providerSettingsManager.getProfile({ + name: profileName, + }) + previousProfile = profile as ProviderSettings + hadPreviousProfile = true + } catch { + // A missing profile is the normal first-save case: compensation then + // removes the profile this call creates. A transient read failure must not + // be mistaken for absence or compensation would delete a profile that + // exists: confirm with hasConfig, and when even that fails, leave the + // stored record alone during compensation. + try { + hadPreviousProfile = await this.sidebarProvider.providerSettingsManager.hasConfig(profileName) + } catch { + profileLookupInconclusive = true + } + } + const previousModeConfigs: Array<[string, string | undefined]> = values.modeApiConfigs + ? await Promise.all( + Object.keys(values.modeApiConfigs).map( + async (mode) => + [ + mode, + await this.sidebarProvider.providerSettingsManager.getModeConfigId(mode as Mode), + ] as [string, string | undefined], + ), + ) + : [] + + try { + await this.sidebarProvider.setValues(values) + } catch (error) { + // ContextProxy.setValues persists keys one at a time: a rejection can + // leave a durable subset of `values` behind, so undo every field whose + // write may have landed before surfacing the failure. + const restoreFailures: string[] = [] + await this.restoreConfigurationValues(previousValues, restoreFailures) + this.failWithCompensation(error, restoreFailures) + } + + try { + // saveConfig replaces the stored record instead of merging: land a partial + // values object on top of the existing profile so provider settings the + // caller omitted keep their stored values instead of being dropped. + await this.sidebarProvider.providerSettingsManager.saveConfig( + profileName, + previousProfile ? { ...previousProfile, ...values } : values, + ) + } catch (error) { + // Only the shared write landed: undo it before surfacing the failure. + const restoreFailures: string[] = [] + await this.restoreConfigurationValues(previousValues, restoreFailures) + this.failWithCompensation(error, restoreFailures) + } + if (values.modeApiConfigs) { - await Promise.all( + // Await every mode write before compensating: with Promise.all the + // first rejection would start the restore while a sibling write was + // still in flight, and that write could land after its own restore. + const modeWriteResults = await Promise.allSettled( Object.entries(values.modeApiConfigs).map(([mode, configId]) => this.sidebarProvider.providerSettingsManager.setModeConfig(mode as Mode, configId), ), ) + const failedModeWrite = modeWriteResults.find((result) => result.status === "rejected") + if (failedModeWrite) { + const error = (failedModeWrite as PromiseRejectedResult).reason + // Undo every write that landed, each restore independently so one failed + // restore cannot strand the others: mode mappings, then the profile + // save, then the shared values. A failed restore is durable state the + // caller must learn about, so every one is collected and reported on the + // error alongside the original write failure. + const restoreFailures: string[] = [] + const recordRestoreFailure = (what: string, restoreError: unknown) => { + const message = restoreError instanceof Error ? restoreError.message : String(restoreError) + this.log(`[API#setConfiguration] failed to ${what}: ${message}`) + restoreFailures.push(`${what}: ${message}`) + } + for (const [mode, configId] of previousModeConfigs) { + try { + // A mode with no prior mapping is restored to an undefined id, + // which getModeConfigId reports as absent just like a missing key. + await this.sidebarProvider.providerSettingsManager.setModeConfig( + mode as Mode, + configId as string, + ) + } catch (restoreError) { + recordRestoreFailure(`restore the mode mapping for ${mode}`, restoreError) + } + } + try { + if (hadPreviousProfile && previousProfile) { + await this.sidebarProvider.providerSettingsManager.saveConfig(profileName, previousProfile) + } else if (!hadPreviousProfile && !profileLookupInconclusive) { + await this.sidebarProvider.providerSettingsManager.deleteConfig(profileName) + } else { + // The profile exists but its read failed: there is no snapshot to + // restore and deleting would destroy it - leave it as-is. + this.log( + `[API#setConfiguration] profile lookup for "${profileName}" was inconclusive; leaving the stored record untouched during compensation.`, + ) + } + } catch (restoreError) { + recordRestoreFailure(`restore the profile "${profileName}"`, restoreError) + } + await this.restoreConfigurationValues(previousValues, restoreFailures) + this.failWithCompensation(error, restoreFailures) + } } + await this.sidebarProvider.postStateToWebview() } + private async restoreConfigurationValues(previous: RooCodeSettings, restoreFailures: string[]): Promise { + try { + await this.sidebarProvider.setValues(previous) + } catch (restoreError) { + const message = restoreError instanceof Error ? restoreError.message : String(restoreError) + this.log(`[API#setConfiguration] failed to restore shared settings: ${message}`) + restoreFailures.push(`restore the shared settings: ${message}`) + } + } + + /** + * Surfaces a configuration write failure together with any compensation step that also + * failed: the caller must know the durable state is partially applied, so the original + * write error becomes the cause and the failed restores are named on the error. + */ + private failWithCompensation(error: unknown, restoreFailures: string[]): never { + if (restoreFailures.length === 0) { + throw error + } + const compensationError = new Error( + `Configuration write failed and ${restoreFailures.length} compensation step(s) also failed: ${restoreFailures.join("; ")}`, + { cause: error }, + ) as Error & { restoreFailures?: string[] } + compensationError.restoreFailures = restoreFailures + throw compensationError + } + + public getGlobalState(key: K): GlobalState[K] { + this.assertTestOnlyApi("getGlobalState") + // The generic only constrains typed callers; a crafted runtime key can be + // any string. Refuse keys outside the settings schema before storage sees + // them, so the test-only API cannot read arbitrary global state. + if (!isGlobalStateKey(key)) { + throw new Error(`getGlobalState received a key outside the settings schema: ${String(key)}`) + } + // The global-state union still contains credential-bearing keys (API keys, + // headers, OAuth paths) that are not part of the secret-state union; refuse + // them before storage access so the test-only API cannot read credentials. + if (isSecretStateKey(String(key)) || isSensitiveProviderConfigKey(String(key))) { + throw new Error(`getGlobalState received a credential-bearing key: ${String(key)}`) + } + // Task history carries user PII (task texts): out of bounds for this surface + // even though it is a plain GlobalState key. + if (key === "taskHistory") { + throw new Error(`getGlobalState received a PII-bearing key: ${String(key)}`) + } + return this.context.globalState.get(key) + } + + public async setGlobalState(key: K, value: GlobalState[K]): Promise { + this.assertTestOnlyApi("setGlobalState") + // Same runtime boundary as getGlobalState: an unvalidated key must not be + // persisted into global state. + if (!isGlobalStateKey(key)) { + throw new Error(`setGlobalState received a key outside the settings schema: ${String(key)}`) + } + // Same credential boundary as getGlobalState: a crafted runtime key must not + // be able to persist secrets through the test-only surface. + if (isSecretStateKey(String(key)) || isSensitiveProviderConfigKey(String(key))) { + throw new Error(`setGlobalState received a credential-bearing key: ${String(key)}`) + } + // Task history carries user PII (task texts): out of bounds for this surface + // even though it is a plain GlobalState key. + if (key === "taskHistory") { + throw new Error(`setGlobalState received a PII-bearing key: ${String(key)}`) + } + // The value is runtime data: parse it against the key's schema so malformed + // state never reaches durable storage. Keys without a global-settings schema + // entry (provider settings) are not seedable through this surface. + const valueSchema = ( + globalSettingsSchema.shape as Record< + string, + { safeParse: (value: unknown) => { success: boolean; data: unknown } } | undefined + > + )[String(key)] + if (!valueSchema) { + throw new Error(`setGlobalState received a key without a value schema: ${String(key)}`) + } + const parsedValue = valueSchema.safeParse(value) + if (!parsedValue.success) { + throw new Error(`setGlobalState received an invalid value for key: ${String(key)}`) + } + // Persist the parsed value: the object schemas in the settings map are + // non-strict, so the raw payload can carry extra credential- or PII-shaped + // fields that the schema strips; storing parsed data keeps unknown fields + // out of durable global state. + await this.context.globalState.update(key, parsedValue.data as GlobalState[K]) + } + public setTerminalProfile(name: string | undefined): void { const previousProfile = Terminal.getTerminalProfile() Terminal.setTerminalProfile(name) diff --git a/src/package.json b/src/package.json index f7cffe01a7..4cc028dc53 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..0b089c149b 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("applies mode and profile from a later state payload after local edits", () => { + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "code", currentApiConfigName: "profile-a", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("code") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-a") + + act(() => { + screen.getByTestId("set-local-mode").click() + screen.getByTestId("set-local-api-config").click() + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("ask") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("local-profile") + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "architect", currentApiConfigName: "profile-b", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("architect") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-b") + }) + it("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..d030e04951 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,384 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +// Minimal Storage surface for VSCodeAPIWrapper browser fallback tests. Typed +// precisely (instead of casting to Storage) so each double only promises the +// members the wrapper actually touches. +interface MockStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + clear(): void +} + +const createMockStorage = (initialState: Record = {}): MockStorage => { + const state = { ...initialState } + return { + getItem: vi.fn((key: string) => state[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + state[key] = value + }), + removeItem: vi.fn((key: string) => { + delete state[key] + }), + clear: vi.fn(() => { + for (const key of Object.keys(state)) { + delete state[key] + } + }), + } +} + +describe("VSCodeAPIWrapper", () => { + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }) + }) + + it("reuses the persisted webview viewStateId when browser storage is available", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "persisted-view" }) }), + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("persisted-view") + }) + + it("replaces a stored Object.prototype member id with a generated one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "constructor" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // The host's setViewStateId rejects every Object.prototype member name; accepting + // "constructor" here would make every launch message rejected and strand the + // pre-launch entry under the temporary id forever. + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("creates and persists a new viewStateId when storage has been cleared", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("falls back to in-memory state when browser storage access is restricted", () => { + const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const storage: MockStorage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + removeItem: vi.fn(() => { + throw new Error("storage denied") + }), + clear: vi.fn(() => { + throw new Error("storage denied") + }), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(randomUUID).toHaveBeenCalledTimes(1) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.setItem).toHaveBeenCalled() + }) + + it("falls back to a timestamp-random id when crypto.randomUUID is unavailable", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: {}, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto global is undefined", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: undefined, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto object lacks randomUUID", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { "": 1 }, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // A truthy crypto global without a randomUUID member must still take the + // deterministic fallback: 1700000000000.toString(36) === "loyw3v28" and + // (0.987654321).toString(36) === "0.zk00000ytu", so the id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("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..f64a774e58 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,49 @@ 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 validation so the webview never + // registers an id the extension would reject: trim, replace unsafe + // characters, and drop whitespace-only values plus every Object.prototype + // member name. "__proto__" alone is not enough: ids like "constructor" or + // "toString" survive the host's normalization and would be refused there + // forever, stranding this view's pre-launch entry under the temporary id. + const normalizedViewStateId = + typeof existingViewStateId === "string" ? existingViewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") : "" + + if (normalizedViewStateId && !Object.prototype.hasOwnProperty.call(Object.prototype, normalizedViewStateId)) { + return normalizedViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,10 +97,24 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { - const state = localStorage.getItem("vscodeState") - return state ? JSON.parse(state) : undefined } + + if (this.storageWriteFailed) { + // A previous write could not be persisted: reading localStorage would + // return stale JSON, so the in-memory fallback is authoritative. + return this.fallbackState + } + + try { + if (typeof localStorage?.getItem === "function") { + const state = localStorage.getItem("vscodeState") + return state ? JSON.parse(state) : this.fallbackState + } + } catch { + return this.fallbackState + } + + return this.fallbackState } /** @@ -69,10 +131,24 @@ class VSCodeAPIWrapper { public setState(newState: T): T { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) - return newState } + + this.fallbackState = newState + + try { + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + this.storageWriteFailed = false + } else { + this.storageWriteFailed = true + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + this.storageWriteFailed = true + } + + return newState } }