From bf30669286cbaf586531e569046f664387a4563d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 5 Sep 2026 02:18:07 +0800 Subject: [PATCH 01/65] fix(activate): target title-bar commands to their click-origin instance --- packages/types/src/vscode.ts | 8 + .../__tests__/registerCommands.spec.ts | 299 +++++++++++++++--- src/activate/registerCommands.ts | 126 ++++++-- src/core/webview/ClineProvider.ts | 10 + .../webview/__tests__/ClineProvider.spec.ts | 13 + src/package.json | 28 +- 6 files changed, 411 insertions(+), 73 deletions(-) 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 7088560700..1e1ee6a5a7 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1,5 +1,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" + import { ClineProvider } from "../../core/webview/ClineProvider" import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" @@ -192,44 +194,104 @@ describe("registerCommands handlers", () => { expect(mockContext.subscriptions).toContain(disposable) }) - 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) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + 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)("$command 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)("$command is a no-op when the tab instance is disposed", async (command) => { + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await handlers[command]() + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) it("acceptInput posts acceptInput message", () => { @@ -302,44 +364,138 @@ 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() }) - // Representative coverage for the .catch arm on all five void-prefixed - // postMessageToWebview sites in registerCommands.ts (settingsButtonClicked - // posts twice, plus historyButtonClicked, marketplaceButtonClicked, and - // acceptInput). Each handler is synchronous, so the .catch arm runs on a - // microtask; setImmediate ensures all microtasks are flushed before we assert. The + it("focusInput does not post when a tab panel is tracked alongside the sidebar", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + await handlers["zoo-code.focusInput"]() + + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("setPanel keeps independent refs: clearing only the tab ref re-enables the sidebar post", async () => { + setPanel({} as vscode.WebviewView, "sidebar") + setPanel({} as vscode.WebviewPanel, "tab") + + // The tab ref does not wipe the sidebar ref... + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + + // ...and clearing only the tab ref re-enables the sidebar post. + setPanel(undefined, "tab") + await handlers["zoo-code.focusInput"]() + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) + }) + + // Coverage for the .catch arm on the sidebar title-bar post sites + // (settingsButtonClicked posts twice, plus historyButtonClicked and + // marketplaceButtonClicked) and acceptInput (the visible-provider path). + // Each handler is synchronous, so the .catch arm runs on a microtask; + // setImmediate ensures all microtasks are flushed before we assert. The // log messages carry a `[]` prefix so multi-failure logs // remain unambiguous; the prefix is per-handler, not per-call (both of - // settingsButtonClicked's posts share the same prefix). + // settingsButtonClicked's posts share the same prefix). Each post rejects + // with its own error and call N is pinned to post N, so a mutant that + // alters one catch's message cannot hide behind the other post's + // identical log. it.each([ - { command: "zoo-code.settingsButtonClicked", prefix: "settingsButtonClicked", expectedCalls: 2 }, - { command: "zoo-code.historyButtonClicked", prefix: "historyButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.marketplaceButtonClicked", prefix: "marketplaceButtonClicked", expectedCalls: 1 }, - { command: "zoo-code.acceptInput", prefix: "acceptInput", expectedCalls: 1 }, + { + command: "zoo-code.settingsButtonClicked", + prefix: "settingsButtonClicked", + errorLabels: ["first post", "second post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.historyButtonClicked", + prefix: "historyButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { + command: "zoo-code.marketplaceButtonClicked", + prefix: "marketplaceButtonClicked", + errorLabels: ["post"], + target: "sidebar" as const, + }, + { command: "zoo-code.acceptInput", prefix: "acceptInput", errorLabels: ["post"], target: "visible" as const }, ])( "$command logs to outputChannel when postMessageToWebview rejects", - async ({ command, prefix, expectedCalls }) => { - const boom = new Error("boom") - mockVisibleProvider.postMessageToWebview.mockReset() - mockVisibleProvider.postMessageToWebview.mockRejectedValue(boom) + async ({ command, prefix, errorLabels, target }) => { + const post = + target === "sidebar" ? mockProvider.postMessageToWebview : mockVisibleProvider.postMessageToWebview + post.mockReset() + const booms = errorLabels.map((label) => new Error(label)) + booms.forEach((boom) => post.mockRejectedValueOnce(boom)) handlers[command]() - // Flush microtasks so the chained .catch arm runs. + // Flush microtasks so the chained .catch arms run. await new Promise((resolve) => setImmediate(resolve)) - expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(expectedCalls) - expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( - `[${prefix}] postMessageToWebview failed: ${boom}`, - ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(booms.length) + booms.forEach((boom, index) => { + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + index + 1, + `[${prefix}] postMessageToWebview failed: ${boom}`, + ) + }) }, ) + // The two posts reject with distinct errors and the nth-call assertions + // pin each catch's message, so neither template literal can survive + // behind the other post's identical log. + it("settingsButtonClickedInTab logs to outputChannel when postMessageToWebview rejects", async () => { + const booms = [new Error("first post"), new Error("second post")] + const mockTabProvider = { + postMessageToWebview: vi.fn().mockRejectedValueOnce(booms[0]).mockRejectedValueOnce(booms[1]), + } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers["zoo-code.settingsButtonClickedInTab"]() + + // Flush microtasks so the chained .catch arms run. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 1, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[0]}`, + ) + expect(mockOutputChannel.appendLine).toHaveBeenNthCalledWith( + 2, + `[settingsButtonClickedInTab] postMessageToWebview failed: ${booms[1]}`, + ) + }) + + // The history and marketplace InTab catch sites share the identical + // single-post pattern (their sidebar equivalents are covered by the + // it.each above); pin their exact messages too. + it.each([ + { command: "zoo-code.historyButtonClickedInTab", prefix: "historyButtonClickedInTab" }, + { command: "zoo-code.marketplaceButtonClickedInTab", prefix: "marketplaceButtonClickedInTab" }, + ])("$command logs to outputChannel when the tab postMessageToWebview rejects", async ({ command, prefix }) => { + const boom = new Error("post") + const mockTabProvider = { postMessageToWebview: vi.fn().mockRejectedValue(boom) } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + handlers[command]() + + // Flush microtasks so the chained .catch arm runs. + await new Promise((resolve) => setImmediate(resolve)) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledTimes(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(`[${prefix}] postMessageToWebview failed: ${boom}`) + }) + it("toggleAutoApprove logs to outputChannel when postMessageToWebview rejects", async () => { // toggleAutoApprove is `async` and awaits postMessageToWebview inside a // try/catch (rather than relying on a `.catch` microtask like the @@ -357,22 +513,40 @@ describe("registerCommands handlers", () => { ) }) - it("plusButtonClicked calls evictCurrentTask on the visible provider", async () => { + it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { const evictCurrentTask = vi.fn().mockResolvedValue(undefined) const refreshWorkspace = vi.fn().mockResolvedValue(undefined) - ;(mockVisibleProvider as any).evictCurrentTask = evictCurrentTask - ;(mockVisibleProvider as any).refreshWorkspace = refreshWorkspace + ;(mockProvider as any).evictCurrentTask = evictCurrentTask + ;(mockProvider as any).refreshWorkspace = refreshWorkspace await handlers["zoo-code.plusButtonClicked"]() + expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") expect(evictCurrentTask).toHaveBeenCalledTimes(1) + expect(refreshWorkspace).toHaveBeenCalledTimes(1) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "chatButtonClicked" }) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) - it("plusButtonClicked is a no-op when no visible provider", async () => { - ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(undefined) + it("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), + } + setPanel({} as vscode.WebviewPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) - // Should not throw even with no visible provider - await handlers["zoo-code.plusButtonClicked"]() + await handlers["zoo-code.plusButtonClickedInTab"]() + + 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" }) }) }) @@ -414,6 +588,9 @@ describe("openClineInNewTab", () => { it("creates a webview panel with title 'Zoo Code'", async () => { await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + // 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", @@ -424,4 +601,42 @@ describe("openClineInNewTab", () => { }), ) }) + + it("reveals the existing tab instead of creating a second panel", async () => { + const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockPanel = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WebviewPanel + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockExistingProvider) + + const result = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + 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 = { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + reveal: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.WebviewPanel + setPanel(mockPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + expect(mockPanel.reveal).not.toHaveBeenCalled() + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index da98be291b..1418fd4938 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -40,7 +40,12 @@ export function getPanel(): vscode.WebviewPanel | vscode.WebviewView | undefined } /** - * Set panel references + * Set panel references. + * + * The two refs are independent: each surface keeps its own ref for its whole + * lifetime, so resolving the sidebar view never wipes a live tab panel (and + * vice versa). Callers pass `undefined` only when the surface itself is + * disposed (see the `onDidDispose` wiring in `openClineInNewTab`). */ export function setPanel( newPanel: vscode.WebviewPanel | vscode.WebviewView | undefined, @@ -48,13 +53,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 @@ -90,21 +104,35 @@ const getCommandsMap = ({ 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,44 +141,74 @@ const getCommandsMap = ({ }, openInNewTab: () => openClineInNewTab({ context, outputChannel }), settingsButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("settings") - if (!visibleProvider) { + void provider + .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) + .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + // Also explicitly post the visibility message to trigger scroll reliably + void provider + .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + }, + settingsButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("settings") - void visibleProvider + void tabProvider .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 + .catch((error) => + outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) + void tabProvider .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + .catch((error) => + outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) }, historyButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) + TelemetryService.instance.captureTitleButtonClicked("history") - if (!visibleProvider) { + void provider + .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) + .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + }, + historyButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { return } TelemetryService.instance.captureTitleButtonClicked("history") - void visibleProvider + void tabProvider .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + .catch((error) => + outputChannel.appendLine(`[historyButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) }, marketplaceButtonClicked: () => { - const visibleProvider = getVisibleProviderOrLog(outputChannel) - if (!visibleProvider) return - void visibleProvider + void provider .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) .catch((error) => outputChannel.appendLine(`[marketplaceButtonClicked] postMessageToWebview failed: ${error}`), ) }, + marketplaceButtonClickedInTab: () => { + const tabProvider = getTabProvider() + if (!tabProvider) { + return + } + void tabProvider + .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) + .catch((error) => + outputChannel.appendLine(`[marketplaceButtonClickedInTab] postMessageToWebview failed: ${error}`), + ) + }, newTask: handleNewTask, setCustomStoragePath: async () => { const { promptForCustomStoragePath } = await import("../utils/storage") @@ -176,8 +234,11 @@ const getCommandsMap = ({ try { await focusPanel(tabPanel, sidebarPanel) - // Send focus input message only for sidebar panels - if (sidebarPanel && getPanel() === sidebarPanel) { + // Send focus input message only when the sidebar panel was + // focused: the tab takes selection priority in focusPanel, so + // the sidebar receives the message only when no tab panel is + // tracked. + if (sidebarPanel && !tabPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -221,6 +282,17 @@ const getCommandsMap = ({ }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { + // 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). diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 874e2f8f08..7969b1dfd9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -910,6 +910,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() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b5f304fe56..bcbc1c56e2 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -583,6 +583,19 @@ describe("ClineProvider", () => { expect(ClineProvider.getVisibleInstance()).toBe(provider) }) + describe("getInstanceForView", () => { + it("returns the instance that owns the given view", () => { + // @ts-ignore - accessing private property for testing + provider.view = 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("loads full model details when preparing an LM Studio task", async () => { await provider.performPreparationTasks({ apiConfiguration: { diff --git a/src/package.json b/src/package.json index 0931ca779d..cb2e971a1d 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" }, From 854af59849cec21a7901dc170db15b983a55a53c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 15:46:00 +0800 Subject: [PATCH 02/65] fix(activate): serialize overlapping openClineInNewTab calls and harden view-identity tests Track the in-flight tab panel creation with a module-level promise so concurrent openClineInNewTab calls reuse one panel and provider (adds a Promise.all regression test). ClineProvider.spec sets the private view via the public resolveWebviewView() instead of a ts-ignore assignment. registerCommands.spec types evictCurrentTask/refreshWorkspace on the fixture and drops the as any attachment. eslint-suppressions: prune the registerCommands.spec.ts entry (two as any suppressions removed). --- .../__tests__/registerCommands.spec.ts | 30 +++- src/activate/registerCommands.ts | 170 ++++++++++-------- .../webview/__tests__/ClineProvider.spec.ts | 5 +- src/eslint-suppressions.json | 5 - 4 files changed, 123 insertions(+), 87 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 1e1ee6a5a7..bd570ab8f4 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -136,7 +136,11 @@ describe("registerCommands handlers", () => { let mockOutputChannel: vscode.OutputChannel let mockContext: vscode.ExtensionContext let mockVisibleProvider: { postMessageToWebview: Mock } - let mockProvider: { postMessageToWebview: Mock } + let mockProvider: { + postMessageToWebview: Mock + evictCurrentTask: Mock + refreshWorkspace: Mock + } let handlers: Record unknown> beforeEach(() => { @@ -164,6 +168,8 @@ describe("registerCommands handlers", () => { mockProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), } ;(ClineProvider.getVisibleInstance as Mock).mockReturnValue(mockVisibleProvider) ;(vscode.commands.registerCommand as Mock).mockImplementation( @@ -514,16 +520,11 @@ describe("registerCommands handlers", () => { }) it("plusButtonClicked calls evictCurrentTask on the registered sidebar provider", async () => { - const evictCurrentTask = vi.fn().mockResolvedValue(undefined) - const refreshWorkspace = vi.fn().mockResolvedValue(undefined) - ;(mockProvider as any).evictCurrentTask = evictCurrentTask - ;(mockProvider as any).refreshWorkspace = refreshWorkspace - await handlers["zoo-code.plusButtonClicked"]() expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") - expect(evictCurrentTask).toHaveBeenCalledTimes(1) - expect(refreshWorkspace).toHaveBeenCalledTimes(1) + 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" }) }) @@ -639,4 +640,17 @@ describe("openClineInNewTab", () => { expect(mockPanel.reveal).not.toHaveBeenCalled() expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) + + 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 }), + openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }), + ]) + + // Overlapping "Open in editor" calls must share the in-flight + // creation: exactly one tab panel is created and both callers + // receive the same provider. + expect(first).toBe(second) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 1418fd4938..8338d4b149 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -31,6 +31,12 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl let sidebarPanel: vscode.WebviewView | undefined = undefined let tabPanel: vscode.WebviewPanel | undefined = undefined +// In-flight "open in editor" creation shared by overlapping calls: a +// double-click starts before the first call tracks its new panel, so +// concurrent callers must share one creation instead of racing to create +// two tab panels. +let pendingTabPanelCreation: Promise | undefined + /** * Get the currently active panel * @returns WebviewPanel或WebviewView @@ -282,87 +288,109 @@ const getCommandsMap = ({ }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { - // 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 - } + // 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. + // Concurrent callers await the same promise: exactly one panel is + // created and every caller receives the same provider. + if (pendingTabPanelCreation) { + return pendingTabPanelCreation } - // (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). - // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts - const contextProxy = await ContextProxy.getInstance(context) + const creation = (async () => { + // 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 + } + } - // Get the existing MDM service instance to ensure consistent policy enforcement - let mdmService: MdmService | undefined - try { - mdmService = MdmService.getInstance() - } catch (error) { - // MDM service not initialized, which is fine - extension can work without it - mdmService = undefined - } + // (This example uses webviewProvider activation event which is necessary to + // deserialize cached webview, but since we use retainContextWhenHidden, we + // don't need to use that event). + // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts + const contextProxy = await ContextProxy.getInstance(context) + const codeIndexManager = CodeIndexManager.getInstance(context) - const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) + // Get the existing MDM service instance to ensure consistent policy enforcement + let mdmService: MdmService | undefined + try { + mdmService = MdmService.getInstance() + } catch (error) { + // MDM service not initialized, which is fine - extension can work without it + mdmService = undefined + } - // Check if there are any visible text editors, otherwise open a new group - // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) + const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } + // Check if there are any visible text editors, otherwise open a new group + // to the right. + const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + if (!hasVisibleEditors) { + await vscode.commands.executeCommand("workbench.action.newGroupRight") + } - const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - // Save as tab type panel. - setPanel(newPanel, "tab") + const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }) - // TODO: Use better svg icon with light and dark variants (see - // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). - newPanel.iconPath = { - light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), - dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), - } + // Save as tab type panel. + setPanel(newPanel, "tab") + + // TODO: Use better svg icon with light and dark variants (see + // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). + newPanel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), + } - await tabProvider.resolveWebviewView(newPanel) + await tabProvider.resolveWebviewView(newPanel) - // Add listener for visibility changes to notify webview - newPanel.onDidChangeViewState( - (e) => { - const panel = e.webviewPanel - if (panel.visible) { - panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx - } - }, - null, // First null is for `thisArgs` - context.subscriptions, // Register listener for disposal - ) - - // Handle panel closing events. - newPanel.onDidDispose( - () => { - setPanel(undefined, "tab") - }, - null, - context.subscriptions, // Also register dispose listener - ) - - // Lock the editor group so clicking on files doesn't open them over the panel. - await delay(100) - await vscode.commands.executeCommand("workbench.action.lockEditorGroup") - - return tabProvider + // Add listener for visibility changes to notify webview + newPanel.onDidChangeViewState( + (e) => { + const panel = e.webviewPanel + if (panel.visible) { + panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx + } + }, + null, // First null is for `thisArgs` + context.subscriptions, // Register listener for disposal + ) + + // Handle panel closing events. + newPanel.onDidDispose( + () => { + setPanel(undefined, "tab") + }, + null, + context.subscriptions, // Also register dispose listener + ) + + // Lock the editor group so clicking on files doesn't open them over the panel. + await delay(100) + await vscode.commands.executeCommand("workbench.action.lockEditorGroup") + + return tabProvider + })() + + pendingTabPanelCreation = creation + + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path above then takes over for the tracked panel. + pendingTabPanelCreation = undefined + } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bcbc1c56e2..1374e42fe1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -584,9 +584,8 @@ describe("ClineProvider", () => { }) describe("getInstanceForView", () => { - it("returns the instance that owns the given view", () => { - // @ts-ignore - accessing private property for testing - provider.view = mockWebviewView + it("returns the instance that owns the given view", async () => { + await provider.resolveWebviewView(mockWebviewView) expect(ClineProvider.getInstanceForView(mockWebviewView)).toBe(provider) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 583485c628..92eca69ec1 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 From cbf0edb4a2ea03068f1f167b40a367eee7906734 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 7 Sep 2026 16:13:46 +0800 Subject: [PATCH 03/65] test(activate): pin openClineInNewTab creation branches and strengthen the concurrency assertion --- .../__tests__/registerCommands.spec.ts | 122 +++++++++++++++++- 1 file changed, 118 insertions(+), 4 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index bd570ab8f4..96e00419e1 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -3,8 +3,9 @@ import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" import { ClineProvider } from "../../core/webview/ClineProvider" +import { MdmService } from "../../services/mdm/MdmService" -import { getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { getPanel, getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -641,6 +642,113 @@ describe("openClineInNewTab", () => { expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) + 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 }) + + // 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 = ClineProvider as unknown as Mock + expect(ctor.mock.instances[0]).toBeDefined() + expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, undefined) + expect(provider).toBe(ctor.mock.instances[0]) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) + }) + + it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { + ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [] + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + 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 = { viewColumn: undefined } as unknown as vscode.TextEditor + ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [ + editorWithoutColumn, + ] + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // 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("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { + const mockMdm = { name: "mock-mdm" } + // MdmService has a private constructor, so pin a sentinel stand-in. + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm as unknown as MdmService) + + const provider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + const ctor = ClineProvider as unknown as Mock + expect(ctor).toHaveBeenCalledTimes(1) + expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, 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 }) + + expect(getPanel()).toBeDefined() + + const panel = (vscode.window.createWebviewPanel as Mock).mock.results[0].value as { + onDidChangeViewState: Mock + onDidDispose: Mock + } + const stateHandler = panel.onDidChangeViewState.mock.calls[0][0] as (event: { + webviewPanel: { visible: boolean; webview: { postMessage: (message: unknown) => void } } + }) => void + const visibleEvent = { webviewPanel: { visible: true, webview: { postMessage: vi.fn() } } } + stateHandler(visibleEvent) + expect(visibleEvent.webviewPanel.webview.postMessage).toHaveBeenCalledWith({ + type: "action", + action: "didBecomeVisible", + }) + + const hiddenEvent = { webviewPanel: { visible: false, webview: { postMessage: vi.fn() } } } + stateHandler(hiddenEvent) + expect(hiddenEvent.webviewPanel.webview.postMessage).not.toHaveBeenCalled() + + const disposeHandler = panel.onDidDispose.mock.calls[0][0] as () => void + disposeHandler() + expect(getPanel()).toBeUndefined() + }) + it("serializes concurrent opens so overlapping calls create one panel and share one provider", async () => { const [first, second] = await Promise.all([ openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }), @@ -648,9 +756,15 @@ describe("openClineInNewTab", () => { ]) // Overlapping "Open in editor" calls must share the in-flight - // creation: exactly one tab panel is created and both callers - // receive the same provider. - expect(first).toBe(second) + // 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 = ClineProvider as unknown as Mock + const constructed = ctor.mock.instances[0] + expect(constructed).toBeDefined() + expect(first).toBe(constructed) + expect(second).toBe(constructed) expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(1) }) }) From 6cc68ef45144c400369447e550006d6f2d9e3caa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 00:11:44 +0800 Subject: [PATCH 04/65] fix(activate): harden tab creation serialization and centralize title-bar posts - openClineInNewTab: extract the unserialized creation body into createTabPanelUnlocked and guard the in-flight slot clear so a settled creation cannot clobber a replacement already stored in the slot. - onDidDispose: clear the tracked tab ref only when the disposing panel is still the tracked one, so a late disposal of a replaced panel cannot clobber the replacement's ref. - MDM lookup failure: log the fallback to the output channel instead of swallowing it silently. - Route the six title-bar button handlers through a shared postActions helper that posts each action in order and logs failures with the handler-specific prefix. - package.json: add the four InTab commands to the command palette, scoped to the active tab panel. - Tests: handler-level regression for openInNewTab + popoutButtonClicked started before the first creation resolves; fresh-creation test for a settled in-flight promise; stale-panel disposal regression; retained panel assertion for disposed tab instances; rightmost-editor column placement assertion; MDM fallback output assertion; %s placeholders for primitive it.each titles. - Stryker directives for the two equivalent setPanel type-literal mutants (setPanel branches only on type === sidebar). --- .../__tests__/registerCommands.spec.ts | 152 ++++++++++- src/activate/registerCommands.ts | 252 ++++++++++-------- src/package.json | 18 ++ 3 files changed, 301 insertions(+), 121 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 96e00419e1..0f34a93b88 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -2,6 +2,7 @@ import type { Mock } from "vitest" import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" +import { ContextProxy } from "../../core/config/ContextProxy" import { ClineProvider } from "../../core/webview/ClineProvider" import { MdmService } from "../../services/mdm/MdmService" @@ -283,7 +284,7 @@ describe("registerCommands handlers", () => { "zoo-code.historyButtonClickedInTab", "zoo-code.marketplaceButtonClickedInTab", ] - it.each(inTabNoOpCommands)("$command is a no-op when no tab panel is tracked", async (command) => { + 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() @@ -291,12 +292,14 @@ describe("registerCommands handlers", () => { expect(mockVisibleProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it.each(inTabNoOpCommands)("$command is a no-op when the tab instance is disposed", async (command) => { - setPanel({} as vscode.WebviewPanel, "tab") + 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() }) @@ -657,6 +660,11 @@ describe("openClineInNewTab", () => { expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, 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 () => { @@ -708,6 +716,27 @@ describe("openClineInNewTab", () => { ) }) + 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. + ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [ + { viewColumn: 1 } as unknown as vscode.TextEditor, + { viewColumn: 3 } as unknown as vscode.TextEditor, + ] + + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // 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 () => { const mockMdm = { name: "mock-mdm" } // MdmService has a private constructor, so pin a sentinel stand-in. @@ -767,4 +796,121 @@ describe("openClineInNewTab", () => { 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 = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: sidebarProvider as unknown as ClineProvider, + }) + + 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 = ClineProvider as unknown as Mock + 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 }) + 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 }) + + const ctor = ClineProvider as unknown as Mock + 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 }) + 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 }) + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(getPanel()).toBe(createdPanels[1]) + + // Panel A's stale dispose handler fires after the replacement is + // tracked; it must not clobber the replacement's ref. + createdPanels[0].onDidDispose.mock.calls[0][0]() + + expect(getPanel()).toBe(createdPanels[1]) + + // Tab-surface commands still reach the provider that owns the + // replacement panel after the stale disposal. + const replacementProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(replacementProvider) + const commandHandlers: Record unknown> = {} + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + commandHandlers[id] = cb + return { dispose: vi.fn() } + }, + ) + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: {} as ClineProvider, + }) + await commandHandlers["zoo-code.historyButtonClickedInTab"]() + expect(replacementProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "action", + action: "historyButtonClicked", + }) + }) }) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 8338d4b149..1e8e20a5ce 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import delay from "delay" -import type { CommandId } from "@roo-code/types" +import type { CommandId, ExtensionMessage } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Package } from "../shared/package" @@ -104,6 +104,23 @@ 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, @@ -149,13 +166,8 @@ const getCommandsMap = ({ settingsButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("settings") - void provider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) - // Also explicitly post the visibility message to trigger scroll reliably - void provider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => outputChannel.appendLine(`[settingsButtonClicked] postMessageToWebview failed: ${error}`)) + // Also explicitly post the visibility message to trigger scroll reliably. + postActions(outputChannel, provider, ["settingsButtonClicked", "didBecomeVisible"], "settingsButtonClicked") }, settingsButtonClickedInTab: () => { const tabProvider = getTabProvider() @@ -165,23 +177,17 @@ const getCommandsMap = ({ TelemetryService.instance.captureTitleButtonClicked("settings") - void tabProvider - .postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) - void tabProvider - .postMessageToWebview({ type: "action", action: "didBecomeVisible" }) - .catch((error) => - outputChannel.appendLine(`[settingsButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) + postActions( + outputChannel, + tabProvider, + ["settingsButtonClicked", "didBecomeVisible"], + "settingsButtonClickedInTab", + ) }, historyButtonClicked: () => { TelemetryService.instance.captureTitleButtonClicked("history") - void provider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => outputChannel.appendLine(`[historyButtonClicked] postMessageToWebview failed: ${error}`)) + postActions(outputChannel, provider, ["historyButtonClicked"], "historyButtonClicked") }, historyButtonClickedInTab: () => { const tabProvider = getTabProvider() @@ -191,29 +197,17 @@ const getCommandsMap = ({ TelemetryService.instance.captureTitleButtonClicked("history") - void tabProvider - .postMessageToWebview({ type: "action", action: "historyButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[historyButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, tabProvider, ["historyButtonClicked"], "historyButtonClickedInTab") }, marketplaceButtonClicked: () => { - void provider - .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 } - void tabProvider - .postMessageToWebview({ type: "action", action: "marketplaceButtonClicked" }) - .catch((error) => - outputChannel.appendLine(`[marketplaceButtonClickedInTab] postMessageToWebview failed: ${error}`), - ) + postActions(outputChannel, tabProvider, ["marketplaceButtonClicked"], "marketplaceButtonClickedInTab") }, newTask: handleNewTask, setCustomStoragePath: async () => { @@ -291,106 +285,128 @@ export const openClineInNewTab = async ({ context, outputChannel }: Omit { - // 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). - // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts - const contextProxy = await ContextProxy.getInstance(context) - const codeIndexManager = CodeIndexManager.getInstance(context) + const creation = createTabPanelUnlocked({ context, outputChannel }) + pendingTabPanelCreation = creation - // Get the existing MDM service instance to ensure consistent policy enforcement - let mdmService: MdmService | undefined - try { - mdmService = MdmService.getInstance() - } catch (error) { - // MDM service not initialized, which is fine - extension can work without it - mdmService = undefined + try { + return await creation + } finally { + // Clear once settled (success or failure) so the next call starts + // fresh: the reuse path in createTabPanelUnlocked then takes over + // for the tracked panel. Guard the clear so this settlement cannot + // clobber a replacement already stored in the slot. That clobber is + // unreachable in single-threaded settlement order: while the slot + // holds this in-flight creation, every other caller receives that + // same promise (guard above), so no replacement can be stored before + // this finally block runs — the equality check pins the invariant. + // Stryker disable next-line ConditionalExpression: defensive clobber guard, unreachable per the ordering argument above. + if (pendingTabPanelCreation === creation) { + pendingTabPanelCreation = undefined } + } +} - const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) - const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) +// The unserialized tab-creation body. Only openClineInNewTab may call it, +// after it has stored the shared in-flight promise. +const createTabPanelUnlocked = async ({ context, outputChannel }: Omit) => { + // 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 + } + } - // Check if there are any visible text editors, otherwise open a new group - // to the right. - const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 + // (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). + // https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts + const contextProxy = await ContextProxy.getInstance(context) + const codeIndexManager = CodeIndexManager.getInstance(context) - if (!hasVisibleEditors) { - await vscode.commands.executeCommand("workbench.action.newGroupRight") - } + // Get the existing MDM service instance to ensure consistent policy enforcement + let mdmService: MdmService | undefined + try { + mdmService = MdmService.getInstance() + } catch (error) { + // MDM service unavailable: log the fallback and continue without it. + outputChannel.appendLine(`[openClineInNewTab] MDM service unavailable, continuing without it: ${error}`) + mdmService = undefined + } - const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two + const tabProvider = new ClineProvider(context, outputChannel, "editor", contextProxy, mdmService) + const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0)) - const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [context.extensionUri], - }) + // Check if there are any visible text editors, otherwise open a new group + // to the right. + const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0 - // Save as tab type panel. - setPanel(newPanel, "tab") + if (!hasVisibleEditors) { + await vscode.commands.executeCommand("workbench.action.newGroupRight") + } - // TODO: Use better svg icon with light and dark variants (see - // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). - newPanel.iconPath = { - light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), - dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), - } + const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two - await tabProvider.resolveWebviewView(newPanel) + const newPanel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Zoo Code", targetCol, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }) - // Add listener for visibility changes to notify webview - newPanel.onDidChangeViewState( - (e) => { - const panel = e.webviewPanel - if (panel.visible) { - panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx - } - }, - null, // First null is for `thisArgs` - context.subscriptions, // Register listener for disposal - ) + // Save as tab type panel. + // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment + setPanel(newPanel, "tab") - // Handle panel closing events. - newPanel.onDidDispose( - () => { - setPanel(undefined, "tab") - }, - null, - context.subscriptions, // Also register dispose listener - ) + // TODO: Use better svg icon with light and dark variants (see + // https://stackoverflow.com/questions/58365687/vscode-extension-iconpath). + newPanel.iconPath = { + light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_light.png"), + dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "panel_dark.png"), + } - // 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") + await tabProvider.resolveWebviewView(newPanel) - return tabProvider - })() + // Add listener for visibility changes to notify webview + newPanel.onDidChangeViewState( + (e) => { + const panel = e.webviewPanel + if (panel.visible) { + panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx + } + }, + null, // First null is for `thisArgs` + context.subscriptions, // Register listener for disposal + ) + + // Handle panel closing events: clear the tracked ref only if this panel + // is still the tracked one, so a late disposal of an already-replaced + // panel cannot clobber the replacement's ref. + newPanel.onDidDispose( + () => { + if (tabPanel === newPanel) { + // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment + setPanel(undefined, "tab") + } + }, + null, + context.subscriptions, // Also register dispose listener + ) - pendingTabPanelCreation = creation + // 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") - try { - return await creation - } finally { - // Clear once settled (success or failure) so the next call starts - // fresh: the reuse path above then takes over for the tracked panel. - pendingTabPanelCreation = undefined - } + return tabProvider } diff --git a/src/package.json b/src/package.json index cb2e971a1d..ae390ca5bb 100644 --- a/src/package.json +++ b/src/package.json @@ -287,6 +287,24 @@ } ] }, + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + } + ], "keybindings": [ { "command": "zoo-code.addToContext", From d7453eb9a80e34d5088cf1077a96c5886badb22b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 04:22:14 +0800 Subject: [PATCH 05/65] test(activate): pin tracked tab identity against the created panel Replace the weak toBeDefined() assertion in the dispose spec with an identity check against the panel returned during creation, per the CodeRabbit actionable comment on this PR (review run 7c4cfeb3-6dd9-4615- 9a58-70cfc705eca2). The tracked tab is now pinned with toBe(panel) before the dispose assertions, so a wrong or duplicated tracked panel fails the suite instead of passing a defined-only check. Upstream: Zoo-Code-Org/Zoo-Code#1528 (vps2 F0) --- src/activate/__tests__/registerCommands.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 0f34a93b88..1836a2b98c 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -753,12 +753,15 @@ describe("openClineInNewTab", () => { it("posts didBecomeVisible only for visible state changes and clears the tracked tab on dispose", async () => { await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) - expect(getPanel()).toBeDefined() - + // 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 From 199d07217799375425dd2653485bbe7fd8e5dbe3 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 04:51:48 +0800 Subject: [PATCH 06/65] test(activate): pin the tracked tab panel passed to getInstanceForView Retain the tracked tab panel in the InTab handler cases and assert that getInstanceForView was called with that exact panel, per the CodeRabbit actionable comment on this PR (review run 4afe1273-8739-4235-90d3-311db5f6ccb9, inline comment 3952466254 on the tabHandlerCases spec). A handler resolving any other view now fails instead of passing on the stubbed provider result alone; the same identity pin is applied to plusButtonClickedInTab. Upstream: Zoo-Code-Org/Zoo-Code#1528 (vps2 F0) --- src/activate/__tests__/registerCommands.spec.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 1836a2b98c..600697fc12 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -257,11 +257,17 @@ describe("registerCommands handlers", () => { "$command targets the tab instance for the tracked tab panel", ({ command, actions, telemetry }) => { const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } - setPanel({} as vscode.WebviewPanel, "tab") + // 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]() + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) for (const action of actions) { expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action }) } @@ -539,11 +545,15 @@ describe("registerCommands handlers", () => { evictCurrentTask: vi.fn().mockResolvedValue(undefined), refreshWorkspace: vi.fn().mockResolvedValue(undefined), } - setPanel({} as vscode.WebviewPanel, "tab") + // 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"]() + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) expect(TelemetryService.instance.captureTitleButtonClicked).toHaveBeenCalledWith("plus") expect(mockTabProvider.evictCurrentTask).toHaveBeenCalledTimes(1) expect(mockTabProvider.refreshWorkspace).toHaveBeenCalledTimes(1) From c1bf94739536105d4358c66965f781f8d501329b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 5 Sep 2026 15:00:35 +0800 Subject: [PATCH 07/65] feat(provider): persist per-view view-state identity and durable viewStates Each ClineProvider instance now owns a unique viewId (renderContext plus a monotonic counter) and registers a stable viewStateId for durable persistence. - Per-view state buffer (viewLocalState) holds mode / currentApiConfigName / apiConfiguration overrides in memory; saveViewState persists the non-secret subset durably under the active view id, rekeyed to the stable id on registration. - viewStates is stored as a map pruned to the newest 50 entries; writes go through a serialized queue so concurrent provider instances merge without lost updates. - setViewStateId sanitizes ids and rejects "__proto__" so a per-view entry can never be keyed through the Object.prototype setter. - postMessageToWebview no longer awaits the webview ack: a remounted or disposed page never acknowledges, and awaiting would wedge task-critical callers. - History restore falls back to the default mode view-locally instead of writing the shared global mode. - GlobalState gains the "viewStates" key and GLOBAL_STATE_KEYS tracks it. Adds F1a coverage in ClineProvider.spec.ts (viewId uniqueness, saveViewState persistence semantics, loadViewState fallback and failure, pruning, the __proto__ guard) and adapts the two history-restore tests in ClineProvider.sticky-mode.spec.ts to the view-local restore. getState() merging of hydrated per-view values and the remaining view-state suites land in the follow-up (F1b). --- packages/types/src/__tests__/index.test.ts | 5 + packages/types/src/global-settings.ts | 10 + packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 391 +++++++++++- .../webview/__tests__/ClineProvider.spec.ts | 575 +++++++++++++++++- .../ClineProvider.sticky-mode.spec.ts | 15 +- src/eslint-suppressions.json | 2 +- 7 files changed, 976 insertions(+), 23 deletions(-) diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 54ae7af089..48b1b94d1b 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -109,6 +109,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 */ @@ -117,6 +126,7 @@ export const globalSettingsSchema = z.object({ currentApiConfigName: z.string().optional(), listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(), pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(), + viewStates: z.record(z.string(), viewStateSchema).optional(), lastShownAnnouncementId: z.string().optional(), customInstructions: z.string().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 2f1222f8ee..96a6908f7d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -648,6 +648,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/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7969b1dfd9..170fd7e2b5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -57,6 +57,7 @@ import { getModelId, isRetiredProvider, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -135,6 +136,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 @@ -203,6 +212,9 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 + private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -322,6 +334,25 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.86.0-models-aborts-tool-streaming" // v3.86.0 models, aborts, and tool/UI streaming fixes @@ -336,14 +367,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) @@ -368,6 +402,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) => { @@ -490,6 +527,217 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy. + * When fresh is set, the map is read directly from globalState (bypassing the + * ContextProxy cache) so serialized writes never observe a stale in-memory value. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return {} + } + + return { ...viewStates } + } + + /** + * Persists this view's non-secret selections through the serialized write queue. + * The write re-reads the map fresh and merges into the existing entry, removing the + * entry entirely when nothing persistable remains, so concurrent views cannot clobber it. + * The entry is keyed by the view id active when the change was made. Writes captured + * while the provider still holds its temporary (pre-launch) id persist under that id + * and are re-keyed to the stable view id when the webview registers one, so a change + * that lands before the launch message stays durable instead of being lost. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const current = states[viewStateId] ?? {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + states[viewStateId] = next + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Keeps only the most recently updated entries of the persisted view states map, + * bounded by MAX_PERSISTED_VIEW_STATES so the global key cannot grow unboundedly. + */ + private prunePersistedViewStates(states: Record): Record { + return Object.fromEntries( + Object.entries(states) + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES), + ) + } + + /** + * Re-keys this provider's temporary pre-launch viewStates entry to the newly + * registered stable id so pre-launch writes become durable under the stable key + * instead of orphaning under a session-local temporary id. Only the provider's own + * temporary id is eligible: an entry under a previously registered stable id belongs + * to that webview's storage and is left alone. When the stable entry already exists + * it wins and the temporary entry is dropped, because temporary ids are session + * counters that can collide across window reloads. Runs through the serialized write + * queue like every other viewStates mutation. + */ + private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { + const previousViewStateId = this.viewId + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Registers this provider's stable view identifier and loads any persisted selections it owns. + * The identifier is sanitized so it remains a safe object key in the shared viewStates map. + */ + public async setViewStateId(viewStateId: string | undefined): Promise { + const normalizedViewStateId = viewStateId?.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject "__proto__": writing states["__proto__"] would go through the + // Object.prototype setter and be silently dropped by the later spread. + normalizedViewStateId === "__proto__" + ) { + return + } + + this.viewStateId = normalizedViewStateId + + // 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() + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + */ + private async loadViewState(): Promise { + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + if (persisted?.mode) { + loadedState.mode = persisted.mode as 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 + } + + this.viewLocalState = loadedState + 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 */ @@ -1259,7 +1507,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 +1726,20 @@ export class ClineProvider return } - try { - await this.view?.webview.postMessage(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(message)).catch(() => { + // Swallow: postMessage rejects when the webview is disposed in flight. + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -3178,6 +3438,7 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) + await this._saveViewLocalStateFromMutation({ [key]: value }) } public getValue(key: K) { @@ -3185,11 +3446,115 @@ 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 ( + typeof sanitizedValues.mode === "string" && + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + // An unknown 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. + this.log(`[ClineProvider#setValues] Ignoring unknown mode "${sanitizedValues.mode}"`) + delete sanitizedValues.mode + } + + await this.contextProxy.setValues(sanitizedValues) + await this._saveViewLocalStateFromMutation(sanitizedValues) + } + + /** + * 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 + } + } else if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + const providerSettingsUpdate = PROVIDER_SETTINGS_KEYS.reduce((acc, key) => { + if (key in values) { + return { ...acc, [key]: values[key as keyof RooCodeSettings] } + } + + return acc + }, {} as ProviderSettings) + + this.viewLocalState.apiConfiguration = + "apiProvider" in providerSettingsUpdate + ? providerSettingsUpdate + : { + ...(this.viewLocalState.apiConfiguration ?? {}), + ...providerSettingsUpdate, + } + } + } + + /** + * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) + * into the registered viewStates map for this view. + */ + private async _persistViewLocalStateFromMutation( + values: Partial & Partial, + ): Promise { + const persistedValues: Partial = {} + + if ("mode" in values) { + persistedValues.mode = values.mode as PersistedViewState["mode"] + } + + if ("currentApiConfigName" in values) { + persistedValues.currentApiConfigName = values.currentApiConfigName + } + + if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { + await this.savePersistedViewState(persistedValues) + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} } // dev @@ -3218,6 +3583,14 @@ export class ClineProvider } await this.contextProxy.resetAllState() + + // Clear view-local state cache so getState() falls back to ContextProxy defaults. + this._clearViewLocalState() + + // Clear this view's persisted entry too, so the reset selections are not + // re-applied from the durable viewStates pin after a reload. + await this.clearPersistedViewState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1374e42fe1..d39ef15f8e 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, @@ -31,6 +32,7 @@ import { ContextProxy } from "../../config/ContextProxy" import { WorkspaceIndexingEnablementManager } from "../../../services/code-index/workspace-indexing-enablement-manager" import { Task, TaskOptions } from "../../task/Task" import { safeWriteJson } from "../../../utils/safeWriteJson" +import { t } from "../../../i18n" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" @@ -608,7 +610,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: { @@ -853,6 +855,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() + }) + describe("theme fixture probes", () => { const fixture = { themeId: "Default Dark Modern", @@ -1056,6 +1078,541 @@ describe("ClineProvider", () => { 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), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // 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)) + + 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)) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // 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)) + + 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)) + + 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)) + + 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)) + + 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)) + + 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)) + const providerAccess = provider as unknown as { + setViewStateId: (viewStateId: string) => Promise + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + viewLocalState: Partial + } + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await providerAccess.setViewStateId("stable-sidebar-view") + + await expect(providerAccess.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(providerAccess.viewLocalState).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + 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)) + + 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)) + const logSpy = vi.spyOn(provider, "log") + + provider["viewLocalState"] = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValue").mockImplementation(() => { + throw new Error("load failed") + }) + + await provider["loadViewState"]() + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-5"]).toBeDefined() + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + 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() + }) + }) + + 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)) + 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)) + // A string in storage is corrupt: the fresh-read guard must not spread it. + mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + 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)) + // Seed storage directly (bypassing the ContextProxy cache) so only the fresh read sees it. + mockContext.globalState.update("viewStates", { [provider.viewId]: { mode: "architect", updatedAt: 1 } }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + await provider.dispose() + }) + + it("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + mockContext.globalState.update("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5 }, + }) + await provider.dispose() + }) + + it("should clear only this view's entry without clobbering an entry only storage knows about", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + await provider["setViewStateId"]("stable-sidebar-view") + // The cache only knows this view's entry; storage gains an extra view directly. + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + mockContext.globalState.update("viewStates", { + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { mode: "code" }, + }) + await provider["clearPersistedViewState"]() + expect(mockContext.globalState.get("viewStates")).toEqual({ "stable-editor-view": { mode: "code" } }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toBeDefined() + expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should sanitize, reject blank and undefined ids, and no-op on the active id", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + 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)) + 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)) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await writer.dispose() + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + 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)) + 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)) + 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 persist known modes, ignore unknown modes and pass through non-string modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + 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 unknown mode "bogus-mode"')) + expect(mockContext.globalState.get("mode")).toBe("refactor") + expect(provider["viewLocalState"].mode).toBe("refactor") + // A non-string mode bypasses the slug validation (double assertion: the type excludes non-strings). + await provider.setValues({ mode: 42 } as unknown as RooCodeSettings) + expect(mockContext.globalState.get("mode")).toBe(42) + expect(provider["viewLocalState"].mode).toBe(42) + } 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)) + 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 build the buffered apiConfiguration from provider settings keys", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + provider["viewLocalState"] = { apiConfiguration: { openRouterApiKey: "key-1" } } + await provider.setValues({ apiProvider: providerIdentifiers.openrouter }) + expect(provider["viewLocalState"].apiConfiguration).toStrictEqual({ + apiProvider: providerIdentifiers.openrouter, + }) + await provider.setValues({ openRouterModelId: "model-x" }) + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await provider.dispose() + }) + + it("should remove the buffered apiConfiguration when it is cleared", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + 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)) + 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. + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + t("common:answers.yes") as unknown as vscode.MessageItem, + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.resetState() + expect(provider["viewLocalState"]).toEqual({}) + expect(mockContext.globalState.get("viewStates")).toEqual({}) + await provider.dispose() + }) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() @@ -2642,8 +3199,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'.", ) @@ -2715,8 +3274,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 @@ -2763,8 +3323,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") diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 55e26a9667..32edd75063 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -478,14 +478,15 @@ describe("ClineProvider - Sticky Mode", () => { mode: "architect", // Saved mode } - // Mock updateGlobalState to track mode updates - const updateGlobalStateSpy = vi.spyOn(provider as any, "updateGlobalState").mockResolvedValue(undefined) + // Register a stable view id so the durable per-view write is persisted + await provider["setViewStateId"]("stable-test-view") // Initialize task with history item await provider.createTaskWithHistoryItem(historyItem) - // Verify mode was restored via updateGlobalState - expect(updateGlobalStateSpy).toHaveBeenCalledWith("mode", "architect") + // Verify mode was restored into the view-local pin (no shared global write) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) it("should use current mode if history item has no saved mode", async () => { @@ -762,9 +763,11 @@ describe("ClineProvider - Sticky Mode", () => { // Restore the task from history await provider.createTaskWithHistoryItem(historyItem) - // Verify that the mode was restored + // Verify that the mode was restored into this view's durable pin. The + // getState() merge of hydrated per-view values lands with the F1b follow-up. + expect(provider["viewLocalState"].mode).toBe("architect") + const state = await provider.getState() - expect(state.mode).toBe("architect") // Verify that the API configuration was also restored expect(state.currentApiConfigName).toBe("architect-config") diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 92eca69ec1..b7907f6a1c 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1036,7 +1036,7 @@ }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 27 + "count": 36 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { From 1e952c53f8c2ef040bc98496fc28798d74357d7f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 00:39:16 +0800 Subject: [PATCH 08/65] fix(provider): track in-flight view-state mutations per field and exclude viewStates from settings transfer --- src/core/config/ContextProxy.ts | 3 + .../config/__tests__/ContextProxy.spec.ts | 16 +++ .../config/__tests__/importExport.spec.ts | 45 +++++++++ src/core/config/importExport.ts | 8 ++ src/core/webview/ClineProvider.ts | 34 ++++++- .../webview/__tests__/ClineProvider.spec.ts | 99 +++++++++++++++++++ 6 files changed, 204 insertions(+), 1 deletion(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..38c2c9ce99 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // Per-view selection state is machine-local: it keeps flowing through the + // normal runtime and pruning paths but must not transfer between settings. + viewStates: true, }) export class ContextProxy { diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2319a6b1a5..d389d31337 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -721,4 +721,20 @@ Output only the summary of the conversation so far, without any additional comme expect(customSupportPromptsUpdateCalls.length).toBe(0) }) }) + + describe("export", () => { + it("should exclude viewStates from the exported settings", async () => { + await proxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + await proxy.setValue("customInstructions", "global instructions") + + const exported = await proxy.export() + + // Per-view selection state is machine-local and must never transfer + // between settings, while ordinary global settings keep round-tripping. + expect(exported).not.toHaveProperty("viewStates") + expect(exported?.customInstructions).toBe("global instructions") + }) + }) }) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 0287b26511..c8a71d5c62 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -332,6 +332,51 @@ 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 }, + ]) + + const result = await importSettingsFromPath("/mock/path/settings.json", { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + 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(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) + expect(result).not.toHaveProperty("globalSettings.viewStates") + }) + it("should return success: false when file content is invalid", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 3c213fedf4..3351a20d62 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -98,6 +98,14 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { for (const [key, rawValue] of Object.entries(rawGlobalSettings)) { const path = `globalSettings.${key}` + + // Per-view selection state is machine-local: it round-trips through the + // normal runtime and pruning paths, but importing it would pin selections + // from another machine's views on this one. + if (key === "viewStates") { + continue + } + const schema = globalSettingsShape[key as keyof GlobalSettings] if (!schema) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 170fd7e2b5..18430c4e5c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -682,6 +682,8 @@ export class ClineProvider /** * 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 @@ -691,6 +693,11 @@ export class ClineProvider 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) { loadedState.mode = persisted.mode as Mode } @@ -715,7 +722,32 @@ export class ClineProvider return } - this.viewLocalState = loadedState + // Reapply only the fields mutated while the load was in flight: untouched + // fields keep the persisted values authoritative, and the pre-load buffer is + // never merged wholesale so stale temporary-id state or a cleared field cannot + // override the stable persisted state. + const postLoadBuffer = this.viewLocalState + const mergedState: Partial = { ...loadedState } + + if (postLoadBuffer.mode !== preLoadBuffer.mode && postLoadBuffer.mode !== undefined) { + mergedState.mode = postLoadBuffer.mode + } + + if ( + postLoadBuffer.currentApiConfigName !== preLoadBuffer.currentApiConfigName && + postLoadBuffer.currentApiConfigName !== undefined + ) { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } + + if ( + postLoadBuffer.apiConfiguration !== preLoadBuffer.apiConfiguration && + postLoadBuffer.apiConfiguration !== undefined + ) { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } + + this.viewLocalState = mergedState this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) } catch (error) { this.log( diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index d39ef15f8e..2968c03efd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1520,6 +1520,105 @@ describe("ClineProvider", () => { 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)) + const logSpy = vi.spyOn(provider, "log") + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + provider.providerSettingsManager = { + getProfile: vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ), + } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // 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)) + 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)) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }), + } + // A pre-load buffer write that was never persisted must not be merged over the load. + provider["viewLocalState"] = { mode: "architect" } + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider["viewLocalState"].mode).toBe("code") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + await writer.dispose() + await provider.dispose() + }) + + it("should not resurrect a field cleared mid-load from the pre-load buffer", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { name: string }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + provider.providerSettingsManager = { + getProfile: vi + .fn() + .mockImplementation(() => new Promise<{ name: string }>((resolve) => (resolveProfile = resolve))), + } + await provider.saveViewState("currentApiConfigName", "cfg-a") + provider["viewLocalState"] = { ...provider["viewLocalState"], mode: "architect" } + const load = provider["setViewStateId"]("stable-sidebar-view") + + // 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 persist known modes, ignore unknown modes and pass through non-string modes", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const logSpy = vi.spyOn(provider, "log") From b16a9e5228daae11aefdd2516d8cd306c0f87d15 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 01:16:21 +0800 Subject: [PATCH 09/65] fix(provider): restore previous view-state id when registration persistence fails --- src/core/webview/ClineProvider.ts | 19 ++++++++++--- .../webview/__tests__/ClineProvider.spec.ts | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 18430c4e5c..7e8079bd2a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -670,13 +670,24 @@ export class ClineProvider return } + const previousViewStateId = this.viewStateId + this.viewStateId = normalizedViewStateId - // 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) + 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() + 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 + } } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 2968c03efd..d8fc8e48b1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1324,6 +1324,33 @@ describe("ClineProvider", () => { 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) + // Seed a pre-launch entry under the temporary id so the re-key has real work to do. + mockContext.globalState.update("viewStates", { [provider.viewId]: { mode: "architect", updatedAt: 1 } }) + + const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) + + await expect(provider["setViewStateId"]("stable-sidebar-view")).rejects.toThrow("storage down") + + // The failed id must not stick: the provider keeps its previous (temporary) + // id so a later launch retries registration and the load instead of the + // guard early-returning for an id that was never persisted. + expect(provider["viewStateId"]).toBe(provider.viewId) + + // A later retry succeeds once the storage write works again, and the + // pre-launch entry lands under the registered id. + setValueSpy.mockRestore() + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewStateId"]).toBe("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + + await provider.dispose() + }) }) describe("view state persistence edge cases", () => { From a3c60645f12864a0321c9cbc09ed344583f8c2bf Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 08:32:35 +0800 Subject: [PATCH 10/65] fix(provider): reject invalid view-state modes and sync profile mutations through the view-local buffer --- src/core/webview/ClineProvider.ts | 49 ++- .../webview/__tests__/ClineProvider.spec.ts | 311 +++++++++++++++++- 2 files changed, 332 insertions(+), 28 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7e8079bd2a..e8e27a3fb0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -710,7 +710,15 @@ export class ClineProvider const preLoadBuffer = { ...this.viewLocalState } if (persisted?.mode) { - loadedState.mode = persisted.mode as 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) { @@ -2214,7 +2222,10 @@ export class ClineProvider // I left the original implementation in just to be safe. await Promise.all([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), + // 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), ]) @@ -2256,12 +2267,19 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + // Write the other settings in one bulk call, then route the current-profile write + // 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(). + const { currentApiConfigName: _previousApiConfigName, ...globalSettingsWithoutCurrent } = globalSettings + await this.contextProxy.setValues({ - ...globalSettings, - currentApiConfigName: profileToActivate, + ...globalSettingsWithoutCurrent, listApiConfigMeta: entries, }) + await this.setValue("currentApiConfigName", profileToActivate) + await this.postStateToWebview() } @@ -2329,7 +2347,10 @@ export class ClineProvider // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), + // 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), ]) } @@ -3495,14 +3516,16 @@ export class ClineProvider public async setValues(values: RooCodeSettings) { const sanitizedValues = { ...values } - if ( - typeof sanitizedValues.mode === "string" && - !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) - ) { - // An unknown 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. - this.log(`[ClineProvider#setValues] Ignoring unknown mode "${sanitizedValues.mode}"`) - delete sanitizedValues.mode + if (sanitizedValues.mode !== undefined) { + // An unknown or non-string mode (e.g. from an API payload) must not be persisted: + // a new Task would read it from getState() and persist it into task history. + if ( + typeof sanitizedValues.mode !== "string" || + !getModeBySlug(sanitizedValues.mode, await this.customModesManager.getCustomModes()) + ) { + this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) + delete sanitizedValues.mode + } } await this.contextProxy.setValues(sanitizedValues) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index d8fc8e48b1..fcf1f837df 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1556,19 +1556,23 @@ describe("ClineProvider", () => { openRouterModelId: string }) => void = () => {} // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - provider.providerSettingsManager = { - getProfile: vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ), - } + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") + // Let the stalled lookup begin so the in-flight 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", { @@ -1621,6 +1625,32 @@ describe("ClineProvider", () => { 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)) + 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)) let resolveProfile: (value: { name: string }) => void = () => {} @@ -1646,7 +1676,166 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should persist known modes, ignore unknown modes and pass through non-string modes", async () => { + it("should reapply an independently mutated mode when the load settles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // 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)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // The profile name changes while the lookup is in flight; the load still + // resolves the name persisted at load start. + await provider.saveViewState("currentApiConfigName", "cfg-b") + + 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)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // 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)) + let resolveProfile: (value: { + name: string + apiProvider: string + openRouterModelId: string + }) => void = () => {} + // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. + const getProfileSpy = vi + .fn() + .mockImplementation( + () => + new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( + (resolve) => (resolveProfile = resolve), + ), + ) + // @ts-ignore - The spy-backed double only needs the stalled getProfile member. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // @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)) const logSpy = vi.spyOn(provider, "log") // @ts-ignore - Replace customModesManager with a test double (no custom modes). @@ -1661,13 +1850,15 @@ describe("ClineProvider", () => { 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 unknown 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 bypasses the slug validation (double assertion: the type excludes non-strings). + // 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. await provider.setValues({ mode: 42 } as unknown as RooCodeSettings) - expect(mockContext.globalState.get("mode")).toBe(42) - expect(provider["viewLocalState"].mode).toBe(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) } @@ -1739,6 +1930,96 @@ describe("ClineProvider", () => { }) }) + 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)) + 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)) + // @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) + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + await provider["setViewStateId"]("shared-view") + expect(provider.getValues().currentApiConfigName).toBe("old-profile") + + await provider.activateProviderProfile({ name: "new-profile" }) + + // The buffer must track the activated profile so getValues() agrees with the proxy. + expect(provider.getValues().currentApiConfigName).toBe("new-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") + await writer.dispose() + await provider.dispose() + }) + + it("should sync the view-local buffer when creating and activating a profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + 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 sync the view-local buffer when deleting the current profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The fallback profile must replace the deleted one in both the proxy and the buffer. + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") + await provider.dispose() + }) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() From 3aec562a16cf77a02f35205c56429e64d53b9194 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 08:32:57 +0800 Subject: [PATCH 11/65] fix(package): move command palette entries to the commandPalette menu contribution --- src/package.json | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/package.json b/src/package.json index ae390ca5bb..7027f516a9 100644 --- a/src/package.json +++ b/src/package.json @@ -285,26 +285,26 @@ "group": "overflow@2", "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" } + ], + "commandPalette": [ + { + "command": "zoo-code.plusButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.settingsButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.marketplaceButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + }, + { + "command": "zoo-code.historyButtonClickedInTab", + "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + } ] }, - "commandPalette": [ - { - "command": "zoo-code.plusButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" - }, - { - "command": "zoo-code.settingsButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" - }, - { - "command": "zoo-code.marketplaceButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" - }, - { - "command": "zoo-code.historyButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" - } - ], "keybindings": [ { "command": "zoo-code.addToContext", From 016c221e195cfc58401138b85237eab41920bde5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 8 Sep 2026 11:11:49 +0800 Subject: [PATCH 12/65] fix(webview): sync view-local state with mode and profile mutations and target tab-instance commands Reapply in-flight view-local fields with Object.is identity so a field cleared during the load window stays cleared; route mode switches through setValue so the in-memory buffer and durable write agree, with rollback on failure; refresh cross-instance view-local state on profile upsert, activate and delete and re-pin the buffer after a delete; point focusInput and active-panel re-registration at the tracked tab provider and panel; log dropped webview postMessage failures with the message type; pin tab-instance, focusInput and active-panel identity in the registerCommands tests and type the mdm double in the provider spec. --- .../__tests__/registerCommands.spec.ts | 83 ++++++- src/activate/registerCommands.ts | 22 +- src/core/webview/ClineProvider.ts | 206 +++++++++++++++--- .../webview/__tests__/ClineProvider.spec.ts | 152 +++++-------- src/eslint-suppressions.json | 2 +- src/package.json | 8 +- 6 files changed, 337 insertions(+), 136 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 600697fc12..043059c260 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -267,7 +267,9 @@ describe("registerCommands handlers", () => { handlers[command]() - expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + // 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 }) } @@ -386,12 +388,28 @@ describe("registerCommands handlers", () => { expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) - it("focusInput does not post when a tab panel is tracked alongside the sidebar", async () => { + it("focusInput does not post when a tab panel is tracked without a live tab instance", async () => { setPanel({} as vscode.WebviewView, "sidebar") setPanel({} as vscode.WebviewPanel, "tab") await handlers["zoo-code.focusInput"]() + // The tab takes selection priority, so the sidebar must not receive + // the message; with no live tab instance there is no other target. + expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() + }) + + it("focusInput posts the focus message on the tab instance when a tab panel is tracked", async () => { + const mockTabProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + setPanel({} as vscode.WebviewView, "sidebar") + const tabPanel = {} as vscode.WebviewPanel + setPanel(tabPanel, "tab") + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockTabProvider) + + await handlers["zoo-code.focusInput"]() + + expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + expect(mockTabProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled() }) @@ -553,7 +571,9 @@ describe("registerCommands handlers", () => { await handlers["zoo-code.plusButtonClickedInTab"]() - expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + // 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) @@ -655,6 +675,63 @@ describe("openClineInNewTab", () => { 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 = { + marker: "panel-A", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } as unknown as vscode.WebviewPanel + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // ...then panel B is created, which re-points the tracked tab ref. + const panelB = { + marker: "panel-B", + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + } as unknown as vscode.WebviewPanel + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) + + // Activating A must reassign the tracked tab ref to A's panel... + const stateChange = (panelA.onDidChangeViewState as Mock).mock.calls[0]![0] as (e: { + webviewPanel: vscode.WebviewPanel + }) => void + stateChange({ webviewPanel: { ...panelA, active: true, visible: true } }) + + // ...so plusButtonClickedInTab targets A's provider, not B's. + const mockProviderA = { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + evictCurrentTask: vi.fn().mockResolvedValue(undefined), + refreshWorkspace: vi.fn().mockResolvedValue(undefined), + } + ;(ClineProvider.getInstanceForView as Mock).mockImplementation((view: unknown) => + (view as { marker?: string }).marker === "panel-A" ? mockProviderA : undefined, + ) + const handlers = new Map unknown>() + ;(vscode.commands.registerCommand as Mock).mockImplementation( + (id: string, cb: (...args: unknown[]) => unknown) => { + handlers.set(id, cb) + return { dispose: vi.fn() } + }, + ) + const mockSidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + registerCommands({ + context: mockContext, + outputChannel: mockOutputChannel, + provider: mockSidebarProvider as unknown as ClineProvider, + }) + + 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") diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 1e8e20a5ce..64b29d5978 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -234,11 +234,15 @@ const getCommandsMap = ({ try { await focusPanel(tabPanel, sidebarPanel) - // Send focus input message only when the sidebar panel was - // focused: the tab takes selection priority in focusPanel, so - // the sidebar receives the message only when no tab panel is - // tracked. - if (sidebarPanel && !tabPanel) { + // Post to the surface focusPanel selected: the tab takes + // selection priority, so the sidebar is targeted only when no + // tab panel is tracked. + if (tabPanel) { + const tabProvider = getTabProvider() + if (tabProvider) { + await tabProvider.postMessageToWebview({ type: "action", action: "focusInput" }) + } + } else if (sidebarPanel) { await provider.postMessageToWebview({ type: "action", action: "focusInput" }) } } catch (error) { @@ -382,6 +386,14 @@ const createTabPanelUnlocked = async ({ context, outputChannel }: Omit { const panel = e.webviewPanel + // Re-point the tracked tab ref at the panel the user is actually + // looking at: several tab panels can stay visible at once, but + // only the active one is the current tab, and the title-bar + // commands must resolve that instance, not the last created one. + if (panel.active) { + // Stryker disable next-line StringLiteral: setPanel only distinguishes "sidebar"; any other value routes to the tab-ref assignment + setPanel(panel, "tab") + } if (panel.visible) { panel.webview.postMessage({ type: "action", action: "didBecomeVisible" }) // Use the same message type as in SettingsView.tsx } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e8e27a3fb0..5f5c4b9fb8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -741,29 +741,38 @@ export class ClineProvider return } - // Reapply only the fields mutated while the load was in flight: untouched - // fields keep the persisted values authoritative, and the pre-load buffer is - // never merged wholesale so stale temporary-id state or a cleared field cannot - // override the stable persisted state. + // 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 (postLoadBuffer.mode !== preLoadBuffer.mode && postLoadBuffer.mode !== undefined) { - mergedState.mode = postLoadBuffer.mode + if (!Object.is(preLoadBuffer.mode, postLoadBuffer.mode)) { + if (postLoadBuffer.mode === undefined) { + delete mergedState.mode + } else { + mergedState.mode = postLoadBuffer.mode + } } - if ( - postLoadBuffer.currentApiConfigName !== preLoadBuffer.currentApiConfigName && - postLoadBuffer.currentApiConfigName !== undefined - ) { - mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + if (!Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName)) { + if (postLoadBuffer.currentApiConfigName === undefined) { + delete mergedState.currentApiConfigName + } else { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } } - if ( - postLoadBuffer.apiConfiguration !== preLoadBuffer.apiConfiguration && - postLoadBuffer.apiConfiguration !== undefined - ) { - mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + if (!Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration)) { + if (postLoadBuffer.apiConfiguration === undefined) { + delete mergedState.apiConfiguration + } else { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } } this.viewLocalState = mergedState @@ -1788,8 +1797,13 @@ export class ClineProvider // 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(message)).catch(() => { + void Promise.resolve(webview.postMessage(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)}`, + ) }) } @@ -2070,7 +2084,36 @@ export class ClineProvider } } - await this.updateGlobalState("mode", newMode) + // A cancelled or timed-out switch must not write the mode or emit + // ModeChanged: check the mutation signal right before the durable write. + if (signal?.aborted) { + return + } + + // setValue (not the deprecated updateGlobalState) so the in-memory viewLocalState + // buffer stays in sync with the durable global write: getValues() merges + // viewLocalState on top of the ContextProxy values, so an unsynced stale + // restored mode would otherwise shadow the fresh switch for consumers. + // If the durable write fails, roll the shared write back so getValues() + // cannot mix a fresh shared mode with the stale pre-switch buffer. + const previousMode = this.getValue("mode") + try { + await this.setValue("mode", newMode) + } catch (error) { + try { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { + this.log( + `[handleModeSwitch] Failed to roll back shared mode after persistence failure: ${ + rollbackError instanceof Error ? rollbackError.message : String(rollbackError) + }`, + ) + } + this.log( + `[handleModeSwitch] Failed to persist mode "${newMode}": ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } this.emit(RooCodeEventName.ModeChanged, newMode) @@ -2228,8 +2271,18 @@ export class ClineProvider this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: also refresh this view's buffer so a stale + // loaded apiConfiguration cannot keep shadowing the new settings in + // getState(). + this._saveViewLocalStateFromMutation(providerSettings), ]) + // 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, providerSettings) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -2267,19 +2320,44 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) - // Write the other settings in one bulk call, then route the current-profile write - // 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(). - const { currentApiConfigName: _previousApiConfigName, ...globalSettingsWithoutCurrent } = globalSettings + // Write only the profile list back: replaying the full settings snapshot + // captured above would also rewrite unrelated keys (including viewStates, + // which ClineProvider mutates directly in storage for concurrent views) + // with this view's stale cached copy. + await this.contextProxy.setValue("listApiConfigMeta", entries) - await this.contextProxy.setValues({ - ...globalSettingsWithoutCurrent, - listApiConfigMeta: entries, - }) + // Resolve the surviving profile's settings so this view and any other + // live view still pinned to the deleted profile can be re-pinned with + // a matching configuration. + let survivingSettings: ProviderSettings | undefined + try { + const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } await this.setValue("currentApiConfigName", profileToActivate) + if (profileToDelete.name === globalSettings.currentApiConfigName && survivingSettings) { + // The deleted profile was the active one, so the shared provider keys + // and this view's buffer still carry its settings; replace both so + // getState() reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + await this._saveViewLocalStateFromMutation(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() } @@ -2352,7 +2430,17 @@ export class ClineProvider // 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: also refresh this view's buffer so a stale + // loaded apiConfiguration cannot keep shadowing the new settings in + // getState(). + this._saveViewLocalStateFromMutation(providerSettings), ]) + + // 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() @@ -2379,6 +2467,70 @@ 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["viewLocalState"].currentApiConfigName === name, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + await instance["_saveViewLocalStateFromMutation"]({ apiConfiguration: providerSettings }) + 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["viewLocalState"].currentApiConfigName === deletedProfileName, + ) + + if (affected.length === 0) { + return + } + + await Promise.all( + affected.map(async (instance) => { + const values: Partial & Partial = { + currentApiConfigName: replacementName, + } + + if (replacementSettings) { + values.apiConfiguration = replacementSettings + } + + await instance["_saveViewLocalStateFromMutation"](values) + await instance.postStateToWebview() + }), + ) + } + async updateCustomInstructions(instructions?: string) { // User may be clearing the field. await this.updateGlobalState("customInstructions", instructions || undefined) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index fcf1f837df..e0c5542c02 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -36,6 +36,7 @@ import { t } from "../../../i18n" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" +import type { MdmService } from "../../../services/mdm/MdmService" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -417,6 +418,35 @@ 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(() => new Promise((resolve) => (resolveProfile = resolve))) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // Return a stable wrapper around the closure binding: resolveProfile is + // reassigned to the pending promise's resolver once getProfile is called, + // so returning the variable directly would hand the test the initial no-op. + return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } +} + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -940,10 +970,13 @@ describe("ClineProvider", () => { }) test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { + // Structural double: the post path only reads these two members, and + // MdmService cannot be constructed as a plain object, so a double + // assertion is the last-resort cast here. const mdmService = { requiresCloudAuth: vi.fn().mockReturnValue(true), isCompliant: vi.fn().mockReturnValue({ compliant: false, reason: "auth required" }), - } as any + } as unknown as MdmService provider = new ClineProvider( mockContext, @@ -954,7 +987,9 @@ describe("ClineProvider", () => { ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider as any, "getStateToPostToWebview").mockResolvedValue({ version: "1.0.0" }) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ + version: "1.0.0", + } as unknown as ExtensionState) await provider.postStateToWebview() @@ -1550,22 +1585,7 @@ describe("ClineProvider", () => { 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)) const logSpy = vi.spyOn(provider, "log") - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1653,17 +1673,19 @@ describe("ClineProvider", () => { 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)) - let resolveProfile: (value: { name: string }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - provider.providerSettingsManager = { - getProfile: vi - .fn() - .mockImplementation(() => new Promise<{ name: string }>((resolve) => (resolveProfile = resolve))), - } + 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" }) @@ -1678,22 +1700,7 @@ describe("ClineProvider", () => { it("should reapply an independently mutated mode when the load settles", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1716,22 +1723,7 @@ describe("ClineProvider", () => { it("should reapply an independently mutated profile name when the load settles", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1757,22 +1749,7 @@ describe("ClineProvider", () => { it("should reapply an independently mutated apiConfiguration when the load settles", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -1798,22 +1775,7 @@ describe("ClineProvider", () => { it("should keep every persisted field authoritative when the load is untouched", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - let resolveProfile: (value: { - name: string - apiProvider: string - openRouterModelId: string - }) => void = () => {} - // @ts-ignore - Replace providerSettingsManager with a test double that stalls the profile lookup. - const getProfileSpy = vi - .fn() - .mockImplementation( - () => - new Promise<{ name: string; apiProvider: string; openRouterModelId: string }>( - (resolve) => (resolveProfile = resolve), - ), - ) - // @ts-ignore - The spy-backed double only needs the stalled getProfile member. - provider.providerSettingsManager = { getProfile: getProfileSpy } + 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") @@ -1948,13 +1910,11 @@ describe("ClineProvider", () => { 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, - }), + getProfile: vi.fn().mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }), } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.contextProxy.setValue( diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index b7907f6a1c..d0a2decc59 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1031,7 +1031,7 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 198 + "count": 196 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { diff --git a/src/package.json b/src/package.json index 7027f516a9..b92852a277 100644 --- a/src/package.json +++ b/src/package.json @@ -289,19 +289,19 @@ "commandPalette": [ { "command": "zoo-code.plusButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" }, { "command": "zoo-code.settingsButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" }, { "command": "zoo-code.marketplaceButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" }, { "command": "zoo-code.historyButtonClickedInTab", - "when": "activeWebviewPanelId == zoo-code.TabPanelProvider" + "when": "false" } ] }, From 257385849d532a20455d13d97e3945b8bae641e2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 20:37:05 +0800 Subject: [PATCH 13/65] fix(provider): close mode-switch abort window, drop bracket access, capture view pin on delete Address CodeRabbit walkthrough findings on the F1a unit: - handleModeSwitchUnlocked now bails before the task-level writes when the abort signal has fired, closing the partial-apply window where a cancelled switch could still rewrite the persisted task mode; the existing pre-write guard still covers in-flight aborts. - Replace bracket access to sibling-instance private members with a typed pinnedProfileName getter and direct private member access (compile-time safe across instances). - deleteProviderProfile now captures this view's pin before the currentApiConfigName rewrite so a view pinned to the deleted profile while the global selection points elsewhere is still reconfigured with the surviving profile's settings. Tests: focusInput asserts the tab panel by identity and that no error was logged on the success path; the stalled getProfile double fails loudly on a second lookup (only one lookup is resolvable). --- .../__tests__/registerCommands.spec.ts | 6 ++- src/core/webview/ClineProvider.ts | 44 +++++++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 12 ++++- 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 043059c260..cc29837676 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -408,7 +408,11 @@ describe("registerCommands handlers", () => { await handlers["zoo-code.focusInput"]() - expect(ClineProvider.getInstanceForView as Mock).toHaveBeenCalledWith(tabPanel) + // 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() }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5f5c4b9fb8..e71f9725c6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -353,6 +353,15 @@ export class ClineProvider */ private viewLocalState: Partial = {} + /** + * This view's pinned profile name from the view-local buffer. Exposed for + * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` + * is private and must not be reached through bracket access. + */ + get pinnedProfileName(): string | undefined { + return this.viewLocalState.currentApiConfigName + } + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.86.0-models-aborts-tool-streaming" // v3.86.0 models, aborts, and tool/UI streaming fixes @@ -2058,6 +2067,14 @@ export class ClineProvider ): Promise { const task = targetTask + // A cancelled or timed-out switch must not be partially applied: bail out + // before the task history / _taskMode writes as well as the durable mode + // write below. The pre-write check further down still covers aborts that + // land while the task writes are in flight. + if (signal?.aborted) { + return + } + if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) @@ -2343,12 +2360,21 @@ export class ClineProvider ) } + // Capture this view's pin before setValue rewrites it: a view pinned to the + // deleted profile while the global selection points elsewhere must still be + // reconfigured, or getState() would keep the deleted profile's settings under + // the surviving profile's name. + const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name + await this.setValue("currentApiConfigName", profileToActivate) - if (profileToDelete.name === globalSettings.currentApiConfigName && survivingSettings) { - // The deleted profile was the active one, so the shared provider keys - // and this view's buffer still carry its settings; replace both so - // getState() reports the surviving profile's configuration. + if ( + (profileToDelete.name === globalSettings.currentApiConfigName || viewWasPinnedToDeleted) && + survivingSettings + ) { + // The deleted profile was the active one (globally, or for this view), so + // the shared provider keys and this view's buffer still carry its settings; + // replace both so getState() reports the surviving profile's configuration. await this.contextProxy.setProviderSettings(survivingSettings) await this._saveViewLocalStateFromMutation(survivingSettings) } @@ -2480,7 +2506,7 @@ export class ClineProvider providerSettings: ProviderSettings, ): Promise { const affected = ClineProvider.getAllInstances().filter( - (instance) => instance !== this && instance["viewLocalState"].currentApiConfigName === name, + (instance) => instance !== this && instance.pinnedProfileName === name, ) if (affected.length === 0) { @@ -2489,7 +2515,8 @@ export class ClineProvider await Promise.all( affected.map(async (instance) => { - await instance["_saveViewLocalStateFromMutation"]({ apiConfiguration: providerSettings }) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) await instance.postStateToWebview() }), ) @@ -2508,7 +2535,7 @@ export class ClineProvider replacementSettings: ProviderSettings | undefined, ): Promise { const affected = ClineProvider.getAllInstances().filter( - (instance) => instance !== this && instance["viewLocalState"].currentApiConfigName === deletedProfileName, + (instance) => instance !== this && instance.pinnedProfileName === deletedProfileName, ) if (affected.length === 0) { @@ -2525,7 +2552,8 @@ export class ClineProvider values.apiConfiguration = replacementSettings } - await instance["_saveViewLocalStateFromMutation"](values) + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation(values) await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index e0c5542c02..ac730c3f6a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -438,7 +438,17 @@ type StalledProfile = { */ function stallProviderSettingsProfile(provider: ClineProvider) { let resolveProfile: (value: StalledProfile) => void = () => {} - const getProfileSpy = vi.fn(() => new Promise((resolve) => (resolveProfile = resolve))) + 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 From 8d13c644c4d7fb40b7d2b94fe135a3b9829e2789 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 21:52:25 +0800 Subject: [PATCH 14/65] test(provider): cover abort and pinned profile deletion guards --- .../webview/__tests__/ClineProvider.spec.ts | 90 +++++++++++++++++++ .../ClineProvider.sticky-mode.spec.ts | 53 +++++++++++ 2 files changed, 143 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ac730c3f6a..ad2feab680 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1988,6 +1988,96 @@ describe("ClineProvider", () => { expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("keeper-profile") 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)) + 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). + await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") + provider["viewLocalState"].currentApiConfigName = "old-profile" + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }), + } + const setProviderSettingsSpy = vi + .spyOn(provider.contextProxy, "setProviderSettings") + .mockResolvedValue(undefined) + + await provider.deleteProviderProfile(oldProfile) + + // The captured pin must still trigger the reconfiguration: the shared + // provider keys and the view-local buffer both take the surviving + // profile's settings. + expect(setProviderSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") + expect(provider["viewLocalState"].apiConfiguration).toEqual( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), + ) + 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)) + 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. + await provider.contextProxy.setValue("currentApiConfigName", "other-profile") + provider["viewLocalState"].currentApiConfigName = "keeper-profile" + 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, + }), + } + 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. + expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(provider.getValues().currentApiConfigName).toBe("other-profile") + expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + await provider.dispose() + }) }) describe("postStateToWebviewThrottled", () => { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 32edd75063..2aa29af67b 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -458,6 +458,59 @@ describe("ClineProvider - Sticky Mode", () => { }), ) }) + + it("bails out before any task write when the mutation signal is already aborted", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + const mockTask = { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as unknown as Task + + 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("proceeds normally when no mutation signal is provided", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + const mockTask = { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as unknown as Task + + 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", () => { From 2ba6240af185e09e1fd42bb9f9353a777699a607 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 14 Sep 2026 22:08:57 +0800 Subject: [PATCH 15/65] test(provider): assert profile settings write identity --- src/core/webview/__tests__/ClineProvider.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ad2feab680..bcf08c1124 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2015,9 +2015,7 @@ describe("ClineProvider", () => { apiProvider: providerIdentifiers.anthropic, }), } - const setProviderSettingsSpy = vi - .spyOn(provider.contextProxy, "setProviderSettings") - .mockResolvedValue(undefined) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") await provider.deleteProviderProfile(oldProfile) From 56d797cac6ecc8d6343793c00c812cbdeb6c98a7 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 15 Sep 2026 09:24:52 +0800 Subject: [PATCH 16/65] fix(provider): make mode switch cancellation atomic and preserve view-local profile pins handleModeSwitchUnlocked: an abort landing while updateTaskHistory is in flight previously left the new mode persisted in task history and assigned to task._taskMode before the pre-write signal check bailed; the landed write is now rolled back to the pre-switch item and the method returns before the TaskModeSwitched emit and the durable mode write. TaskModeSwitched now only fires for a completed transition. deleteProviderProfile: the unconditional setValue('currentApiConfigName', ...) overwrote a view's pin when an unrelated profile was deleted; the pin is now re-pointed only when it names the deleted profile, and a deleted-was-global deletion updates the shared store only. The nested apiConfiguration overlay is replaced with the surviving profile's settings only for a view pinned to the deleted profile. --- src/core/webview/ClineProvider.ts | 52 +++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 36 +++++++--- .../ClineProvider.sticky-mode.spec.ts | 65 +++++++++++++++++++ 3 files changed, 132 insertions(+), 21 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e71f9725c6..a999c9f1ee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2069,15 +2069,15 @@ export class ClineProvider // 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. The pre-write check further down still covers aborts that - // land while the task writes are in flight. + // 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. @@ -2085,8 +2085,21 @@ 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. + if (signal?.aborted) { + await this.updateTaskHistory(taskHistoryItem) + return + } + } else if (signal?.aborted) { + return } + task.emit(RooCodeEventName.TaskModeSwitched, task.taskId, newMode) + // Only update the task's mode after successful persistence. ;(task as any)._taskMode = newMode } catch (error) { @@ -2360,23 +2373,38 @@ export class ClineProvider ) } - // Capture this view's pin before setValue rewrites it: a view pinned to the + // Capture this view's pin before any rewrite: a view pinned to the // deleted profile while the global selection points elsewhere must still be // reconfigured, or getState() would keep the deleted profile's settings under // the surviving profile's name. const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName - await this.setValue("currentApiConfigName", profileToActivate) + if (viewWasPinnedToDeleted) { + // This view's pin now dangles: re-point it. setValue also persists the + // survivor to the shared store, which covers the deleted-was-global case + // for every other view as well as this one. + await this.setValue("currentApiConfigName", profileToActivate) + } else if (deletedWasGlobal) { + // The shared selection changed, but this view's own pin still names a + // surviving profile: update the shared store only, leaving the + // view-local pin untouched. + await this.contextProxy.setValue("currentApiConfigName", profileToActivate) + } - if ( - (profileToDelete.name === globalSettings.currentApiConfigName || viewWasPinnedToDeleted) && - survivingSettings - ) { + if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { // The deleted profile was the active one (globally, or for this view), so - // the shared provider keys and this view's buffer still carry its settings; - // replace both so getState() reports the surviving profile's configuration. + // the shared provider keys still carry its settings; replace them so + // getState() reports the surviving profile's configuration. await this.contextProxy.setProviderSettings(survivingSettings) - await this._saveViewLocalStateFromMutation(survivingSettings) + + if (viewWasPinnedToDeleted) { + // This view's nested overlay (viewLocalState.apiConfiguration, seeded + // by loadViewState) still serves the deleted profile's configuration: + // replace it with the survivor's so the re-pointed pin serves matching + // settings. A view pinned to another profile keeps its own overlay. + await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) + } } // Re-pin other live views still buffered on the deleted profile: their diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bcf08c1124..1a5b1d0852 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2003,9 +2003,14 @@ describe("ClineProvider", () => { } 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). + // is still pinned to the profile being deleted (it loaded it earlier): + // the nested overlay still carries the deleted profile's configuration. await provider.contextProxy.setValue("currentApiConfigName", "keeper-profile") provider["viewLocalState"].currentApiConfigName = "old-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "deleted-profile-secret", + } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // @ts-ignore - Replace providerSettingsManager with a test double. provider.providerSettingsManager = { @@ -2021,14 +2026,16 @@ describe("ClineProvider", () => { // The captured pin must still trigger the reconfiguration: the shared // provider keys and the view-local buffer both take the surviving - // profile's settings. + // profile's settings, and the nested overlay is replaced wholesale so + // no key of the deleted profile survives. expect(setProviderSettingsSpy).toHaveBeenCalledWith( expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), ) expect(provider.getValues().currentApiConfigName).toBe("keeper-profile") - expect(provider["viewLocalState"].apiConfiguration).toEqual( - expect.objectContaining({ apiProvider: providerIdentifiers.anthropic }), - ) + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) await provider.dispose() }) @@ -2051,9 +2058,14 @@ describe("ClineProvider", () => { } 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. + // 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 = { @@ -2070,10 +2082,16 @@ describe("ClineProvider", () => { await provider.deleteProviderProfile(oldProfile) // No reconfiguration: the guard must stay false when neither the global - // selection nor the view pin names the deleted profile. + // 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.getValues().currentApiConfigName).toBe("other-profile") - expect(provider["viewLocalState"].apiConfiguration).toBeUndefined() + 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() }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 2aa29af67b..c6ec52ba24 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -487,6 +487,71 @@ describe("ClineProvider - Sticky Mode", () => { expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) + it("rolls back the landed history write and leaves no partial mode state when the signal aborts in flight", async () => { + // A minimal typed double keeps the test focused on the mode-switch contract. + const mockTask = { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as unknown as Task + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The history write settles only after the abort lands: the first call is + // controlled, the rollback call resolves immediately. + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await switchPromise + + // The persisted new mode is rolled back to the pre-switch item. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: "test-task-id", mode: "architect" }), + ) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith(2, historyItem) + // No partial mode state: the task keeps its previous mode, nothing was + // emitted, and the durable provider mode write never happened. + expect(mockTask["_taskMode"]).toBe("code") + expect(mockTask.emit).not.toHaveBeenCalledWith("taskModeSwitched", mockTask.taskId, "architect") + expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") + }) + it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. const mockTask = { From 6c9856db16535733be93fad191957bd459c2d99c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 15 Sep 2026 10:01:46 +0800 Subject: [PATCH 17/65] fix(core/webview): purge deleted provider profile from the settings store deleteProviderProfile pruned the UI-facing listApiConfigMeta entry but never removed the profile's settings from the ProviderSettingsManager store (context.secrets), so a later listApiConfigMeta sync could resurrect the deleted profile and a dangling per-mode mapping could re-activate it. The purge now calls providerSettingsManager.deleteConfig and branches on the typed ProviderSettingsNotFoundError (introduced here alongside) so an already-gone secret is an idempotent success -- the stale list entry is still pruned -- while any other failure (e.g. the store refusing to delete the last remaining configuration) propagates. Matching message text instead would let a profile whose name contains 'not found' swallow an unrelated failure. Tests: the dangling-mode-mapping resurrection scenario, the already-gone secret, the store-level last-profile refusal, the typed-signal contract in the manager spec, and provider-level not-found/propagation pins. --- src/core/config/ProviderSettingsManager.ts | 20 ++- .../__tests__/ProviderSettingsManager.spec.ts | 12 +- src/core/webview/ClineProvider.ts | 21 ++- .../webview/__tests__/ClineProvider.spec.ts | 62 +++++++++ .../ClineProvider.sticky-profile.spec.ts | 122 ++++++++++++++++++ 5 files changed, 234 insertions(+), 3 deletions(-) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 3fcc0e6e43..5cbd87d1cc 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -52,6 +52,19 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +/** + * Signals that a profile's configuration no longer exists. Callers that treat an + * already-deleted profile as an idempotent no-op branch on this type instead of + * matching error message text, which a profile name containing the phrase could + * otherwise spoof. + */ +export class ProviderSettingsNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderSettingsNotFoundError" + } +} + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() @@ -474,7 +487,7 @@ export class ProviderSettingsManager { const providerProfiles = await this.load() if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config '${name}' not found`) } if (Object.keys(providerProfiles.apiConfigs).length === 1) { @@ -485,6 +498,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__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 13e1aeeb2d..5f0a68ece5 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,12 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +import { + ProviderSettingsManager, + ProviderSettingsNotFoundError, + ProviderProfiles, + SyncCloudProfilesResult, +} from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -745,6 +750,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a999c9f1ee..aa3dffe2c9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -111,7 +111,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" @@ -2348,6 +2348,25 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } + // Remove the profile from the settings store (context.secrets) so it cannot be + // resurrected by a later listApiConfigMeta sync. A not-found rejection means + // the secret was already gone (e.g. pruned by an earlier run): branch on the + // typed ProviderSettingsNotFoundError so the stale list entry below is still + // pruned as an idempotent success, while any other failure (e.g. refusing to + // delete the last remaining configuration) propagates. Matching message text + // instead would let a profile whose name contains "not found" swallow an + // unrelated failure. + try { + await this.providerSettingsManager.deleteConfig(profileToDelete.name) + } catch (error) { + if (!(error instanceof ProviderSettingsNotFoundError)) { + throw error + } + this.log( + `deleteProviderProfile: settings for '${profileToDelete.name}' were not found; pruning the stale list entry only`, + ) + } + const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) // Write only the profile list back: replaying the full settings snapshot diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a5b1d0852..ecbef4d666 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -29,6 +29,7 @@ import { defaultModeSlug } from "../../../shared/modes" import { experimentDefault } from "../../../shared/experiments" 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" @@ -1989,6 +1990,65 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("should swallow only the typed not-found signal when pruning a stale profile entry", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const staleProfile: ProviderSettingsEntry = { + name: "stale-profile", + id: "stale-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [staleProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The secret was already pruned: the typed not-found must be an idempotent + // success so the stale list entry is still removed. + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockRejectedValue( + new ProviderSettingsNotFoundError(`Config 'stale-profile' not found`), + ) + + await provider.deleteProviderProfile(staleProfile) + + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([keeperProfile]) + await provider.dispose() + }) + + it("should propagate a non-not-found deletion failure for a profile named like the not-found message", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const profile: ProviderSettingsEntry = { + name: "not found config", + id: "nf-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [profile, keeperProfile]) + await provider.setValue("currentApiConfigName", "keeper-profile") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // An unrelated failure (wrapped the way deleteConfig wraps storage errors) + // must not be mistaken for the idempotent not-found path just because the + // profile name contains "not found". + const deleteConfigSpy = vi + .spyOn(provider.providerSettingsManager, "deleteConfig") + .mockRejectedValue( + new Error(`Failed to delete config: Error: storage write failed for 'not found config'`), + ) + + await expect(provider.deleteProviderProfile(profile)).rejects.toThrow("storage write failed") + + // The list entry must remain untouched when the deletion failed. + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([profile, keeperProfile]) + expect(deleteConfigSpy).toHaveBeenCalledTimes(1) + await provider.dispose() + }) + it("reconfigures a view pinned to the deleted profile even when the global selection points elsewhere", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const oldProfile: ProviderSettingsEntry = { @@ -2019,6 +2079,7 @@ describe("ClineProvider", () => { id: "keeper-id", apiProvider: providerIdentifiers.anthropic, }), + deleteConfig: vi.fn().mockResolvedValue(undefined), } const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") @@ -2074,6 +2135,7 @@ describe("ClineProvider", () => { id: "other-id", apiProvider: providerIdentifiers.openrouter, }), + deleteConfig: vi.fn().mockResolvedValue(undefined), } const setProviderSettingsSpy = vi .spyOn(provider.contextProxy, "setProviderSettings") diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 7d8493fba3..306602ad14 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -1015,4 +1015,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"]) + }) + }) }) From 67dbd22b2f316065a8d56e238bcf024e7be0a80c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 03:43:30 +0800 Subject: [PATCH 18/65] test: replace double assertions with single structural casts in view-identity specs --- .../__tests__/registerCommands.spec.ts | 64 ++++++++------- .../webview/__tests__/ClineProvider.spec.ts | 47 +++++------ .../ClineProvider.sticky-mode.spec.ts | 78 ++++++++++++------- 3 files changed, 109 insertions(+), 80 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index cc29837676..b86e40a22a 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -643,12 +643,12 @@ describe("openClineInNewTab", () => { it("reveals the existing tab instead of creating a second panel", async () => { const mockExistingProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } - const mockPanel = { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), reveal: vi.fn().mockResolvedValue(undefined), - } as unknown as vscode.WebviewPanel + }) setPanel(mockPanel, "tab") ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(mockExistingProvider) @@ -664,12 +664,12 @@ describe("openClineInNewTab", () => { }) it("creates a new tab panel when the tracked tab's provider has been disposed", async () => { - const mockPanel = { + const mockPanel = Object.assign({} as vscode.WebviewPanel, { webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), reveal: vi.fn().mockResolvedValue(undefined), - } as unknown as vscode.WebviewPanel + }) setPanel(mockPanel, "tab") ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) @@ -681,22 +681,22 @@ describe("openClineInNewTab", () => { it("re-points the tracked tab ref at the panel that becomes active", async () => { // Panel A is created first and tracked... - const panelA = { + const panelA = Object.assign({} as vscode.WebviewPanel, { marker: "panel-A", webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), - } as unknown as vscode.WebviewPanel + }) ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelA) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) // ...then panel B is created, which re-points the tracked tab ref. - const panelB = { + const panelB = Object.assign({} as vscode.WebviewPanel, { marker: "panel-B", webview: { postMessage: vi.fn() }, onDidChangeViewState: vi.fn(), onDidDispose: vi.fn(), - } as unknown as vscode.WebviewPanel + }) ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) @@ -722,11 +722,13 @@ describe("openClineInNewTab", () => { return { dispose: vi.fn() } }, ) - const mockSidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const mockSidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) registerCommands({ context: mockContext, outputChannel: mockOutputChannel, - provider: mockSidebarProvider as unknown as ClineProvider, + provider: mockSidebarProvider, }) await handlers.get("zoo-code.plusButtonClickedInTab")!() @@ -746,7 +748,7 @@ describe("openClineInNewTab", () => { // 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 = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) expect(ctor.mock.instances[0]).toBeDefined() expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, undefined) expect(provider).toBe(ctor.mock.instances[0]) @@ -759,7 +761,9 @@ describe("openClineInNewTab", () => { }) it("opens a new group to the right and targets ViewColumn.Two when no editors are visible", async () => { - ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [] + // 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 }) @@ -789,10 +793,10 @@ describe("openClineInNewTab", () => { 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 = { viewColumn: undefined } as unknown as vscode.TextEditor - ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [ - editorWithoutColumn, - ] + const editorWithoutColumn = Object.assign({} as vscode.TextEditor, { viewColumn: undefined }) + Object.assign(vscode.window, { + visibleTextEditors: [editorWithoutColumn], + }) await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) @@ -810,10 +814,12 @@ describe("openClineInNewTab", () => { 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. - ;(vscode.window as unknown as { visibleTextEditors: vscode.TextEditor[] }).visibleTextEditors = [ - { viewColumn: 1 } as unknown as vscode.TextEditor, - { viewColumn: 3 } as unknown as vscode.TextEditor, - ] + 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 }) @@ -829,13 +835,13 @@ describe("openClineInNewTab", () => { }) it("constructs the tab provider with the 'editor' context and the live MdmService instance", async () => { - const mockMdm = { name: "mock-mdm" } // MdmService has a private constructor, so pin a sentinel stand-in. - ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm as unknown as MdmService) + const mockMdm = Object.assign({} as MdmService, { name: "mock-mdm" }) + ;(MdmService.getInstance as Mock).mockReturnValue(mockMdm) const provider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) - const ctor = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) expect(ctor).toHaveBeenCalledTimes(1) expect(ctor).toHaveBeenCalledWith(mockContext, mockOutputChannel, "editor", undefined, mockMdm) expect(provider).toBe(ctor.mock.instances[0]) @@ -883,7 +889,7 @@ describe("openClineInNewTab", () => { // 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 = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) const constructed = ctor.mock.instances[0] expect(constructed).toBeDefined() expect(first).toBe(constructed) @@ -908,11 +914,13 @@ describe("openClineInNewTab", () => { return { dispose: vi.fn() } }, ) - const sidebarProvider = { postMessageToWebview: vi.fn().mockResolvedValue(undefined) } + const sidebarProvider = Object.assign({} as ClineProvider, { + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + }) registerCommands({ context: mockContext, outputChannel: mockOutputChannel, - provider: sidebarProvider as unknown as ClineProvider, + provider: sidebarProvider, }) const started = [commandHandlers["zoo-code.openInNewTab"](), commandHandlers["zoo-code.popoutButtonClicked"]()] @@ -927,7 +935,7 @@ describe("openClineInNewTab", () => { // 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 = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) const constructed = ctor.mock.instances[0] expect(constructed).toBeDefined() expect(first).toBe(constructed) @@ -947,7 +955,7 @@ describe("openClineInNewTab", () => { const secondProvider = await openClineInNewTab({ context: mockContext, outputChannel: mockOutputChannel }) - const ctor = ClineProvider as unknown as Mock + const ctor = vi.mocked(ClineProvider) const second = ctor.mock.instances[1] expect(second).toBeDefined() expect(secondProvider).toBe(second) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ecbef4d666..008f733bad 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -982,12 +982,12 @@ describe("ClineProvider", () => { test("postStateToWebview does not force action navigation for non-compliant MDM state", async () => { // Structural double: the post path only reads these two members, and - // MdmService cannot be constructed as a plain object, so a double - // assertion is the last-resort cast here. - const mdmService = { + // 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 unknown as MdmService + }) provider = new ClineProvider( mockContext, @@ -998,9 +998,11 @@ describe("ClineProvider", () => { ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async () => undefined) - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - version: "1.0.0", - } as unknown as ExtensionState) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + version: "1.0.0", + }), + ) await provider.postStateToWebview() @@ -1015,9 +1017,11 @@ describe("ClineProvider", () => { }) let statePostSettled = false - vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue({ - taskHistory: [], - } as unknown as ExtensionState) + vi.spyOn(provider, "getStateToPostToWebview").mockResolvedValue( + Object.assign({} as ExtensionState, { + taskHistory: [], + }), + ) const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockReturnValue(pendingPost) const statePost = provider.postStateToWebviewWithoutTaskHistory() @@ -1254,17 +1258,12 @@ describe("ClineProvider", () => { it("should not update viewLocalState when durable view-state persistence fails", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - const providerAccess = provider as unknown as { - setViewStateId: (viewStateId: string) => Promise - saveViewState: (key: keyof ExtensionState, value: unknown) => Promise - viewLocalState: Partial - } vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) - await providerAccess.setViewStateId("stable-sidebar-view") + await provider["setViewStateId"]("stable-sidebar-view") - await expect(providerAccess.saveViewState("mode", "architect")).rejects.toThrow("persist failed") - expect(providerAccess.viewLocalState).not.toHaveProperty("mode") + 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() @@ -1826,9 +1825,10 @@ describe("ClineProvider", () => { 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. - await provider.setValues({ mode: 42 } as unknown as RooCodeSettings) + // 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") @@ -1890,7 +1890,10 @@ describe("ClineProvider", () => { 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. + // 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, ) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index c6ec52ba24..afc6f38812 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -461,16 +461,22 @@ describe("ClineProvider - Sticky Mode", () => { it("bails out before any task write when the mutation signal is already aborted", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. - const mockTask = { - taskId: "test-task-id", - taskMode: "code", - _taskMode: undefined as string | undefined, - emit: vi.fn(), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } as unknown as Task + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { return Promise.resolve([]) @@ -489,16 +495,22 @@ describe("ClineProvider - Sticky Mode", () => { it("rolls back the landed history write and leaves no partial mode state when the signal aborts in flight", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. - const mockTask = { - taskId: "test-task-id", - taskMode: "code", - _taskMode: "code", - emit: vi.fn(), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } as unknown as Task + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) const historyItem: HistoryItem = { id: "test-task-id", @@ -554,16 +566,22 @@ describe("ClineProvider - Sticky Mode", () => { it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. - const mockTask = { - taskId: "test-task-id", - taskMode: "code", - _taskMode: undefined as string | undefined, - emit: vi.fn(), - saveClineMessages: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - updateApiConfiguration: vi.fn(), - } as unknown as Task + // The literal is asserted as Partial so its private members (_taskMode, + // saveClineMessages) stay out of the Object.assign intersection type, which + // would otherwise collapse to never. + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: undefined as string | undefined, + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) vi.spyOn(provider, "updateTaskHistory").mockImplementation(() => { return Promise.resolve([]) From 1004cbdb6866d7a178010c0aa45063dfc0ea32ad Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 05:04:47 +0800 Subject: [PATCH 19/65] fix(webview): contain the abort rollback failure in handleModeSwitch A failed task-history rollback during an aborted mode switch previously propagated into the outer persistence-error handler and surfaced as the switch's own persistence failure. Guard the rollback with its own try/catch so the rollback error is logged with task context and the cancellation return is preserved (CodeRabbit finding on this PR). --- src/core/webview/ClineProvider.ts | 10 +++- .../ClineProvider.sticky-mode.spec.ts | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index aa3dffe2c9..0c9c55f63c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2089,9 +2089,15 @@ export class ClineProvider // 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. + // 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) { - await this.updateTaskHistory(taskHistoryItem) + try { + await this.updateTaskHistory(taskHistoryItem) + } catch (rollbackError) { + this.log(`Failed to roll back mode switch ${task.taskId}: ${String(rollbackError)}`) + } return } } else if (signal?.aborted) { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index afc6f38812..67939682a8 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -564,6 +564,66 @@ describe("ClineProvider - Sticky Mode", () => { expect(mockContext.globalState.update).not.toHaveBeenCalledWith("mode", "architect") }) + it("keeps the cancellation result when the abort rollback write itself fails", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const historyItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem) + + // The first write settles after the abort lands; the rollback write rejects: + // the failure is logged with its own message and the cancelled switch still + // resolves (it must not surface as the switch's persistence error). + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockRejectedValueOnce(new Error("rollback write failed")) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("Failed to roll back mode switch"), + ) + }) + it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. // The literal is asserted as Partial so its private members (_taskMode, From dfb826420e3786417bb572759143736dc21f3dd2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 28 Sep 2026 05:16:33 +0800 Subject: [PATCH 20/65] fix(webview): restore only the mode field when rolling back an aborted switch The in-flight abort rollback rewrote the whole task-history item with the pre-switch snapshot, clobbering any fields the running task persisted during the pending window (tokens, cost, status, apiConfigName). Re-read the item and restore only the mode this switch changed (CodeRabbit data-integrity finding on this PR). --- src/core/webview/ClineProvider.ts | 6 +- .../ClineProvider.sticky-mode.spec.ts | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0c9c55f63c..b2097cd502 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2094,7 +2094,11 @@ export class ClineProvider // cancelled switch: log it and keep the cancellation result. if (signal?.aborted) { try { - await this.updateTaskHistory(taskHistoryItem) + // 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)}`) } diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 67939682a8..01d33c035c 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -624,6 +624,78 @@ describe("ClineProvider - Sticky Mode", () => { ) }) + it("preserves fields persisted during the pending window when rolling back the mode", async () => { + const mockTask = Object.assign( + {} as Task, + { + taskId: "test-task-id", + taskMode: "code", + _taskMode: "code", + emit: vi.fn(), + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + updateApiConfiguration: vi.fn(), + } as Partial, + ) + + const preSwitchItem: HistoryItem = { + id: "test-task-id", + ts: Date.now(), + task: "Test task", + mode: "code", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + } + // While the history write is in flight the running task persists other fields + // of the same item; the store now returns that updated item. + const updatedItem: HistoryItem = { + ...preSwitchItem, + mode: "architect", + tokensIn: 42, + totalCost: 0.5, + } + const storeGet = vi + .spyOn(provider.taskHistoryStore, "get") + .mockReturnValueOnce(preSwitchItem) + .mockReturnValue(updatedItem) + + let releaseUpdate!: (value: HistoryItem[]) => void + const updateTaskHistorySpy = vi + .spyOn(provider, "updateTaskHistory") + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseUpdate = resolve + }), + ) + .mockResolvedValueOnce([]) + await provider.addClineToStack(mockTask) + + const controller = new AbortController() + const switchPromise = provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal) + + await vi.waitFor(() => { + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(1) + }) + controller.abort() + releaseUpdate([]) + await expect(switchPromise).resolves.toBeUndefined() + + // The rollback restores only the mode: the fields persisted during the + // pending window (tokensIn, totalCost) survive the rollback write. + expect(updateTaskHistorySpy).toHaveBeenCalledTimes(2) + expect(storeGet).toHaveBeenCalledTimes(2) + expect(updateTaskHistorySpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: "test-task-id", mode: "code", tokensIn: 42, totalCost: 0.5 }), + ) + }) + it("proceeds normally when no mutation signal is provided", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. // The literal is asserted as Partial so its private members (_taskMode, From ce8b27638ebb159f2e6f51e28427b8546b41a4ca Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:40:17 +0800 Subject: [PATCH 21/65] fix(activate): drop the CodeIndexManager lookup left by the rebase Rebasing onto main tip merged main's createTabPanelUnlocked body with the PR's serialized creation. Main no longer resolves CodeIndexManager in this function, so the leftover line referenced an import the PR never added and the suite failed with ReferenceError. Removing it restores the PR's own delta: 48 registerCommands tests and 459 F1a tests pass. --- src/activate/registerCommands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 64b29d5978..f2ed9b53da 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -338,7 +338,6 @@ const createTabPanelUnlocked = async ({ context, outputChannel }: Omit Date: Mon, 7 Sep 2026 03:11:34 +0800 Subject: [PATCH 22/65] fix(core/webview): merge view-local state into getState for per-view overrides Fold ClineProvider viewLocalState on top of ContextProxy values in getState() (mode, apiConfiguration, and all per-view fields) so each webview reports its own selections while falling back to shared global state for everything else. Ports the getState-merging and local-state-isolation spec coverage from the superseded vps2 source. Also pins the full default surface of the merged read path, including the apiConfiguration provider fill-in when provider settings sanitize the raw value away (mutation-diff gate). --- src/core/webview/ClineProvider.ts | 201 +++---- .../webview/__tests__/ClineProvider.spec.ts | 518 ++++++++++++++++++ 2 files changed, 622 insertions(+), 97 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b2097cd502..923f965dee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3493,12 +3493,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. @@ -3560,121 +3566,122 @@ export class ClineProvider // Return the same structure as before. return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - allowedReadFiles: stateValues.allowedReadFiles ?? [], - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - allowedWriteFiles: stateValues.allowedWriteFiles ?? [], - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + apiConfiguration: { + ...providerSettings, + ...mergedStateValues.apiConfiguration, + }, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + allowedReadFiles: mergedStateValues.allowedReadFiles ?? [], + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + allowedWriteFiles: mergedStateValues.allowedWriteFiles ?? [], + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: - stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, - 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.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll() : [], - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 008f733bad..439340613b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -27,6 +27,7 @@ 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" @@ -2161,6 +2162,523 @@ describe("ClineProvider", () => { }) }) + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + 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), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + 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)) + + // 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)) + + await provider.saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + expect(state.language).toBeDefined() + expect(state.customModes).toBeDefined() + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + 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("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerAccess = provider as unknown as { + saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + } + const contextProxyAccess = provider.contextProxy as unknown 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 providerAccess.saveViewState("mode", "architect") + await providerAccess.saveViewState("currentApiConfigName", "view-profile") + await providerAccess.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 update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + 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() + + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + expect(provider["viewLocalState"].apiConfiguration?.apiProvider).toBe("bedrock") + expect(provider["viewLocalState"].apiConfiguration).not.toHaveProperty("openRouterModelId") + + await provider.dispose() + }) + + it("should persist setValue mutations for view-local mode", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + 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)) + + 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)) + + // This file's getModeBySlug mock resolves every slug; narrow it to the slugs + // under test so "not-a-real-mode" is rejected like the real lookup would. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + ["code", "architect"].includes(slug) ? { slug } : undefined) as typeof modesModule.getModeBySlug) + + try { + await provider.setValues({ mode: "not-a-real-mode" }) + + expect(provider.contextProxy.getValue("mode")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.setValues({ mode: "architect" }) + + expect(provider.contextProxy.getValue("mode")).toBe("architect") + expect(provider["viewLocalState"].mode).toBe("architect") + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + + await provider.dispose() + }) + + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + 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)) + + 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)) + + 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)) + + 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)) + + // 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)) + + // A stable entry already exists (e.g. a previous session persisted under a + // colliding temporary id); it must win over the temporary entry. + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "architect", updatedAt: 1 }, + "stable-sidebar-view": { mode: "debugger", updatedAt: 2 }, + }) + + await provider["setViewStateId"]("stable-sidebar-view") + + const viewStates = provider.contextProxy.getValue("viewStates") as Record + expect(viewStates["stable-sidebar-view"]).toMatchObject({ mode: "debugger" }) + expect(viewStates[provider.viewId]).toBeUndefined() + expect(provider["viewLocalState"].mode).toBe("debugger") + + await provider.dispose() + }) + + 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)) + const providerAccess = provider as unknown as { + viewId: string + viewLocalState: { mode?: string; currentApiConfigName?: string } + loadViewState(): Promise + setViewStateId(id: string): Promise + } + + // 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", { + [providerAccess.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( + () => + ({ + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }) as unknown as Awaited>, + ), + ) + + const staleLoad = providerAccess.loadViewState() + + // 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 = providerAccess.setViewStateId("stable-sidebar-view") + await register + + releaseGhost() + await staleLoad + + // The stale (temporary-id) load must not overwrite the stable id's load. + expect(providerAccess.viewLocalState).toEqual({ mode: "debug" }) + + await provider.dispose() + }) + }) + + describe("getState default values", () => { + it("should fall back to defaults for unset state values", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const state = await provider.getState() + + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) + expect(state.alwaysAllowReadOnly).toBe(false) + expect(state.alwaysAllowReadOnlyOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWrite).toBe(false) + expect(state.alwaysAllowWriteOutsideWorkspace).toBe(false) + expect(state.alwaysAllowWriteProtected).toBe(false) + expect(state.alwaysAllowExecute).toBe(false) + expect(state.alwaysAllowMcp).toBe(false) + expect(state.alwaysAllowModeSwitch).toBe(false) + expect(state.alwaysAllowSubtasks).toBe(false) + expect(state.alwaysAllowFollowupQuestions).toBe(false) + expect(state.followupAutoApproveTimeoutMs).toBe(60000) + expect(state.diagnosticsEnabled).toBe(true) + expect(state.soundEnabled).toBe(false) + expect(state.ttsEnabled).toBe(false) + expect(state.ttsSpeed).toBe(1) + expect(state.enableCheckpoints).toBe(true) + expect(state.checkpointTimeout).toBe(DEFAULT_CHECKPOINT_TIMEOUT_SECONDS) + expect(state.terminalPowershellCounter).toBe(false) + expect(state.terminalZshClearEolMark).toBe(true) + expect(state.terminalZshOhMy).toBe(false) + expect(state.terminalZshP10k).toBe(false) + expect(state.terminalZdotdir).toBe(false) + expect(state.mcpEnabled).toBe(true) + expect(state.listApiConfigMeta).toEqual([]) + expect(state.pinnedApiConfigs).toEqual({}) + expect(state.modeApiConfigs).toEqual({}) + expect(state.customSupportPrompts).toEqual({}) + expect(state.experiments).toEqual(experimentDefault) + expect(state.autoApprovalEnabled).toBe(false) + expect(state.maxOpenTabsContext).toBe(20) + expect(state.maxWorkspaceFiles).toBe(200) + expect(state.telemetrySetting).toBe("unset") + expect(state.enableSubfolderRules).toBe(false) + expect(state.maxImageFileSize).toBe(5) + expect(state.maxTotalImageSize).toBe(20) + expect(state.historyPreviewCollapsed).toBe(false) + expect(state.reasoningBlockCollapsed).toBe(true) + expect(state.enterBehavior).toBe("send") + expect(state.codebaseIndexModels).toEqual(EMBEDDING_MODEL_PROFILES) + expect(state.codebaseIndexConfig).toEqual({ + codebaseIndexEnabled: false, + codebaseIndexQdrantUrl: "http://localhost:6333", + codebaseIndexEmbedderProvider: providerIdentifiers.openai, + codebaseIndexEmbedderBaseUrl: "", + codebaseIndexEmbedderModelId: "", + }) + expect(state.profileThresholds).toEqual({}) + expect(state.includeDiagnosticMessages).toBe(true) + expect(state.maxDiagnosticMessages).toBe(50) + expect(state.includeTaskHistoryInEnhance).toBe(true) + expect(state.includeCurrentTime).toBe(true) + expect(state.includeCurrentCost).toBe(true) + expect(state.maxGitStatusFiles).toBe(0) + expect(state.language).toBe("en") + + await provider.dispose() + }) + + it("should report a non-retired apiProvider from state instead of the anthropic fallback", async () => { + const contextProxy = new ContextProxy(mockContext) + await contextProxy.setValues({ apiProvider: providerIdentifiers.openrouter }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", contextProxy) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.openrouter) + + await provider.dispose() + }) + + it("should fill the apiConfiguration apiProvider from the raw state value when provider settings sanitize it away", async () => { + // "bogus-provider" is neither an active nor a retired provider, so + // ContextProxy.sanitizeProviderValues drops it from the provider + // settings; the raw state value still reaches apiConfiguration via + // the getState fill-in, which is what this assertion pins. + const contextProxy = new ContextProxy(mockContext) + const contextProxyAccess = contextProxy as unknown as { + setValues: (values: Record) => Promise + } + await contextProxyAccess.setValues({ apiProvider: "bogus-provider" }) + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", contextProxy) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bogus-provider") + + await provider.dispose() + }) + + it("should serve the embedding model profiles default when the stored value is cleared", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // 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) + + const state = await provider.getState() + + expect(state.codebaseIndexModels).toBe(EMBEDDING_MODEL_PROFILES) + + await provider.dispose() + }) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() From 3887f8e3546c2367d213f1655883a980637d98cb Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:43:52 +0800 Subject: [PATCH 23/65] fix(webview): rebuild F1b on main tip + own delta Rebuilding the F1b unit as main tip + its own delta cleared the conflict with main. The patch was authored against an older main, so applying it dropped the alwaysDenyUnapprovedCommands entry from getState(); restoring it with the shared default constant keeps the settings round trip complete. 410 tests pass. --- .../config/__tests__/importExport.spec.ts | 50 ++++- src/core/webview/ClineProvider.ts | 40 ++-- .../webview/__tests__/ClineProvider.spec.ts | 179 +++++++++++++----- .../ClineProvider.sticky-mode.spec.ts | 29 ++- .../__tests__/webviewMessageHandler.spec.ts | 30 +++ 5 files changed, 252 insertions(+), 76 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index c8a71d5c62..286c7cae47 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -364,17 +364,63 @@ describe("importExport", () => { { 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: mockContextProxy, + 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(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) + 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 () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 923f965dee..be7a6ff099 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -57,7 +57,6 @@ import { getModelId, isRetiredProvider, providerIdentifiers, - PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -2312,10 +2311,10 @@ export class ClineProvider this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), // setProviderSettings writes the shared store directly, bypassing the - // view-local mutation path: also refresh this view's buffer so a stale - // loaded apiConfiguration cannot keep shadowing the new settings in - // getState(). - this._saveViewLocalStateFromMutation(providerSettings), + // 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; @@ -2514,10 +2513,10 @@ export class ClineProvider this.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), // setProviderSettings writes the shared store directly, bypassing the - // view-local mutation path: also refresh this view's buffer so a stale - // loaded apiConfiguration cannot keep shadowing the new settings in - // getState(). - this._saveViewLocalStateFromMutation(providerSettings), + // 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; @@ -3583,6 +3582,8 @@ export class ClineProvider alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, destructiveCommandGuardEnabled: mergedStateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + alwaysDenyUnapprovedCommands: + mergedStateValues.alwaysDenyUnapprovedCommands ?? DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS, alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, @@ -3819,23 +3820,12 @@ export class ClineProvider } else { this.viewLocalState.apiConfiguration = val } - } else if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { - const providerSettingsUpdate = PROVIDER_SETTINGS_KEYS.reduce((acc, key) => { - if (key in values) { - return { ...acc, [key]: values[key as keyof RooCodeSettings] } - } - - return acc - }, {} as ProviderSettings) - - this.viewLocalState.apiConfiguration = - "apiProvider" in providerSettingsUpdate - ? providerSettingsUpdate - : { - ...(this.viewLocalState.apiConfiguration ?? {}), - ...providerSettingsUpdate, - } } + + // 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. } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 439340613b..234c221c5a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1859,21 +1859,6 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should build the buffered apiConfiguration from provider settings keys", async () => { - const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - provider["viewLocalState"] = { apiConfiguration: { openRouterApiKey: "key-1" } } - await provider.setValues({ apiProvider: providerIdentifiers.openrouter }) - expect(provider["viewLocalState"].apiConfiguration).toStrictEqual({ - apiProvider: providerIdentifiers.openrouter, - }) - await provider.setValues({ openRouterModelId: "model-x" }) - expect(provider["viewLocalState"].apiConfiguration).toEqual({ - apiProvider: providerIdentifiers.openrouter, - openRouterModelId: "model-x", - }) - await provider.dispose() - }) - it("should remove the buffered apiConfiguration when it is cleared", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise @@ -2261,12 +2246,112 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should merge getValues from ContextProxy with view-local values taking precedence", async () => { + 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)) + // @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)) + // 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("clears this view's buffered apiConfiguration when directly activating a profile", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) - const providerAccess = provider as unknown as { - saveViewState: (key: keyof ExtensionState, value: unknown) => Promise + // 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), } - const contextProxyAccess = provider.contextProxy as unknown as { + 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)) + const contextProxyAccess = provider.contextProxy as { setValues: (values: Partial) => Promise } await contextProxyAccess.setValues({ @@ -2279,9 +2364,9 @@ describe("ClineProvider", () => { customModePrompts: { code: { roleDefinition: "shared" } }, }) - await providerAccess.saveViewState("mode", "architect") - await providerAccess.saveViewState("currentApiConfigName", "view-profile") - await providerAccess.saveViewState("apiConfiguration", { + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "view-profile") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "view-key", }) @@ -2299,7 +2384,7 @@ describe("ClineProvider", () => { await provider.dispose() }) - it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + it("should keep flat provider settings out of the view-local buffer", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) await provider.saveViewState("apiConfiguration", { @@ -2317,12 +2402,16 @@ describe("ClineProvider", () => { awsBedrockEndpointEnabled: true, }) - const state = await provider.getState() - - expect(state.apiConfiguration.apiProvider).toBe("bedrock") - expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") - expect(provider["viewLocalState"].apiConfiguration?.apiProvider).toBe("bedrock") - expect(provider["viewLocalState"].apiConfiguration).not.toHaveProperty("openRouterModelId") + // Flat provider-settings keys are shared settings: they must flow through + // the ContextProxy only and must not be merged into the view-local buffer, + // which would turn them into a per-view override masking later shared + // updates from other views. The explicit view-local override survives. + expect(provider["viewLocalState"].apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openrouter/old-model", + }) + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.bedrock) + expect(provider.contextProxy.getValue("awsBedrockEndpoint")).toBe("http://127.0.0.1:4567") await provider.dispose() }) @@ -2514,18 +2603,12 @@ describe("ClineProvider", () => { 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)) - const providerAccess = provider as unknown as { - viewId: string - viewLocalState: { mode?: string; currentApiConfigName?: string } - loadViewState(): Promise - setViewStateId(id: string): Promise - } // 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", { - [providerAccess.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, + [provider.viewId]: { mode: "architect", currentApiConfigName: "ghost-profile", updatedAt: 1 }, "stable-sidebar-view": { mode: "debug", updatedAt: 2 }, }) @@ -2536,29 +2619,28 @@ describe("ClineProvider", () => { releaseGhost = resolve }) vi.spyOn(provider.providerSettingsManager, "getProfile").mockReturnValue( - ghostLoad.then( - () => - ({ - name: "ghost-profile", - id: "ghost-id", - apiProvider: providerIdentifiers.anthropic, - }) as unknown as Awaited>, + ghostLoad.then(() => + Object.assign({} as Awaited>, { + name: "ghost-profile", + id: "ghost-id", + apiProvider: providerIdentifiers.anthropic, + }), ), ) - const staleLoad = providerAccess.loadViewState() + const staleLoad = provider["loadViewState"]() // 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 = providerAccess.setViewStateId("stable-sidebar-view") + const register = provider["setViewStateId"]("stable-sidebar-view") await register releaseGhost() await staleLoad // The stale (temporary-id) load must not overwrite the stable id's load. - expect(providerAccess.viewLocalState).toEqual({ mode: "debug" }) + expect(provider["viewLocalState"]).toEqual({ mode: "debug" }) await provider.dispose() }) @@ -2649,7 +2731,10 @@ describe("ClineProvider", () => { // settings; the raw state value still reaches apiConfiguration via // the getState fill-in, which is what this assertion pins. const contextProxy = new ContextProxy(mockContext) - const contextProxyAccess = contextProxy as unknown as { + // 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" }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 01d33c035c..26aebbe4dd 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -459,6 +459,31 @@ describe("ClineProvider - Sticky Mode", () => { ) }) + it("should sync the view-local mode buffer when switching modes after a restored view state", async () => { + // Simulate the history-restore path: saveViewState is what + // createTaskWithHistoryItem uses to pin a saved mode into the + // view-local buffer, leaving a stale mode there until the next mutation. + await provider.saveViewState("mode", "code") + + // Global-only mode switch with no active task. + await provider.handleModeSwitch("architect") + + // The durable global write still happens... + expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect") + + // ...and the in-memory buffer must not keep serving the stale restored + // mode: getValues() merges viewLocalState on top of the ContextProxy + // values, so an unsynced buffer would hide the fresh mode from consumers. + expect(provider["viewLocalState"].mode).toBe("architect") + + // The durable per-view write must land too: a regression that left the + // persisted entry on the stale restored mode would reload it on restart. + // setValue awaits the serialized write queue, so the entry is settled here. + const persisted = provider["getPersistedViewStates"]()[provider["viewStateId"]] + expect(persisted.mode).toBe("architect") + expect(provider.getValues().mode).toBe("architect") + }) + it("bails out before any task write when the mutation signal is already aborted", async () => { // A minimal typed double keeps the test focused on the mode-switch contract. // The literal is asserted as Partial so its private members (_taskMode, @@ -1031,11 +1056,11 @@ describe("ClineProvider - Sticky Mode", () => { // Restore the task from history await provider.createTaskWithHistoryItem(historyItem) - // Verify that the mode was restored into this view's durable pin. The - // getState() merge of hydrated per-view values lands with the F1b follow-up. + // 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") // Verify that the API configuration was also restored expect(state.currentApiConfigName).toBe("architect-config") diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index dcd70f92f1..6e04b91e92 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2138,6 +2138,25 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(calls.at(-1)).toEqual([true]) }) + // The webviewDidLaunch tests below replace these mockClineProvider members with + // per-test doubles. Snapshot the module-level originals at collection time and + // restore them in the afterEach below so the launch stubs never leak into other + // tests of this file. + // Single structural cast: the class types these members as a method / a + // readonly property, which cannot be re-assigned to swap in a per-test double. + const launchSuiteSnapshot = (() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + return { + getMcpHub: view.getMcpHub, + providerSettingsManager: view.providerSettingsManager, + getStateToPostToWebview: view.getStateToPostToWebview, + } + })() + // CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state // via an async provider.getStateToPostToWebview().then(...) continuation, outside // telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message @@ -2306,4 +2325,15 @@ describe("webviewMessageHandler - telemetrySetting", () => { expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled() }) + + afterEach(() => { + const view = mockClineProvider as { + getMcpHub: unknown + providerSettingsManager: unknown + getStateToPostToWebview: unknown + } + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) From b70e91e02953f792a41557780ea492f95f16e81e Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Mon, 5 Oct 2026 21:45:07 +0800 Subject: [PATCH 24/65] fix(webview): rebuild F1c on the rebuilt F1b + own delta Applying the F1c delta (ad9423948...8554307ec) onto the rebuilt F1b head cleared the conflict with main tip without changing content. 338 src tests and 39 webview-ui tests pass. --- .../webview/__tests__/ClineProvider.spec.ts | 84 ++-- .../__tests__/webviewMessageHandler.spec.ts | 147 ++++++- src/core/webview/webviewMessageHandler.ts | 51 ++- .../src/context/ExtensionStateContext.tsx | 5 +- .../__tests__/ExtensionStateContext.spec.tsx | 97 +++++ webview-ui/src/utils/__tests__/vscode.spec.ts | 365 ++++++++++++++++++ webview-ui/src/utils/vscode.ts | 89 ++++- 7 files changed, 794 insertions(+), 44 deletions(-) create mode 100644 webview-ui/src/utils/__tests__/vscode.spec.ts diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 234c221c5a..6dcc53001d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2223,9 +2223,10 @@ describe("ClineProvider", () => { // mode should come from viewLocalState expect(state.mode).toBe("architect") - // Other values should still come from global state / contextProxy - expect(state.language).toBeDefined() - expect(state.customModes).toBeDefined() + // 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() }) @@ -2291,16 +2292,13 @@ describe("ClineProvider", () => { 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), - } + // Typed spies on the real manager (instance-scoped): the upsert-activate path + // reads only these methods, so no manager replacement is needed. + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-b-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.upsertProviderProfile("profile-b", { apiProvider: providerIdentifiers.anthropic }, true) @@ -2323,20 +2321,17 @@ describe("ClineProvider", () => { 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), - } + // Typed spies on the real manager (instance-scoped): the direct-activation path + // reads only these methods, so no manager replacement is needed. + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // The direct-activation path (activateProviderProfileUnlocked) carries its own @@ -3094,6 +3089,41 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalled() }) + it("should re-pin the view to a valid global selection when the first listed profile has no name", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // @ts-ignore - Replace providerSettingsManager with a test double: the view's pinned config no + // longer exists, the shared global selection is still valid, and the only listed profile is a + // legacy entry without a name. + provider.providerSettingsManager = { + hasConfig: vi.fn(async (name: string) => name === "global-valid"), + listConfig: vi.fn(async () => [{ id: "legacy-id", apiProvider: providerIdentifiers.openai }]), + saveConfig: vi.fn(async () => "legacy-id"), + dispose: vi.fn(), + } + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The view's own pin points at a deleted profile; the shared global selection is still valid. + await provider.saveViewState("currentApiConfigName", "gone-pin") + await provider.contextProxy.setValue("currentApiConfigName", "global-valid") + + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview") + const contextProxySetValueSpy = vi.spyOn(provider.contextProxy, "setValue") + await webviewMessageHandler(provider, { type: "webviewDidLaunch" }) + // The launch flow's profile-list sync is fire-and-forget; wait for its final post. + await vi.waitFor(() => + expect(postMessageSpy).toHaveBeenCalledWith(expect.objectContaining({ type: "listApiConfig" })), + ) + + // The valid shared selection is adopted by this view only: the view-local pin switches to it + // while the shared global selection is left untouched for the other views. + expect(provider["viewLocalState"].currentApiConfigName).toBe("global-valid") + expect(mockContext.globalState.get("currentApiConfigName")).toBe("global-valid") + // A nameless first listed profile must never clear the shared selection with undefined. + expect(contextProxySetValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", undefined) + + await provider.dispose() + }) + test("logs detached workspace initialization failures", async () => { await provider.resolveWebviewView(mockWebviewView) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 6e04b91e92..d4553303a8 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -69,7 +69,7 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, RooCodeSettings } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -99,6 +99,7 @@ const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInf const mockClineProvider = { getState: vi.fn(), postMessageToWebview: vi.fn(), + saveViewState: vi.fn(), customModesManager: { getCustomModes: vi.fn(), deleteCustomMode: vi.fn(), @@ -115,6 +116,16 @@ const mockClineProvider = { setValue: vi.fn(), getValue: vi.fn(), }, + // Delegates to contextProxy.setValue so existing assertions keep holding while + // the updateSettings flow is exercised through the provider-level mutation path. + setValue: vi + .fn() + .mockImplementation((key: string, value: unknown) => + mockClineProvider.contextProxy.setValue( + key as keyof RooCodeSettings, + value as RooCodeSettings[keyof RooCodeSettings], + ), + ), log: vi.fn(), postStateToWebview: vi.fn(), resolveWebviewThemeFixtureProbe: vi.fn(), @@ -261,6 +272,126 @@ 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 }) => Promise + getMcpHub: () => unknown + getStateToPostToWebview: () => Promise<{ telemetrySetting: string }> + } + const double = mockClineProvider as LaunchProviderFixture + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue( + Object.assign({} as Awaited>, { + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + currentApiConfigName: "view-local-profile", + }), + ) + double.setViewStateId = vi.fn().mockResolvedValue(undefined) + double.workspaceTracker = { initializeFilePaths: vi.fn().mockResolvedValue(undefined) } + double.providerSettingsManager = { + listConfig: vi + .fn() + .mockResolvedValue([{ name: "shared-profile", apiProvider: providerIdentifiers.anthropic }]), + hasConfig: vi.fn().mockResolvedValue(false), + } + double.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + double.getMcpHub = vi.fn().mockReturnValue(undefined) + double.getStateToPostToWebview = vi.fn().mockResolvedValue({ telemetrySetting: "disabled" }) + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + // Key-aware so a mutated global-state key (e.g. "") resolves to nothing instead + // of the canned value, keeping the re-pin branch's global lookup observable. + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => + key === "currentApiConfigName" ? "shared-profile" : undefined, + ) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + it("validates the view-local currentApiConfigName on launch", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(double.setViewStateId).toHaveBeenCalledWith("view-1") + + // The merged (view-local) name is validated first; the shared global is only + // consulted when the view-local name is invalid. + expect(double.providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect(mockClineProvider.providerSettingsManager.hasConfig).toHaveBeenCalledWith("shared-profile") + // Both names are invalid in this setup, so the shared global is repaired. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.activateProviderProfile).toHaveBeenCalledWith({ name: "shared-profile" }) + }) + + it("re-pins only the view when its profile is missing but the shared global is still valid", async () => { + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The view pin is re-pinned to the first available profile, + // and the shared global selection is left untouched: no global write, no global activation. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( + "currentApiConfigName", + "shared-profile", + ) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("re-pins the view to the shared global profile rather than the first listed profile", async () => { + double.providerSettingsManager.listConfig = vi.fn().mockResolvedValue([ + { name: "first-listed", apiProvider: providerIdentifiers.anthropic }, + { name: "shared-profile", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The view pin follows the still-valid shared global selection, not the first + // profile in the list; the global selection is left untouched. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + expect(mockClineProvider.saveViewState).not.toHaveBeenCalledWith("currentApiConfigName", "first-listed") + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("records the legacy repair without activating a profile when no name is listed", async () => { + double.providerSettingsManager.listConfig = vi + .fn() + .mockResolvedValue([{ apiProvider: providerIdentifiers.anthropic }]) + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockResolvedValue(false) + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + // The legacy repair still records the (empty) selection, but does not activate a + // profile that has no name. + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", undefined) + expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() + }) + + it("logs and continues launch when view-state registration fails", async () => { + 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() @@ -1308,6 +1439,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, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4ba94d454c..ca6f53e151 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -579,7 +579,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) @@ -628,17 +641,36 @@ export const webviewMessageHandler = async ( } } - const currentConfigName = getGlobalState("currentApiConfigName") + const currentState = await provider.getState() + const currentConfigName = currentState.currentApiConfigName if (currentConfigName) { if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) { - // Current config name not valid, get first config in list. + // The merged name (which may be this view's durable pin) no longer + // resolves. When the shared global selection is still valid, re-pin + // only this view so the global selection is left untouched; only + // repair the global when it is invalid as well. + const globalConfigName = getGlobalState("currentApiConfigName") + const globalStillValid = + !!globalConfigName && + (await provider.providerSettingsManager.hasConfig(globalConfigName)) const name = listApiConfig[0]?.name - await updateGlobalState("currentApiConfigName", name) - if (name) { - await provider.activateProviderProfile({ name }) - return + if (globalStillValid && globalConfigName) { + // Re-pin this view to the still-valid shared global selection (not the + // first listed profile) so the view adopts the shared choice; the + // global selection itself is left untouched. + await provider.saveViewState("currentApiConfigName", globalConfigName) + // Fall through: refresh listApiConfigMeta and post listApiConfig + // to this webview below. + } else { + // Current config name not valid, get first config in list. + await updateGlobalState("currentApiConfigName", name) + + if (name) { + await provider.activateProviderProfile({ name }) + return + } } } } @@ -688,6 +720,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 @@ -855,7 +888,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() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 177372f310..ce333b3779 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -515,7 +515,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 4c2e2a092c..b2f293f4d3 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -15,6 +15,14 @@ import { } from "@roo-code/types" import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } 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 } = @@ -105,7 +113,96 @@ const InitialStateTestComponent = () => { ) } +const ViewLocalStateTestComponent = () => { + const { mode, setMode, currentApiConfigName, setCurrentApiConfigName } = useExtensionState() + + return ( +
+
{mode}
+
{currentApiConfigName}
+ + +
+ ) +} + describe("ExtensionStateContext", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("posts webviewDidLaunch with the stable viewStateId from vscode API", () => { + render( + + + , + ) + + expect(vscode.getViewStateId).toHaveBeenCalled() + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch", viewStateId: "view-a" }) + }) + + it("posts webviewDidLaunch without a viewStateId when getViewStateId is unavailable", () => { + const savedGetViewStateId = vscode.getViewStateId + Object.defineProperty(vscode, "getViewStateId", { configurable: true, value: undefined }) + try { + render( + + + , + ) + + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "webviewDidLaunch", viewStateId: undefined }) + } finally { + Object.defineProperty(vscode, "getViewStateId", { configurable: true, value: savedGetViewStateId }) + } + }) + + it("reseeds view-local mode and API profile from a new state payload after local edits", () => { + render( + + + , + ) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "code", currentApiConfigName: "profile-a", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("code") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-a") + + act(() => { + screen.getByTestId("set-local-mode").click() + screen.getByTestId("set-local-api-config").click() + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("ask") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("local-profile") + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "state", + state: { mode: "architect", currentApiConfigName: "profile-b", apiConfiguration: {} }, + }, + }), + ) + }) + expect(screen.getByTestId("view-local-mode")).toHaveTextContent("architect") + expect(screen.getByTestId("view-local-api-config")).toHaveTextContent("profile-b") + }) + it("initializes with empty allowedCommands array", () => { render( diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts new file mode 100644 index 0000000000..6153e07fd4 --- /dev/null +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -0,0 +1,365 @@ +import { VSCodeAPIWrapper } from "../vscode" + +const originalCrypto = globalThis.crypto +const originalLocalStorage = globalThis.localStorage + +// Minimal Storage surface for VSCodeAPIWrapper browser fallback tests. Typed +// precisely (instead of casting to Storage) so each double only promises the +// members the wrapper actually touches. +interface MockStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + clear(): void +} + +const createMockStorage = (initialState: Record = {}): MockStorage => { + const state = { ...initialState } + return { + getItem: vi.fn((key: string) => state[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + state[key] = value + }), + removeItem: vi.fn((key: string) => { + delete state[key] + }), + clear: vi.fn(() => { + for (const key of Object.keys(state)) { + delete state[key] + } + }), + } +} + +describe("VSCodeAPIWrapper", () => { + afterEach(() => { + vi.restoreAllMocks() + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: originalCrypto, + }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: originalLocalStorage, + }) + }) + + it("reuses the persisted webview viewStateId when browser storage is available", () => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "persisted-view" }) }), + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("persisted-view") + }) + + it("creates and persists a new viewStateId when storage has been cleared", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "generated-view") }, + }) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("generated-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "generated-view" }) + }) + + it("falls back to in-memory state when browser storage access is restricted", () => { + const randomUUID = vi.fn().mockReturnValueOnce("memory-view").mockReturnValueOnce("new-memory-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const storage: MockStorage = { + getItem: vi.fn(() => { + throw new Error("storage denied") + }), + setItem: vi.fn(() => { + throw new Error("storage denied") + }), + removeItem: vi.fn(() => { + throw new Error("storage denied") + }), + clear: vi.fn(() => { + throw new Error("storage denied") + }), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(wrapper.getViewStateId()).toBe("memory-view") + expect(randomUUID).toHaveBeenCalledTimes(1) + expect(storage.getItem).toHaveBeenCalled() + expect(storage.setItem).toHaveBeenCalled() + }) + + it("falls back to a timestamp-random id when crypto.randomUUID is unavailable", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: {}, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto global is undefined", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: undefined, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // 1700000000000.toString(36) === "loyw3v28" and (0.987654321).toString(36) === + // "0.zk00000ytu", so the deterministic fallback id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("falls back to a timestamp-random id when the crypto object lacks randomUUID", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { "": 1 }, + }) + vi.spyOn(Date, "now").mockReturnValue(1700000000000) + vi.spyOn(Math, "random").mockReturnValue(0.987654321) + const storage = createMockStorage() + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // A truthy crypto global without a randomUUID member must still take the + // deterministic fallback: 1700000000000.toString(36) === "loyw3v28" and + // (0.987654321).toString(36) === "0.zk00000ytu", so the id drops the "0." prefix. + expect(wrapper.getViewStateId()).toBe("loyw3v28-zk00000ytu") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "loyw3v28-zk00000ytu" }) + }) + + it("creates a new viewStateId when the stored state parses to JSON null", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "after-null-view") }, + }) + const storage = createMockStorage({ vscodeState: "null" }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("after-null-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "after-null-view" }) + }) + + it("replaces an empty persisted viewStateId with a freshly created one", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toMatchObject({ viewStateId: "refilled-view" }) + }) + + it("replaces a non-object persisted state with a freshly created viewStateId", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "replaced-string-view") }, + }) + // A persisted JSON string is truthy but not an object: the guard must keep it out of + // the fresh state, so the persisted record contains only the new viewStateId. + const storage = createMockStorage({ vscodeState: JSON.stringify("stale-string-state") }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("replaced-string-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "replaced-string-view" }) + }) + + it("returns the normalized id when the persisted id needs the extension-side cleanup", () => { + const randomUUID = vi.fn(() => "should-not-be-generated") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // ClineProvider.setViewStateId trims and rewrites unsafe characters, so the + // webview must report the same normalized id instead of the raw stored value. + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " side 1 " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("side_1") + expect(wrapper.getViewStateId()).toBe("side_1") + expect(randomUUID).not.toHaveBeenCalled() + }) + + it("generates a fresh id when the persisted id is whitespace-only", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "ws-refilled-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: " " }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("ws-refilled-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "ws-refilled-view" }) + }) + + it("generates a fresh id when the persisted id is the __proto__ string", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "proto-refused-view") }, + }) + const storage = createMockStorage({ vscodeState: JSON.stringify({ viewStateId: "__proto__" }) }) + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("proto-refused-view") + expect(JSON.parse(storage.getItem("vscodeState")!)).toEqual({ viewStateId: "proto-refused-view" }) + }) + + it("keeps one generated id when a write fails while stale persisted state stays readable", () => { + const randomUUID = vi.fn().mockReturnValueOnce("gen-one").mockReturnValueOnce("gen-two") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + // Storage still serves a stale persisted state (e.g. pre-identity webview state + // without a viewStateId) while setItem throws: without the write-failure flag, + // every getViewStateId call would re-read the stale JSON and generate a fresh id. + const storage: MockStorage = { + getItem: vi.fn(() => JSON.stringify({ mode: "architect" })), + setItem: vi.fn(() => { + throw new Error("write denied") + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + const first = wrapper.getViewStateId() + const second = wrapper.getViewStateId() + + expect(first).toBe("gen-one") + expect(second).toBe("gen-one") // no flap: the in-memory state stays authoritative + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "gen-one" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("keeps the in-memory viewStateId when setItem is unavailable while reads still work", () => { + const randomUUID = vi.fn().mockReturnValueOnce("no-setitem-view").mockReturnValueOnce("flapped-view") + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + const storage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + // The persisted JSON never gained the id: the in-memory state stays authoritative + // instead of re-reading the stale JSON on every call. + expect(wrapper.getViewStateId()).toBe("no-setitem-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "no-setitem-view" }) + expect(randomUUID).toHaveBeenCalledTimes(1) + }) + + it("treats persisted storage as authoritative again once a write recovers", () => { + Object.defineProperty(globalThis, "crypto", { + configurable: true, + value: { randomUUID: vi.fn(() => "recovered-view") }, + }) + const backing: Record = { vscodeState: JSON.stringify({ mode: "architect" }) } + let writesFail = true + const storage: MockStorage = { + getItem: vi.fn((key: string) => backing[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + if (writesFail) { + throw new Error("write denied") + } + backing[key] = value + }), + removeItem: vi.fn(), + clear: vi.fn(), + } + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }) + const wrapper = new VSCodeAPIWrapper() + + // The first generation cannot be persisted: the in-memory state is authoritative. + expect(wrapper.getViewStateId()).toBe("recovered-view") + expect(wrapper.getState()).toEqual({ mode: "architect", viewStateId: "recovered-view" }) + + // A later write succeeds: the flag clears and the persisted JSON is + // authoritative again for reads. + writesFail = false + wrapper.setState({ mode: "code", viewStateId: "recovered-view" }) + expect(backing.vscodeState).toBe(JSON.stringify({ mode: "code", viewStateId: "recovered-view" })) + // An external writer refreshes the persisted record: a recovered wrapper must see + // it (flag cleared); a still-flagged wrapper would keep the in-memory copy. + backing.vscodeState = JSON.stringify({ mode: "code", viewStateId: "recovered-view", external: true }) + expect(wrapper.getState()).toEqual({ mode: "code", viewStateId: "recovered-view", external: true }) + }) +}) diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..05dd380dfe 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -11,8 +11,13 @@ import { WebviewMessage } from "@roo/WebviewMessage" * dev server by using native web browser features that mock the functionality * enabled by acquireVsCodeApi. */ -class VSCodeAPIWrapper { +export class VSCodeAPIWrapper { private readonly vsCodeApi: WebviewApi | undefined + private fallbackState: unknown | undefined + // Once a persistent-storage write fails (setItem throws or is unavailable), + // the persisted JSON is stale: the in-memory fallbackState is authoritative + // for getState() until a write succeeds again. + private storageWriteFailed: boolean = false constructor() { // Check if the acquireVsCodeApi function exists in the current development @@ -22,6 +27,46 @@ class VSCodeAPIWrapper { } } + /** + * Generates a unique identifier for this webview instance. + * + * @remarks Used only when no persisted identifier exists yet. + */ + private createViewStateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + /** + * Returns the stable view state identifier for this webview, creating and persisting + * one on first use so the extension can keep per-view state isolated across providers. + */ + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + // Mirror the ClineProvider.setViewStateId normalization so the webview never + // registers an id the extension would rewrite or reject: trim, replace unsafe + // characters, and drop whitespace-only and "__proto__" values. + const normalizedViewStateId = + typeof existingViewStateId === "string" ? existingViewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") : "" + + if (normalizedViewStateId && normalizedViewStateId !== "__proto__") { + return normalizedViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,10 +94,25 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { - const state = localStorage.getItem("vscodeState") - return state ? JSON.parse(state) : undefined } + + if (this.storageWriteFailed) { + // A previous write could not be persisted: reading localStorage would + // return stale JSON, so the in-memory fallback is authoritative. + return this.fallbackState + } + + try { + // Stryker disable next-line ConditionalExpression,OptionalChaining: equivalent mutant - when localStorage is unavailable the guard-false path and the throwing body both return this.fallbackState from this catch + if (typeof localStorage?.getItem === "function") { + const state = localStorage.getItem("vscodeState") + return state ? JSON.parse(state) : this.fallbackState + } + } catch { + return this.fallbackState + } + + return this.fallbackState } /** @@ -69,10 +129,25 @@ class VSCodeAPIWrapper { public setState(newState: T): T { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) - } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) - return newState } + + this.fallbackState = newState + + try { + // Stryker disable next-line ConditionalExpression,OptionalChaining: equivalent mutant - when localStorage is unavailable the guard-false path and the throwing body both mark the write failed and return newState + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + this.storageWriteFailed = false + } else { + this.storageWriteFailed = true + } + } catch { + // Storage can be unavailable in restricted webview/browser contexts. + // The in-memory fallback above keeps a stable viewStateId for this session. + this.storageWriteFailed = true + } + + return newState } } From 5886f67988ad4a7c3906f6882dc069e702ae8718 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 09:28:36 +0800 Subject: [PATCH 25/65] fix(lint): correct the recorded suppression count for the sticky-mode spec The recorded count was 36 while the file has 26 @typescript-eslint/no-explicit-any violations, so `eslint . --max-warnings=0` fails with "There are suppressions left that do not occur anymore" on CI. Counts must never increase; this brings the record back to the actual count. --- src/eslint-suppressions.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d0a2decc59..a9369f5e19 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1036,7 +1036,7 @@ }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 36 + "count": 26 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { @@ -1724,4 +1724,4 @@ "count": 1 } } -} +} \ No newline at end of file From 019344d9b2ad80ec30a51277ffc2a8d2ca9bed52 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 09:43:05 +0800 Subject: [PATCH 26/65] test(webview): seed the re-key entries through the write path The re-key guard in F1a only moves an entry this instance authored, so seeding storage directly no longer exercises the re-key. Seeding through savePersistedViewState matches how a pre-launch entry is actually created. --- src/core/webview/__tests__/ClineProvider.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6dcc53001d..9e38b796fc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1374,8 +1374,9 @@ describe("ClineProvider", () => { 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) - // Seed a pre-launch entry under the temporary id so the re-key has real work to do. - mockContext.globalState.update("viewStates", { [provider.viewId]: { mode: "architect", updatedAt: 1 } }) + // Seed a pre-launch entry under the temporary id through the real write path, + // so the re-key has real work to do and records this instance as its author. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) @@ -1455,8 +1456,9 @@ describe("ClineProvider", () => { 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)) - // Seed storage directly (bypassing the ContextProxy cache) so only the fresh read sees it. - mockContext.globalState.update("viewStates", { [provider.viewId]: { mode: "architect", updatedAt: 1 } }) + // 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 }, @@ -1466,7 +1468,10 @@ describe("ClineProvider", () => { 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)) - mockContext.globalState.update("viewStates", { + // 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 }, }) From 9971777e8327a6d7d58db98190bc15772b6c94ba Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 10:13:35 +0800 Subject: [PATCH 27/65] fix(webview): refresh pinned views when a profile is saved without activation loadViewState buffers a full copy of the pinned profile's settings and getState() overlays it on the shared store, but the refresh ran only on the activating paths. A token refresh saved a non-active profile straight through saveConfig, so a view pinned to that profile kept serving the token captured before the save until it reloaded. The non-activating upsert now refreshes the pinned views (including this one), and handleZooCodeCallback routes its non-active writes through upsertProviderProfile so the refresh is not skipped. Tests: 251 passed in ClineProvider.spec.ts; ESLint clean with --max-warnings=0. --- src/core/webview/ClineProvider.ts | 15 ++++++-- .../webview/__tests__/ClineProvider.spec.ts | 34 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index be7a6ff099..00fde8ff1e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2330,6 +2330,15 @@ export class ClineProvider await this.persistStickyProviderProfileToCurrentTask(name) } else { await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + + // A save that does not activate still changes the profile's settings, and + // every view pinned to it buffers a full copy of them. Refresh those views + // (and this one when it is the pinned view) or their getState() keeps + // serving the settings captured before the save under the same name. + if (this.pinnedProfileName === name) { + await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + } + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } await this.postStateToWebview() @@ -2750,8 +2759,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) } } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9e38b796fc..75d350220d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2317,6 +2317,35 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("refreshes this view's buffer when a pinned profile is saved without activation", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Seed the per-view buffer as loadViewState would after a restart with a + // pinned profile: the pin and a full copy of its settings. + await provider.saveViewState("currentApiConfigName", "pinned-profile") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "old-token", + }) + + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("pinned-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "pinned-profile", id: "pinned-id", apiProvider: providerIdentifiers.zooGateway }, + ]) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // A token refresh saves the pinned profile without activating it. + await provider.upsertProviderProfile( + "pinned-profile", + { apiProvider: providerIdentifiers.zooGateway, zooSessionToken: "new-token" }, + false, + ) + + // The buffer must not keep serving the token captured before the save. + const state = await provider.getState({ includeTaskHistory: false }) + expect(state.apiConfiguration.zooSessionToken).toBe("new-token") + 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)) // Seed the per-view buffer with profile A's settings, as loadViewState would @@ -7115,12 +7144,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, ) }) From 0170600f789ba7ae1f13185c7d0b128f54670306 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 10:16:45 +0800 Subject: [PATCH 28/65] test(webview): spy the real managers instead of replacing them Partial doubles behind @ts-ignore hide a contract mismatch when the production path calls a member the double does not provide. Spying the real instance keeps the contract visible and removes the suppressions. StalledProfile now uses ProviderSettings so the spied getProfile type-checks without a cast. Tests: 251 passed in ClineProvider.spec.ts; ESLint clean with --max-warnings=0. --- .../webview/__tests__/ClineProvider.spec.ts | 130 +++++++----------- 1 file changed, 48 insertions(+), 82 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 75d350220d..fc1f57269d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -421,15 +421,11 @@ afterAll(() => { }) /** - * 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. + * Profile shape the stalled getProfile resolves to. ProviderSettings fields are + * optional, so a lookup that resolves name-only (a profile with no configured + * provider) still type-checks against the real getProfile return type. */ -type StalledProfile = { - name: string - apiProvider?: string - openRouterModelId?: string -} +type StalledProfile = ProviderSettings & { name: string } /** * Swap the provider's ProviderSettingsManager for a double whose getProfile @@ -440,7 +436,9 @@ type StalledProfile = { */ function stallProviderSettingsProfile(provider: ClineProvider) { let resolveProfile: (value: StalledProfile) => void = () => {} - const getProfileSpy = vi.fn(() => { + // Spy on the real manager instead of replacing it: getProfile is the only member + // loadViewState awaits, and a partial double hides any other member the path calls. + const getProfileSpy = vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(() => { // 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. @@ -451,8 +449,6 @@ function stallProviderSettingsProfile(provider: ClineProvider) { } 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. @@ -1535,13 +1531,11 @@ describe("ClineProvider", () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) const logSpy = vi.spyOn(provider, "log") - const getProfileSpy = vi.fn().mockResolvedValue({ + const getProfileSpy = vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ name: "my-profile", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x", }) - // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. - provider.providerSettingsManager = { getProfile: getProfileSpy } await provider.contextProxy.setValue( "viewStates", mockContext.globalState.get("viewStates"), @@ -1570,8 +1564,7 @@ describe("ClineProvider", () => { 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)) 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")) } + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue(new Error("profile missing")) await provider.saveViewState("currentApiConfigName", "my-profile") await provider["setViewStateId"]("stable-sidebar-view") expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") @@ -1583,13 +1576,10 @@ describe("ClineProvider", () => { 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)) 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 }) - }), - } + vi.spyOn(provider.providerSettingsManager, "getProfile").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") @@ -1636,14 +1626,11 @@ describe("ClineProvider", () => { await writer.saveViewState("currentApiConfigName", "my-profile") const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) - // @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", - }), - } + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) // A pre-load buffer write that was never persisted must not be merged over the load. provider["viewLocalState"] = { mode: "architect" } await provider.contextProxy.setValue( @@ -1664,8 +1651,7 @@ describe("ClineProvider", () => { it("should not restore a persisted mode whose custom mode no longer exists", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) 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() } + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) // 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") @@ -1879,8 +1865,7 @@ describe("ClineProvider", () => { it("should clear viewLocalState and the persisted entry when resetting state", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) 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() } + vi.spyOn(provider.customModesManager, "resetCustomModes").mockResolvedValue(undefined) // 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 @@ -1910,17 +1895,14 @@ describe("ClineProvider", () => { } const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) - // @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.providerSettingsManager, "activateProfile").mockResolvedValue(profile) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([profile]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.anthropic, + }) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.contextProxy.setValue( "viewStates", @@ -1945,12 +1927,9 @@ describe("ClineProvider", () => { 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.providerSettingsManager, "saveConfig").mockResolvedValue("fresh-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([profile]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.setValue("currentApiConfigName", "stale-profile") @@ -2066,15 +2045,12 @@ describe("ClineProvider", () => { openRouterApiKey: "deleted-profile-secret", } vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) - // @ts-ignore - Replace providerSettingsManager with a test double. - provider.providerSettingsManager = { - getProfile: vi.fn().mockResolvedValue({ - name: "keeper-profile", - id: "keeper-id", - apiProvider: providerIdentifiers.anthropic, - }), - deleteConfig: vi.fn().mockResolvedValue(undefined), - } + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") await provider.deleteProviderProfile(oldProfile) @@ -2122,15 +2098,12 @@ describe("ClineProvider", () => { 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), - } + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "other-profile", + id: "other-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) const setProviderSettingsSpy = vi .spyOn(provider.contextProxy, "setProviderSettings") .mockResolvedValue(undefined) @@ -2254,16 +2227,10 @@ describe("ClineProvider", () => { 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)) - // @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.providerSettingsManager, "saveConfig").mockResolvedValue("activated-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "activated-profile", id: "activated-id", apiProvider: providerIdentifiers.anthropic }, ]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.upsertProviderProfile( @@ -3134,8 +3101,7 @@ describe("ClineProvider", () => { saveConfig: vi.fn(async () => "legacy-id"), dispose: vi.fn(), } - // @ts-ignore - Replace customModesManager with a test double (no custom modes). - provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) // The view's own pin points at a deleted profile; the shared global selection is still valid. await provider.saveViewState("currentApiConfigName", "gone-pin") await provider.contextProxy.setValue("currentApiConfigName", "global-valid") From c3fb9e9a7344e96b83d09a6bdf3ab39a60852057 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech Date: Tue, 6 Oct 2026 10:31:16 +0800 Subject: [PATCH 29/65] fix(webview): treat a view-local profile as a complete snapshot, not a patch getState() spread the pinned profile over the shared provider settings, so any key the pinned profile never defined came from the shared profile instead - another profile's base URL, model temperature, rate limit, or secret. ContextProxy.setProviderSettings deliberately clears omitted keys so that mixing cannot happen; the overlay skipped that rule. The view's own settings are now used in place, with only apiProvider taken from the shared state when the profile does not carry one. Tests: 252 passed in ClineProvider.spec.ts; ESLint clean with --max-warnings=0. --- src/core/webview/ClineProvider.ts | 18 ++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 00fde8ff1e..ba8824e36c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3575,11 +3575,21 @@ 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. + const viewApiConfiguration = mergedStateValues.apiConfiguration + const resolvedApiConfiguration = viewApiConfiguration + ? { + ...viewApiConfiguration, + apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider, + } + : providerSettings + return { - apiConfiguration: { - ...providerSettings, - ...mergedStateValues.apiConfiguration, - }, + apiConfiguration: resolvedApiConfiguration, lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, customInstructions: mergedStateValues.customInstructions, apiModelId: mergedStateValues.apiModelId, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index fc1f57269d..72a0d0a383 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2255,6 +2255,30 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("does not fill a pinned profile's omitted keys from the shared profile", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // The pinned profile defines only its provider and key. + await provider.saveViewState("currentApiConfigName", "pinned-profile") + await provider.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterApiKey: "pinned-key", + }) + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.openai, + openAiApiKey: "shared-key", + openAiBaseUrl: "https://shared.example", + }) + + const state = await provider.getState({ includeTaskHistory: false }) + + // A view-local profile is a complete snapshot, not a patch: a key the profile + // never defined must not leak in from the shared profile. + expect(state.apiConfiguration.openRouterApiKey).toBe("pinned-key") + expect(state.apiConfiguration).not.toHaveProperty("openAiBaseUrl") + expect(state.apiConfiguration).not.toHaveProperty("openAiApiKey") + 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)) // Seed the per-view buffer with profile A's settings, as loadViewState would From 0657506ccd7c2fa20a89c35f6c973d84d0cb9681 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 14:58:48 +0800 Subject: [PATCH 30/65] fix(webview): validate the webview-supplied view state id before adopting it The stable view state id arrives over the webview postMessage channel, so the declared string type is not a guarantee: a crafted message could carry a non-string value (which the sanitizer would crash on), an unbounded string (unbounded growth in the shared viewStates map), or an id another live view already owns (adopting it would load that view's pinned profile, overlay and mode here, and let this webview overwrite that view's persisted entry). setViewStateId now rejects non-strings, bounds the key length, and refuses ids claimed by another live instance; a refused view stays on its temporary id and keeps working against the shared state. Proofs: ClineProvider.sticky-mode.spec 32/32 (3 new tests: non-string, oversized, claimed-by-another-view); negative controls: disabling the ownership check reddens exactly the claim test, widening the length bound reddens exactly the oversized test, restores byte-exact (hash match); eslint --max-warnings=0 clean; tsc clean except the two known local telemetry-dist noise lines; prettier ALL-MATCHED on touched files. --- src/core/webview/ClineProvider.ts | 37 ++++++++++++++++++- .../ClineProvider.sticky-mode.spec.ts | 35 ++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 101ad87a83..335eb8665c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -215,6 +215,7 @@ export class ClineProvider 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[] = [] @@ -704,7 +705,18 @@ export class ClineProvider * The identifier is sanitized so it remains a safe object key in the shared viewStates map. */ public async setViewStateId(viewStateId: string | undefined): Promise { - const normalizedViewStateId = viewStateId?.trim().replace(/[^A-Za-z0-9_-]/g, "_") + // The webview generates this id on purpose - it has to survive a webview reload, + // which recreates the view on both sides - so the host cannot mint it instead. + // What the host owns is which view may hold a given persisted key, so everything + // below validates the supplied value before it becomes one. + if (typeof viewStateId !== "string") { + // A crafted postMessage can carry any JSON value here even though the message + // type declares a string; the persisted key must stay a string. + this.log(`[setViewStateId] Ignoring a non-string viewStateId for view ${this.viewId}.`) + return + } + + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") if ( !normalizedViewStateId || @@ -716,6 +728,29 @@ export class ClineProvider return } + // Bound the key: the webview generates a UUID, so a much longer value is not a + // view id, and an unbounded key is unbounded growth in the shared viewStates map. + if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { + this.log( + `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, + ) + return + } + + const claimant = ClineProvider.getAllInstances().find( + (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, + ) + if (claimant) { + // Ownership check: a key another live view holds must not be adopted. Doing so + // would load that view's pinned profile, nested overlay, and mode into this + // provider, and this webview could then overwrite that view's persisted entry. + // Staying on the temporary id keeps this view working against the shared state. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, + ) + return + } + const previousViewStateId = this.viewStateId this.viewStateId = normalizedViewStateId diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index b0b37c251d..e0ec506ce2 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(), } }, }, @@ -1682,4 +1684,37 @@ describe("ClineProvider - Sticky Mode", () => { 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() + } + }) + }) }) From 16a9ba2ca7103e09927dd3132373b75c42629c76 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 16:01:41 +0800 Subject: [PATCH 31/65] test(webview): cover non-activating-save refresh and overlay completeness The get-state unit's review round produced two regression tests; this unit already implements both behaviors (non-activating saves refresh pinned views, and the pinned overlay is used as a complete profile snapshot), so the tests land here without a code change. The cloud instance doubles gain on/off handlers because provider dispose detaches the settings listener against the getter those tests leave in place. Proofs: ClineProvider.spec 285/285 (2 new tests pass against the shipped behavior); negative controls on the behaviors under test: dropping the else-branch refresh reddens exactly the refresh test, reverting the merge semantics reddens the leak test (and the existing overlay test), restores byte-exact (hash match); eslint --max-warnings=0 clean; tsc clean except the two known local telemetry-dist noise lines; prettier clean. --- .../webview/__tests__/ClineProvider.spec.ts | 122 +++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b32b8f16e7..cfba0cab83 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -496,6 +496,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() @@ -2123,6 +2130,10 @@ describe("ClineProvider", () => { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x", }) + // 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"), @@ -2135,7 +2146,6 @@ describe("ClineProvider", () => { }) expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) - await writer.dispose() await provider.dispose() }) @@ -2256,6 +2266,10 @@ describe("ClineProvider", () => { }) // 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"), @@ -2267,7 +2281,6 @@ describe("ClineProvider", () => { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x", }) - await writer.dispose() await provider.dispose() }) @@ -2652,6 +2665,10 @@ describe("ClineProvider", () => { 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"), @@ -2664,7 +2681,6 @@ describe("ClineProvider", () => { // The buffer must track the activated profile so getValues() agrees with the proxy. expect(provider.getValues().currentApiConfigName).toBe("new-profile") expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile") - await writer.dispose() await provider.dispose() }) @@ -6579,6 +6595,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 @@ -6597,6 +6618,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 @@ -6638,6 +6664,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 @@ -8247,4 +8278,89 @@ 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) + 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(), + getProfile: vi + .fn() + .mockResolvedValue({ name: "saved-profile", apiProvider: providerIdentifiers.anthropic }), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + + 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", + }) + // Let queued view-local persistence settle while the console spies are + // still installed, so teardown has no pending writes. + await new Promise((resolve) => setTimeout(resolve, 50)) + } finally { + await sibling.dispose() + } + }) + + 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)) + }) + }) }) From 52df09c6703a39e68ab8fed5bfec980de2c82d61 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 16:28:21 +0800 Subject: [PATCH 32/65] fix(webview): read the view overlay from the buffer, not the merged state The overlay-completeness fix read the overlay from the merged state values, which also carry the raw apiConfiguration state key. That key is a partial patch written by shared-store paths, not a view snapshot: when a view had no overlay but the raw key held a partial object, getState() served only that partial object and dropped the shared provider settings the previous merge produced (activation tests caught exactly this: the activated profile's model id vanished). Read the overlay from the view buffer itself, where a present value is by contract a complete profile and an absent one means the shared store serves the settings. Proofs: ClineProvider.parallelMode.spec green (the six activation/upsert/ delete synchronization tests that regressed), ClineProvider.spec green including the overlay-completeness tests, sticky-mode and webviewMessageHandler green; negative control: reverting the overlay to a shallow merge over the shared settings still reddens exactly the leak test; restores byte-exact (hash match); eslint --max-warnings=0 clean; prettier clean; tsc clean except the two known local telemetry-dist noise lines. --- src/core/webview/ClineProvider.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 335eb8665c..c5ca28d4ac 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3661,7 +3661,9 @@ export class ClineProvider // 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. - const viewApiConfiguration = mergedStateValues.apiConfiguration + // 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, From aa1fb114e47cf855c0f8d8e9c98e793c76dab36d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 17:10:15 +0800 Subject: [PATCH 33/65] fix(webview): rebuild active task handlers when a pinned profile is saved A non-activating save refreshed the pinned views' buffers but not their active tasks' API handlers, and the posted state prefers the task's own apiConfiguration: a conversation running in a view pinned to the saved profile kept sending the old token (e.g. after the Zoo Gateway callback rotated it) even though the view's buffer had caught up. The refresh now rebuilds the affected tasks' handlers in the sibling views, and the non-activating branch rebuilds this view's own handler when it holds the pin. Proofs: ClineProvider.spec 258/258 (the pinned-sibling refresh test now asserts the active task's handler is rebuilt with the saved settings, not just the private buffer); negative control: dropping the rebuild from the refresh reddens exactly that assertion, restores byte-exact (hash match); sticky-mode 32/32, webviewMessageHandler 82/82; eslint --max-warnings=0 clean; prettier clean. --- src/core/webview/ClineProvider.ts | 8 ++++++++ .../webview/__tests__/ClineProvider.spec.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c5ca28d4ac..07127d27b3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2418,6 +2418,7 @@ export class ClineProvider // serving the settings captured before the save under the same name. if (this.pinnedProfileName === name) { await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } @@ -2663,6 +2664,13 @@ export class ClineProvider 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. Direct private access across sibling + // instances of the same class is compile-time safe. + instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index cfba0cab83..131548b527 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -8301,6 +8301,17 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { 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" }, + 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 = { @@ -8325,6 +8336,13 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { 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", + }) // Let queued view-local persistence settle while the console spies are // still installed, so teardown has no pending writes. await new Promise((resolve) => setTimeout(resolve, 50)) From 7b9f12b63ffe10d40b7cbc9646d2105b7a16e0b5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 17:52:38 +0800 Subject: [PATCH 34/65] test: fix describe nesting, strengthen prune assertion, make stale-entry check deterministic, restore timers in finally Review round on this unit's test quality: the viewStates describe block was nested inside the alwaysDenyUnapprovedCommands block, so reported test names carried the wrong subject; the prune test asserted survival with toDefined(), which passes even if pruning strips fields - the surviving entries are fully known, so assert their exact shape (matching the parallel-mode suite); the stale-temporary-entry test waited on a fixed 10 ms timer, so a slow load could let the negative assertion pass vacuously - await the load itself; the readiness-cancel test left fake timers active when an assertion failed before the restore line, hanging a later real-timer test - restore in a finally block. Proofs: global-settings 9/9, ClineProvider.spec 294/294, sticky-mode 33/33, api-task-control 23/23; negative controls: pruning that strips updatedAt reddens exactly the prune test, a loadViewState that adopts the unowned temporary-id entry reddens exactly the stale-entry test, restores byte-exact (hash match); eslint --max-warnings=0 clean (src and types); prettier clean. --- src/core/webview/__tests__/ClineProvider.spec.ts | 4 ++-- src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 131548b527..baa61d3d60 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2079,8 +2079,8 @@ describe("ClineProvider", () => { const pruned = provider["prunePersistedViewStates"](states) expect(Object.keys(pruned)).toHaveLength(50) // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. - expect(pruned["view-1"]).toBeDefined() - expect(pruned["view-54"]).toBeDefined() + expect(pruned["view-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() }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index e0ec506ce2..b6e4a7019a 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1613,8 +1613,9 @@ describe("ClineProvider - Sticky Mode", () => { const fresh = new ClineProvider(mockContext, mockOutputChannel, "sidebar", proxy, new WebviewFocusTracker()) expect(fresh.viewId).toBe(tempId) - // Let the constructor's fire-and-forget loadViewState settle. - await new Promise((resolve) => setTimeout(resolve, 10)) + // 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({}) From 8f6749a301016ff6ff4c443d2709e4442413ccb5 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:19:47 +0800 Subject: [PATCH 35/65] fix: rebuild re-pinned siblings' tasks on profile deletion Review-round fix on the shared code this unit carries: rePinViewLocalStateForDeletedProfile updated each sibling's buffer but not its running task: the posted state prefers the task's own apiConfiguration, so a conversation in a view pinned to the deleted profile kept sending the deleted profile's settings - the re-pin now rebuilds the sibling's API handler with the replacement's settings and re-points the task's sticky profile name, mirroring the activation path. Tested by the sibling re-pin assertions in the unit that owns the parallel mode suite; this unit's suites confirm the port is behavior-preserving for its own coverage. Proofs: ClineProvider.spec and sticky-mode green; eslint --max-warnings=0 clean; prettier clean. --- src/core/webview/ClineProvider.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 07127d27b3..ecf3406c30 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2708,6 +2708,16 @@ export class ClineProvider // 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). + if (replacementSettings) { + instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + } + await instance.persistStickyProviderProfileToCurrentTask(replacementName) await instance.postStateToWebview() }), ) From 279e9da615159ea290309ef94aedbab21d77da92 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 21:37:04 +0800 Subject: [PATCH 36/65] fix(webview): scope profile-save task rebuilds to the task's own profile Port of the sibling-unit fix: a non-activating profile save force-rebuilt the API handler of every view pinned to the saved profile, including views whose active task follows a different sticky profile. Both rebuild sites (the acting view's pinned branch and the pinned-sibling refresh) are now guarded by a taskApiConfigName match; the pinned view's overlay still refreshes. Regression tests cover both sites. Test hygiene: prune assertions compare full kept entries; the two re-key storage tests seed viewStates through the durable write path so their assertions can fail; webview state-payload test renamed. --- src/core/webview/ClineProvider.ts | 18 +++- .../webview/__tests__/ClineProvider.spec.ts | 100 +++++++++++++++++- .../ClineProvider.sticky-mode.spec.ts | 16 +-- .../__tests__/ExtensionStateContext.spec.tsx | 2 +- 4 files changed, 121 insertions(+), 15 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ecf3406c30..18ac96defc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2418,7 +2418,13 @@ export class ClineProvider // serving the settings captured before the save under the same name. if (this.pinnedProfileName === name) { await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + // 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(providerSettings, { forceRebuild: true }) + } } await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) } @@ -2668,9 +2674,13 @@ export class ClineProvider // 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. Direct private access across sibling - // instances of the same class is compile-time safe. - instance.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + // 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() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index baa61d3d60..6d576f408b 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1865,8 +1865,8 @@ describe("ClineProvider", () => { const pruned = provider["prunePersistedViewStates"](states) expect(Object.keys(pruned)).toHaveLength(50) - expect(pruned["view-54"]).toBeDefined() - expect(pruned["view-5"]).toBeDefined() + 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() @@ -8307,6 +8307,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // 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( @@ -8351,6 +8352,101 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { } }) + 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() + // Let queued view-local persistence settle while the console spies are + // still installed, so teardown has no pending writes. + await new Promise((resolve) => setTimeout(resolve, 50)) + } 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(() => {}) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index b6e4a7019a..247a6bfc07 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1579,10 +1579,10 @@ describe("ClineProvider - Sticky Mode", () => { it("does not resolve a view id that names an Object.prototype key through the chain", async () => { await provider.resolveWebviewView(mockWebviewView) - mockContext.globalState.get = vi.fn().mockImplementation((key: string) => { - if (key === "viewStates") return { "stable-test-view": { mode: "architect" } } - return undefined - }) + // 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() @@ -1673,10 +1673,10 @@ describe("ClineProvider - Sticky Mode", () => { // 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. - mockContext.globalState.get = vi.fn().mockImplementation((key: string) => { - if (key === "viewStates") return { [provider.viewId]: { mode: "architect" } } - return undefined - }) + // 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") diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 572363e567..0b089c149b 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -168,7 +168,7 @@ describe("ExtensionStateContext", () => { } }) - it("reseeds view-local mode and API profile from a new state payload after local edits", () => { + it("applies mode and profile from a later state payload after local edits", () => { render( From 23838ac7d06ce9eac7d469b43d95aafc43a1493d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 22:41:08 +0800 Subject: [PATCH 37/65] fix(webview): gate deleted-profile re-pin task writes on the task's own profile A view pinned to a deleted profile can hold a task that follows a different sticky profile: the deletion re-pin must re-point the view buffer but must not force the replacement's settings onto that conversation's handler, nor rewrite its persisted sticky profile name. Both task-side writes are now gated on a taskApiConfigName match with the deleted profile, mirroring the profile-save rebuild guards. Regression test included. --- src/core/webview/ClineProvider.ts | 14 +++- .../webview/__tests__/ClineProvider.spec.ts | 83 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 18ac96defc..87603ce25d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2723,11 +2723,17 @@ export class ClineProvider // 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). - if (replacementSettings) { - instance.updateTaskApiHandlerIfNeeded(replacementSettings, { forceRebuild: true }) + // 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.persistStickyProviderProfileToCurrentTask(replacementName) await instance.postStateToWebview() }), ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6d576f408b..9d9423b6ab 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2864,6 +2864,89 @@ describe("ClineProvider", () => { 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() + // Let queued view-local persistence settle while the console spies are + // still installed, so teardown has no pending writes. + await new Promise((resolve) => setTimeout(resolve, 50)) + } 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, From 89f59689c023a8f36e40828250a19bef049372c8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 14:27:21 +0800 Subject: [PATCH 38/65] fix: clear newly-unused bindings surfaced by main unused-vars re-enable --- src/core/config/__tests__/ProviderSettingsManager.spec.ts | 7 +------ .../webview/__tests__/ClineProvider.sticky-mode.spec.ts | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 16b7c75527..3dafb00279 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,12 +12,7 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { - ProviderSettingsManager, - ProviderSettingsNotFoundError, - ProviderProfiles, - SyncCloudProfilesResult, -} 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 diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 1d806d3da0..564ea4bc3e 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1654,7 +1654,7 @@ describe("ClineProvider - Sticky Mode", () => { expect(fresh.viewId).toBe(tempId) const originalUpdate = mockContext.globalState.update - mockContext.globalState.update = vi.fn().mockImplementation((key: string, value: unknown) => { + mockContext.globalState.update = vi.fn().mockImplementation((key: string, _value: unknown) => { if (key === "viewStates") { return Promise.reject(new Error("storage unavailable")) } From fcb2791947d51d9a195c3453904b10934f03f0e6 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 18:34:06 +0800 Subject: [PATCH 39/65] chore: drop Stryker suppression comments per AGENTS.md policy --- src/activate/registerCommands.ts | 4 ---- src/core/webview/webviewMessageHandler.ts | 2 -- webview-ui/src/utils/vscode.ts | 2 -- 3 files changed, 8 deletions(-) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 6c8f2e18cc..91388e488e 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -318,7 +318,6 @@ export const openClineInNewTab = async (options: OpenClineInNewTabOptions) => { // holds this in-flight creation, every other caller receives that // same promise (guard above), so no replacement can be stored before // this finally block runs — the equality check pins the invariant. - // Stryker disable next-line ConditionalExpression: defensive clobber guard, unreachable per the ordering argument above. if (pendingTabPanelCreation === creation) { pendingTabPanelCreation = undefined } @@ -382,7 +381,6 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac }) // Save as tab type panel. - // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment setPanel(newPanel, "tab") // TODO: Use better svg icon with light and dark variants (see @@ -403,7 +401,6 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac // only the active one is the current tab, and the title-bar // commands must resolve that instance, not the last created one. if (panel.active) { - // Stryker disable next-line StringLiteral: setPanel only distinguishes "sidebar"; any other value routes to the tab-ref assignment setPanel(panel, "tab") } if (panel.visible) { @@ -420,7 +417,6 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac newPanel.onDidDispose( () => { if (tabPanel === newPanel) { - // Stryker disable next-line StringLiteral: setPanel branches only on type === "sidebar", so any other literal routes to the identical tab-ref assignment setPanel(undefined, "tab") } }, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dd7a513612..ff336caf5d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1605,9 +1605,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 } diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 05dd380dfe..fc0f035de9 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -103,7 +103,6 @@ export class VSCodeAPIWrapper { } try { - // Stryker disable next-line ConditionalExpression,OptionalChaining: equivalent mutant - when localStorage is unavailable the guard-false path and the throwing body both return this.fallbackState from this catch if (typeof localStorage?.getItem === "function") { const state = localStorage.getItem("vscodeState") return state ? JSON.parse(state) : this.fallbackState @@ -134,7 +133,6 @@ export class VSCodeAPIWrapper { this.fallbackState = newState try { - // Stryker disable next-line ConditionalExpression,OptionalChaining: equivalent mutant - when localStorage is unavailable the guard-false path and the throwing body both mark the write failed and return newState if (typeof localStorage?.setItem === "function") { localStorage.setItem("vscodeState", JSON.stringify(newState)) this.storageWriteFailed = false From 2550529e4d6f824210e8960a335d12fad7b17251 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 14 Oct 2026 20:45:02 +0800 Subject: [PATCH 40/65] test: await serialized view-state writes and global-state seeds deterministically --- .../webview/__tests__/ClineProvider.spec.ts | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b22b92af51..7e7a71602f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1960,7 +1960,7 @@ describe("ClineProvider", () => { new WebviewFocusTracker(), ) // A string in storage is corrupt: the fresh-read guard must not spread it. - mockContext.globalState.update("viewStates", "corrupted-storage-value") + await mockContext.globalState.update("viewStates", "corrupted-storage-value") expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) await provider.dispose() }) @@ -2060,7 +2060,7 @@ describe("ClineProvider", () => { await provider["setViewStateId"]("stable-sidebar-view") // The cache only knows this view's entry; storage gains an extra view directly. await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) - mockContext.globalState.update("viewStates", { + await mockContext.globalState.update("viewStates", { "stable-sidebar-view": { mode: "architect" }, "stable-editor-view": { mode: "code" }, }) @@ -2945,9 +2945,11 @@ describe("ClineProvider", () => { // nor the sticky-name rewrite may touch it. expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() expect(siblingTask.setTaskApiConfigName).not.toHaveBeenCalled() - // Let queued view-local persistence settle while the console spies are - // still installed, so teardown has no pending writes. - await new Promise((resolve) => setTimeout(resolve, 50)) + // 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() @@ -8500,9 +8502,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key", }) - // Let queued view-local persistence settle while the console spies are - // still installed, so teardown has no pending writes. - await new Promise((resolve) => setTimeout(resolve, 50)) + // 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() } @@ -8562,9 +8566,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // ...but the task runs under another profile: its handler must not be // switched to the saved profile's settings. expect(siblingTask.updateApiConfiguration).not.toHaveBeenCalled() - // Let queued view-local persistence settle while the console spies are - // still installed, so teardown has no pending writes. - await new Promise((resolve) => setTimeout(resolve, 50)) + // 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() } From 554ceae0dbc745e52a5c42590e18786d7be5da0c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 11:59:01 +0800 Subject: [PATCH 41/65] fix: re-point the tracked tab ref on disposal and replace the remaining test-double swaps Track live tab panels in a module-level set so the dispose handler re-points the tracked ref at the best remaining panel (active, then visible, then any) instead of leaving the tab-scoped commands without a target. Replace the six remaining @ts-ignore readonly-manager swaps with typed spies, and give the webviewDidLaunch suite its own snapshot/afterEach restoring all six reassigned members. --- .../__tests__/registerCommands.spec.ts | 166 +++++++++++++++++- src/activate/registerCommands.ts | 39 +++- .../webview/__tests__/ClineProvider.spec.ts | 62 +++---- .../__tests__/webviewMessageHandler.spec.ts | 72 ++++---- 4 files changed, 272 insertions(+), 67 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index 7fc1efda11..e2b11d1e06 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -8,7 +8,14 @@ import { ContextProxy } from "../../core/config/ContextProxy" import { ClineProvider } from "../../core/webview/ClineProvider" import { MdmService } from "../../services/mdm/MdmService" -import { getPanel, getVisibleProviderOrLog, openClineInNewTab, registerCommands, setPanel } from "../registerCommands" +import { + __resetLiveTabPanelsForTests, + getPanel, + getVisibleProviderOrLog, + openClineInNewTab, + registerCommands, + setPanel, +} from "../registerCommands" vi.mock("execa", () => ({ execa: vi.fn(), @@ -194,6 +201,7 @@ describe("registerCommands handlers", () => { // Reset module-level panel state to prevent leakage between tests. setPanel(undefined, "sidebar") setPanel(undefined, "tab") + __resetLiveTabPanelsForTests() }) it("registers the ripgrep diagnostic command and stores its disposable in context.subscriptions", async () => { @@ -672,6 +680,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 () => { @@ -813,6 +822,161 @@ describe("openClineInNewTab", () => { expect(mockProviderA.postMessageToWebview).toHaveBeenCalledWith({ type: "action", action: "focusInput" }) }) + it("re-points the tracked tab ref at the remaining live panel when the tracked panel closes", async () => { + // A preceding test's instance-lookup double must not leak into the reuse + // path: both opens here must create fresh panels. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + + // 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("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { ;(MdmService.getInstance as Mock).mockImplementation(() => { throw new Error("MDM service not initialized") diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 91388e488e..4e36dc74a8 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -30,6 +30,30 @@ 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. + */ +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. + */ +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 @@ -382,6 +406,7 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac // 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). @@ -416,8 +441,20 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac // panel cannot clobber the replacement's ref. newPanel.onDidDispose( () => { + liveTabPanels.delete(newPanel) if (tabPanel === newPanel) { - setPanel(undefined, "tab") + // 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, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7e7a71602f..df5f274077 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2457,8 +2457,7 @@ describe("ClineProvider", () => { 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() } + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) await provider.saveViewState("mode", "code") await provider.saveViewState("currentApiConfigName", "cfg-a") const load = provider["setViewStateId"]("stable-sidebar-view") @@ -2487,8 +2486,7 @@ describe("ClineProvider", () => { 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() } + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) // 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") @@ -4176,15 +4174,16 @@ describe("ClineProvider", () => { new ContextProxy(mockContext), new WebviewFocusTracker(), ) - // @ts-ignore - Replace providerSettingsManager with a test double: the view's pinned config no - // longer exists, the shared global selection is still valid, and the only listed profile is a - // legacy entry without a name. - provider.providerSettingsManager = { - hasConfig: vi.fn(async (name: string) => name === "global-valid"), - listConfig: vi.fn(async () => [{ id: "legacy-id", apiProvider: providerIdentifiers.openai }]), - saveConfig: vi.fn(async () => "legacy-id"), - dispose: vi.fn(), - } + // The view's pinned config no longer exists, the shared global selection is + // still valid, and the only listed profile is a legacy entry without a name + // (the cast keeps that intentionally missing field from widening the double). + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockImplementation( + async (name: string) => name === "global-valid", + ) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { id: "legacy-id", apiProvider: providerIdentifiers.openai }, + ] as ProviderSettingsEntry[]) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("legacy-id") vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) // The view's own pin points at a deleted profile; the shared global selection is still valid. await provider.saveViewState("currentApiConfigName", "gone-pin") @@ -8472,16 +8471,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { 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(), - getProfile: vi - .fn() - .mockResolvedValue({ name: "saved-profile", apiProvider: providerIdentifiers.anthropic }), - getModeConfigId: vi.fn().mockResolvedValue(undefined), - } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("saved-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + id: "saved-id", + name: "saved-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) await provider.upsertProviderProfile( "saved-profile", @@ -8545,12 +8543,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { 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(), - } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("saved-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) await provider.upsertProviderProfile( "saved-profile", @@ -8590,12 +8585,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { ) 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(), - } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("saved-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) await provider.upsertProviderProfile( "saved-profile", diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index fdb89c1007..ed39e384ed 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -144,6 +144,31 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +// The webviewDidLaunch suite replaces these mockClineProvider members with +// per-test doubles. Snapshot the module-level originals at collection time and +// restore them in that suite's own afterEach, so the launch stubs never leak +// into the suites that run after it (vitest runs suites in file order). +// Single structural cast: the class types these members as methods / readonly +// properties, which cannot be re-assigned to swap in a per-test double. +const launchSuiteSnapshot = (() => { + const view = mockClineProvider as { + setViewStateId: unknown + workspaceTracker: unknown + providerSettingsManager: unknown + activateProviderProfile: unknown + getMcpHub: unknown + getStateToPostToWebview: unknown + } + return { + setViewStateId: view.setViewStateId, + workspaceTracker: view.workspaceTracker, + providerSettingsManager: view.providerSettingsManager, + activateProviderProfile: view.activateProviderProfile, + getMcpHub: view.getMcpHub, + getStateToPostToWebview: view.getStateToPostToWebview, + } +})() + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { @@ -398,6 +423,23 @@ describe("webviewMessageHandler - webviewDidLaunch", () => { // ... and marks the view as launched. expect(mockClineProvider.isViewLaunched).toBe(true) }) + + afterEach(() => { + const view = mockClineProvider as { + setViewStateId: unknown + workspaceTracker: unknown + providerSettingsManager: unknown + activateProviderProfile: unknown + getMcpHub: unknown + getStateToPostToWebview: unknown + } + view.setViewStateId = launchSuiteSnapshot.setViewStateId + view.workspaceTracker = launchSuiteSnapshot.workspaceTracker + view.providerSettingsManager = launchSuiteSnapshot.providerSettingsManager + view.activateProviderProfile = launchSuiteSnapshot.activateProviderProfile + view.getMcpHub = launchSuiteSnapshot.getMcpHub + view.getStateToPostToWebview = launchSuiteSnapshot.getStateToPostToWebview + }) }) describe("webviewMessageHandler - requestLmStudioModels", () => { @@ -2344,25 +2386,6 @@ 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 @@ -2531,15 +2554,4 @@ 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 - }) }) From e606e6b1277c5855c7e4b9382e52e78a84e06f80 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 13:15:01 +0800 Subject: [PATCH 42/65] fix: register tab disposal listeners before initialization and validate payload custom modes Move the visibility and disposal listeners ahead of the resolveWebviewView await and drop the half-registered panel from the live registry on initialization failure, so a close that lands mid-init cannot leave the panel tracked. Validate setValues mode against custom modes carried in the same payload so createTask/setConfiguration payloads do not drop a brand-new custom mode. --- .../__tests__/registerCommands.spec.ts | 106 ++++++++++++++++++ src/activate/registerCommands.ts | 24 +++- src/core/webview/ClineProvider.ts | 8 +- .../webview/__tests__/ClineProvider.spec.ts | 35 ++++++ 4 files changed, 170 insertions(+), 3 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index e2b11d1e06..c86dade03f 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -9,6 +9,7 @@ import { ClineProvider } from "../../core/webview/ClineProvider" import { MdmService } from "../../services/mdm/MdmService" import { + __getLiveTabPanelCountForTests, __resetLiveTabPanelsForTests, getPanel, getVisibleProviderOrLog, @@ -977,6 +978,111 @@ describe("openClineInNewTab", () => { expect(getPanel()).toBe(panelH1) }) + 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")) + // The real provider dispose disposes the panel, which fires the disposal + // handler installed by createClineTabPanel; the double mirrors that + // contract so the tracked-ref cleanup runs the production path. + let disposeHandler: (() => void) | undefined + const failingPanel = Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn((handler: () => void) => { + disposeHandler = handler + }), + dispose: vi.fn(() => disposeHandler?.()), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue(failingPanel) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockImplementation(async () => { + failingPanel.dispose() + }) + + 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 and the provider is disposed - disposing the provider + // disposes the panel, whose disposal handler then clears the tracked + // ref (no other live panel remains here). + expect(__getLiveTabPanelCountForTests()).toBe(0) + expect(getPanel()).toBeUndefined() + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) + + it("re-points the tracked tab at the surviving panel when a new panel's initialization fails", async () => { + // clearAllMocks() keeps mock implementations: reset the instance-lookup + // return value so a preceding test's tab double cannot leak into the + // creation path below. + ;(ClineProvider.getInstanceForView as Mock).mockReturnValue(undefined) + // Panel A opens fully and becomes the tracked tab. + const panelA = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-A", + active: true, + visible: true, + 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(), + }) + expect(getPanel()).toBe(panelA) + + // Panel B's initialization fails. Its provider dispose mirrors the real + // contract: disposing the provider disposes the panel, which fires the + // disposal handler installed by createClineTabPanel. + let disposeHandlerB: (() => void) | undefined + const panelB = Object.assign({} as vscode.WebviewPanel, { + marker: "panel-B", + active: true, + visible: true, + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn((handler: () => void) => { + disposeHandlerB = handler + }), + dispose: vi.fn(() => disposeHandlerB?.()), + }) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValueOnce(panelB) + const resolveSpy = vi + .spyOn(ClineProvider.prototype, "resolveWebviewView") + .mockRejectedValueOnce(new Error("init failed")) + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockImplementation(async () => { + panelB.dispose() + }) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("init failed") + + // The failed panel leaves the live registry, and the tracked ref follows + // the disposal handler to the surviving panel A instead of being cleared + // up front - clearing it first would make the handler skip the re-point + // and leave the tab surface without a provider. + expect(__getLiveTabPanelCountForTests()).toBe(1) + expect(getPanel()).toBe(panelA) + expect(disposeSpy).toHaveBeenCalledTimes(1) + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { ;(MdmService.getInstance as Mock).mockImplementation(() => { throw new Error("MDM service not initialized") diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 4e36dc74a8..6744974b6b 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -415,8 +415,10 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac 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) => { @@ -461,6 +463,24 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac 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 dispose the provider so the failed surface leaves no + // listeners or state behind. Disposing the provider disposes the panel + // (resolveWebviewView assigns this.view before its first await), whose + // disposal listener (installed above) then re-points the tracked ref at + // the best remaining live panel - or clears it when none remains. + // Clearing the ref here first would make that handler skip the + // re-pointing and leave getTabProvider() undefined while another tab + // panel is still open. A user close that landed during initialization + // already ran that handler. + liveTabPanels.delete(newPanel) + 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/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7d9e8718f8..5ec03e5dec 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3904,9 +3904,15 @@ export class ClineProvider 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()) + !getModeBySlug(sanitizedValues.mode, [ + ...(await this.customModesManager.getCustomModes()), + ...(sanitizedValues.customModes ?? []), + ]) ) { this.log(`[ClineProvider#setValues] Ignoring invalid mode "${String(sanitizedValues.mode)}"`) delete sanitizedValues.mode diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index df5f274077..624919605f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3472,6 +3472,41 @@ describe("ClineProvider", () => { 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("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { const provider = new ClineProvider( mockContext, From b3f9247d99ff97652aa291990a55f68c04af5665 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 13:56:29 +0800 Subject: [PATCH 43/65] fix: deregister the provider in a finally path and preserve the init error A throwing MCP disposable inside dispose() aborted the method before the registry cleanup below it, leaving the provider registered while _disposed already blocked a retry; deregistration now runs in a finally path. The tab-init failure cleanup also nests the dispose await so a cleanup rejection can no longer replace the initialization error the caller sees. --- .../__tests__/registerCommands.spec.ts | 29 ++++++++++++++ src/activate/registerCommands.ts | 11 +++++- src/core/webview/ClineProvider.ts | 39 +++++++++++-------- .../webview/__tests__/ClineProvider.spec.ts | 23 +++++++++++ 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/activate/__tests__/registerCommands.spec.ts b/src/activate/__tests__/registerCommands.spec.ts index c86dade03f..96bd6631b0 100644 --- a/src/activate/__tests__/registerCommands.spec.ts +++ b/src/activate/__tests__/registerCommands.spec.ts @@ -1083,6 +1083,35 @@ describe("openClineInNewTab", () => { resolveSpy.mockRestore() disposeSpy.mockRestore() }) + + it("preserves the initialization error when provider cleanup also fails", async () => { + const resolveSpy = vi + .spyOn(ClineProvider.prototype, "resolveWebviewView") + .mockRejectedValue(new Error("init failed")) + // A throwing MCP disposable inside dispose() rejects the cleanup await; + // that rejection must not replace the initialization error the caller + // sees (deregistration itself already ran in dispose()'s finally path). + const disposeSpy = vi.spyOn(ClineProvider.prototype, "dispose").mockRejectedValue(new Error("cleanup boom")) + ;(vscode.window.createWebviewPanel as Mock).mockReturnValue( + Object.assign({} as vscode.WebviewPanel, { + webview: { postMessage: vi.fn() }, + onDidChangeViewState: vi.fn(), + onDidDispose: vi.fn(), + }), + ) + + await expect( + openClineInNewTab({ + context: mockContext, + outputChannel: mockOutputChannel, + webviewFocusTracker: new WebviewFocusTracker(), + }), + ).rejects.toThrow("init failed") + + resolveSpy.mockRestore() + disposeSpy.mockRestore() + }) + it("falls back to an undefined MdmService when MdmService.getInstance throws", async () => { ;(MdmService.getInstance as Mock).mockImplementation(() => { throw new Error("MDM service not initialized") diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 6744974b6b..9c52b22aeb 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -477,7 +477,16 @@ const createTabPanelUnlocked = async ({ context, outputChannel, webviewFocusTrac // panel is still open. A user close that landed during initialization // already ran that handler. liveTabPanels.delete(newPanel) - await tabProvider.dispose() + try { + await tabProvider.dispose() + } catch (cleanupError) { + // A cleanup rejection (e.g. an MCP client whose disposal throws) must + // not replace the initialization error the caller is about to see; + // dispose()'s finally path has already deregistered the provider. + tabProvider.log( + `Tab provider cleanup after failed init also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, + ) + } throw error } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5ec03e5dec..ed73868f68 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1268,22 +1268,29 @@ export class ClineProvider } } - 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) - - // Clean up any event listeners attached to this provider - this.removeAllListeners() - - McpServerManager.unregisterProvider(this) + try { + 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 the cleanup above completing: a + // throwing disposable (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() + + McpServerManager.unregisterProvider(this) + } } public get isViewVisible(): boolean { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 624919605f..ce839cccfd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3507,6 +3507,29 @@ describe("ClineProvider", () => { 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("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { const provider = new ClineProvider( mockContext, From 855720a68c5a15e29cdbfeee494fb2faacd407b9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 14:08:45 +0800 Subject: [PATCH 44/65] chore: retrigger CI (e2e-mock env timeout flake) From 912fff6a8ccb1cfd31636896b74b39464dda2ea5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 16:52:49 +0800 Subject: [PATCH 45/65] fix: restore the previous mode when custom-mode registration fails createTask persists the payload's mode through setValues before registering its custom modes; when updateCustomMode rejected, the unregistered mode stayed selected in shared and per-view state. The failure path now restores the previous selection in both layers before surfacing the original error. --- src/core/webview/ClineProvider.ts | 24 +++++++++++++++++-- .../webview/__tests__/ClineProvider.spec.ts | 24 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ed73868f68..d9eb7a44b3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4194,6 +4194,10 @@ export class ClineProvider configuration: RooCodeSettings = {}, ): Promise { if (configuration) { + // Capture the selection a registration failure must roll back to: + // setValues below persists the payload's mode to shared and per-view + // state while the payload's custom modes are still unregistered. + const previousMode = this.getValue("mode") await this.setValues(configuration) if (configuration.allowedCommands) { @@ -4227,8 +4231,24 @@ 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 selection to shared + // and per-view state; if registration fails the mode is not actually + // usable, so restore the previous selection before surfacing the + // failure. A failed restore is logged; the original failure is what + // callers see. + try { + await this.setValues({ mode: previousMode }) + } catch (restoreError) { + this.log( + `[ClineProvider#createTask] Failed to restore the previous mode after custom-mode registration failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + throw error } } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index ce839cccfd..b15689f40a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3530,6 +3530,30 @@ describe("ClineProvider", () => { expect(ClineProvider.getAllInstances()).not.toContain(provider) }) + it("restores the previous mode when custom-mode registration fails during createTask", async () => { + await provider.setValues({ 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") + + // 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") + }) + it("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { const provider = new ClineProvider( mockContext, From 094eea8b2abde7d043ea0286b126926a69c8d76f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 17:21:32 +0800 Subject: [PATCH 46/65] fix: read the prior mode through contextProxy in createTask single-open-invariant.spec.ts drives createTask against a provider double that carries contextProxy but not the getValue wrapper; reading the prior selection via contextProxy.getValue keeps the rollback capture working for both call shapes. --- src/core/webview/ClineProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d9eb7a44b3..66e5cecd16 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4197,7 +4197,7 @@ export class ClineProvider // Capture the selection a registration failure must roll back to: // setValues below persists the payload's mode to shared and per-view // state while the payload's custom modes are still unregistered. - const previousMode = this.getValue("mode") + const previousMode = this.contextProxy.getValue("mode") await this.setValues(configuration) if (configuration.allowedCommands) { From 4aff98f03e9e3d5539a09f5c0da8842953ed9c68 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 17:57:32 +0800 Subject: [PATCH 47/65] fix: roll back the shared mode and the view pin independently The registration-failure rollback wrote the shared previous mode into both layers, so a view pinned to a different mode lost its pin (and an unpinned view gained one). The failure path now restores the shared value via contextProxy and the view's own pin via saveViewState, each awaited and logged on failure, preserving an unset pin as unset. --- src/core/webview/ClineProvider.ts | 31 +++++++++++------ .../webview/__tests__/ClineProvider.spec.ts | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 66e5cecd16..2fdb07a46b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -4194,10 +4194,13 @@ export class ClineProvider configuration: RooCodeSettings = {}, ): Promise { if (configuration) { - // Capture the selection a registration failure must roll back to: - // setValues below persists the payload's mode to shared and per-view - // state while the payload's custom modes are still unregistered. + // 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) { @@ -4236,16 +4239,24 @@ export class ClineProvider await this.customModesManager.updateCustomMode(mode.slug, mode) } } catch (error) { - // setValues already persisted the payload's mode selection to shared - // and per-view state; if registration fails the mode is not actually - // usable, so restore the previous selection before surfacing the - // failure. A failed restore is logged; the original failure is what + // 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.setValues({ mode: previousMode }) - } catch (restoreError) { + await this.contextProxy.setValue("mode", previousMode) + } catch (rollbackError) { this.log( - `[ClineProvider#createTask] Failed to restore the previous mode after custom-mode registration failed: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + `[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.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b15689f40a..9d6126f4c4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3532,6 +3532,9 @@ describe("ClineProvider", () => { 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( @@ -3552,6 +3555,36 @@ describe("ClineProvider", () => { // 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("should sanitize raw viewStateId before using it as persisted viewStates key", async () => { From e39d8b88f07113560bcf0e785b89e246c300b8b0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Thu, 15 Oct 2026 22:12:54 +0800 Subject: [PATCH 48/65] fix(f1c): reject renderer-supplied viewStates writes and compensate failed profile activations --- src/core/webview/ClineProvider.ts | 143 ++++++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 40 +++++ .../__tests__/webviewMessageHandler.spec.ts | 17 +++ src/core/webview/webviewMessageHandler.ts | 8 + 4 files changed, 181 insertions(+), 27 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2fdb07a46b..9df5fc1582 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -203,6 +203,13 @@ type GetStateOptions = { includeTaskHistory?: boolean } +type ProfileActivationSnapshot = { + listApiConfigMeta: ProviderSettingsEntry[] | undefined + currentApiConfigName: string | undefined + providerSettings: RooCodeSettings + viewApiConfiguration: ProviderSettings | undefined +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -2341,6 +2348,55 @@ export class ClineProvider return !!this.getProviderProfileEntry(name) } + /** + * Prior state that the profile activation writes overwrite, captured before the + * concurrent write group so a failure can compensate the writes that landed. + */ + private _snapshotProfileActivationState(): ProfileActivationSnapshot { + return { + listApiConfigMeta: this.contextProxy.getValue("listApiConfigMeta"), + currentApiConfigName: this.contextProxy.getValue("currentApiConfigName"), + providerSettings: this.contextProxy.getValues(), + viewApiConfiguration: this.viewLocalState?.apiConfiguration, + } + } + + /** + * Compensate the profile activation writes after one of them failed. Every + * restore is awaited independently and logged: one failed restore must not + * strand the others. The caller rethrows the original failure. + */ + private async _restoreProfileActivationState(snapshot: ProfileActivationSnapshot): Promise { + try { + await this.updateGlobalState("listApiConfigMeta", snapshot.listApiConfigMeta) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the profile list: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + await this.setValue("currentApiConfigName", snapshot.currentApiConfigName) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the current profile selection: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + await this.contextProxy.setProviderSettings(snapshot.providerSettings) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the shared provider settings: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + await this._saveViewLocalStateFromMutation({ apiConfiguration: snapshot.viewApiConfiguration }) + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the view-local overlay: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + async upsertProviderProfile( name: string, providerSettings: ProviderSettings, @@ -2370,20 +2426,46 @@ 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. - await Promise.all([ - this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - // Route through setValue so the in-memory viewLocalState buffer tracks the - // activated profile: a plain global write would leave a stale loaded - // currentApiConfigName shadowing the new value in getValues(). - this.setValue("currentApiConfigName", name), - this.providerSettingsManager.setModeConfig(mode, id), - this.contextProxy.setProviderSettings(providerSettings), - // setProviderSettings writes the shared store directly, bypassing the - // view-local mutation path: clear this view's buffered apiConfiguration - // overlay (if any) so a stale loaded profile cannot keep shadowing the - // new settings in getState(). - this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), - ]) + const activationSnapshot = this._snapshotProfileActivationState() + let priorModeConfigId: string | undefined + try { + priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) + } catch { + // Best-effort snapshot: when the prior mapping cannot be read the + // compensation restores the mapping as absent. + } + try { + await Promise.all([ + this.updateGlobalState( + "listApiConfigMeta", + await this.providerSettingsManager.listConfig(), + ), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain global write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), + this.providerSettingsManager.setModeConfig(mode, id), + this.contextProxy.setProviderSettings(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 }), + ]) + } catch (error) { + // Compensate every write that may have landed: the mode mapping + // first (it belongs to this write group), then the shared and + // per-view state. + try { + await this.providerSettingsManager.setModeConfig(mode, priorModeConfigId as string) + } catch (restoreError) { + this.log( + `[ClineProvider] profile-activation rollback failed for the mode mapping: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + await this._restoreProfileActivationState(activationSnapshot) + throw error + } // Other live views may have buffered this profile's settings earlier; // refresh them so their getState() cannot report the updated profile's @@ -2589,19 +2671,26 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. - await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - // Route through setValue so the in-memory viewLocalState buffer tracks the - // activated profile: a plain ContextProxy write would leave a stale loaded - // currentApiConfigName shadowing the new value in getValues(). - this.setValue("currentApiConfigName", name), - this.contextProxy.setProviderSettings(providerSettings), - // setProviderSettings writes the shared store directly, bypassing the - // view-local mutation path: clear this view's buffered apiConfiguration - // overlay (if any) so a stale loaded profile cannot keep shadowing the - // new settings in getState(). - this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), - ]) + const activationSnapshot = this._snapshotProfileActivationState() + try { + await Promise.all([ + this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + // Route through setValue so the in-memory viewLocalState buffer tracks the + // activated profile: a plain ContextProxy write would leave a stale loaded + // currentApiConfigName shadowing the new value in getValues(). + this.setValue("currentApiConfigName", name), + this.contextProxy.setProviderSettings(providerSettings), + // setProviderSettings writes the shared store directly, bypassing the + // view-local mutation path: clear this view's buffered apiConfiguration + // overlay (if any) so a stale loaded profile cannot keep shadowing the + // new settings in getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), + ]) + } catch (error) { + // Compensate every write that may have landed before the failure. + await this._restoreProfileActivationState(activationSnapshot) + throw error + } // Other live views may have buffered this profile's settings earlier; // refresh them so their getState() cannot report the activated profile's diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9d6126f4c4..48fa850a6e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3248,6 +3248,46 @@ describe("ClineProvider", () => { expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic) await provider.dispose() }) + it("rolls back the profile activation writes when one concurrent write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the prior shared selection so the rollback restores a real state. + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.anthropic }, + ]) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, + ]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id (the + // user sees the error toast); the compensation must still have run. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + // Every write that landed is compensated: the mode mapping returns to its + // prior id, and the selection, list, and shared settings are restored. + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("code", "old-mode-id") + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + expect((provider.contextProxy.getValue("listApiConfigMeta") ?? []).map((entry) => entry.name)).toEqual([ + "old-profile", + ]) + expect(setProviderSettingsSpy).toHaveBeenCalledTimes(2) + await provider.dispose() + }) it("refreshes this view's buffer when a pinned profile is saved without activation", async () => { const provider = new ClineProvider( diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index ed39e384ed..a647e18896 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1556,6 +1556,23 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1) }) + + // viewStates is server-owned: a crafted updatedSettings payload that echoes the + // whole map back must not rewrite (or clobber) the persisted per-view state, + // while sibling keys in the same payload still persist normally. + it("does not let updateSettings rewrite the server-owned viewStates map", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { + viewStates: { injected: { mode: "architect" } }, + alwaysDenyUnapprovedCommands: true, + }, + }) + + expect(mockClineProvider.setValue).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("viewStates", expect.anything()) + expect(mockClineProvider.setValue).toHaveBeenCalledWith("alwaysDenyUnapprovedCommands", true) + }) }) // A plain boolean needs no normalization branch in the updateSettings loop, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ff336caf5d..d0d1edc079 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -784,6 +784,14 @@ export const webviewMessageHandler = async ( } for (const [key, value] of Object.entries(message.updatedSettings)) { + // viewStates is server-owned per-view persistence (identity pins and + // loaded-profile overlays). The settings UI never puts it into + // updatedSettings, so a crafted renderer payload must not be able to + // rewrite the whole map through the generic loop below. + if (key === "viewStates") { + continue + } + let newValue = value if (key === "language") { From de4f774b723cd9504bc5b98814ea8752726af0f2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 11:19:50 +0800 Subject: [PATCH 49/65] fix(f1c): extend profile mutation compensation to the provider-manager store --- src/core/config/ProviderSettingsManager.ts | 16 ++ src/core/webview/ClineProvider.ts | 238 +++++++++++++----- .../webview/__tests__/ClineProvider.spec.ts | 17 ++ 3 files changed, 208 insertions(+), 63 deletions(-) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 33bcb6be34..ca4ad872c6 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -494,6 +494,22 @@ export class ProviderSettingsManager { } } + /** + * Read the manager-owned current profile selection. Exposed for callers that + * snapshot the full activation state before a mutation group so a failure can + * compensate the manager store as well as its ContextProxy copies. + */ + public async getCurrentProfileName(): Promise { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + return providerProfiles.currentApiConfigName + }) + } catch (error) { + throw new Error(`Failed to read the active profile: ${error instanceof Error ? error.message : error}`) + } + } + /** * Activate a profile by name or ID. */ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9df5fc1582..465bb8c8ae 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -35,6 +35,7 @@ import { type CloudUserInfo, type CloudOrganizationMembership, type CreateTaskOptions, + type ProviderSettingsWithId, type TokenUsage, type ToolUsage, type ExtensionMessage, @@ -210,6 +211,12 @@ type ProfileActivationSnapshot = { viewApiConfiguration: ProviderSettings | undefined } +type ProviderManagerSnapshot = { + previousRecord: ProviderSettingsWithId | undefined + recordExisted: boolean | undefined + previousCurrentProfileName: string | undefined +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -2397,6 +2404,84 @@ export class ClineProvider } } + /** + * Prior provider-manager state that the profile mutation group overwrites: the + * manager's record for the profile being saved (or its confirmed absence) and + * the manager-owned current selection. The manager store is the source of truth + * that the profile list and the shared settings are derived from, so + * compensation has to restore it and not only its ContextProxy copies. + */ + private async _snapshotProviderManagerState(name: string): Promise { + let previousRecord: ProviderSettingsWithId | undefined + let recordExisted: boolean | undefined + try { + const { name: _name, ...record } = await this.providerSettingsManager.getProfile({ name }) + previousRecord = record as ProviderSettingsWithId + recordExisted = true + } catch (error: unknown) { + // A typed not-found proves the record is new; any other failure leaves the + // prior record unknown, and the restore must not delete what it cannot see. + if (error instanceof ProviderSettingsNotFoundError) { + recordExisted = false + } + } + let previousCurrentProfileName: string | undefined + try { + previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() + } catch { + // Best-effort: the selection restore is skipped when unreadable. + } + return { previousRecord, recordExisted, previousCurrentProfileName } + } + + /** + * Selection-only variant of the manager snapshot, for paths that overwrite the + * manager selection without touching any profile record. + */ + private async _snapshotProviderManagerSelection(): Promise { + let previousCurrentProfileName: string | undefined + try { + previousCurrentProfileName = await this.providerSettingsManager.getCurrentProfileName() + } catch { + // Best-effort: the selection restore is skipped when unreadable. + } + return { previousRecord: undefined, recordExisted: undefined, previousCurrentProfileName } + } + + /** + * Restore the manager record for a mutation that failed after the record write + * landed. A record the mutation created is deleted; a deletion that refuses + * (last remaining configuration) is logged and leaves the record behind. + */ + private async _restoreProviderManagerRecord(name: string, snapshot: ProviderManagerSnapshot): Promise { + try { + if (snapshot.recordExisted === true && snapshot.previousRecord) { + await this.providerSettingsManager.saveConfig(name, snapshot.previousRecord) + } else if (snapshot.recordExisted === false) { + await this.providerSettingsManager.deleteConfig(name) + } + } catch (error) { + this.log( + `[ClineProvider] profile-mutation rollback failed for the provider-manager record: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Restore the manager-owned current selection captured by a prior snapshot. + */ + private async _restoreProviderManagerSelection(snapshot: ProviderManagerSnapshot): Promise { + try { + if (snapshot.previousCurrentProfileName) { + await this.providerSettingsManager.activateProfile({ name: snapshot.previousCurrentProfileName }) + } + } catch (error) { + this.log( + `[ClineProvider] profile-mutation rollback failed for the provider-manager selection: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + async upsertProviderProfile( name: string, providerSettings: ProviderSettings, @@ -2409,32 +2494,40 @@ export class ClineProvider // we rely on the `ContextProxy`'s data store and in other cases // we rely on the `ProviderSettingsManager`'s data store. It might // be simpler to unify these two. + // The manager record written below belongs to the same mutation group as + // the activation writes: snapshot the prior manager record and the + // manager's own selection BEFORE saveConfig so a failure anywhere in the + // group compensates the manager store too, not just its ContextProxy + // copies. The shared/per-view snapshot is taken at the same point. + const managerSnapshot = await this._snapshotProviderManagerState(name) + const preWriteSnapshot = this._snapshotProfileActivationState() const id = await this.providerSettingsManager.saveConfig(name, providerSettings) if (signal.aborted) return id - if (activate) { - const { mode } = await this.getState() - - // These promises do the following: - // 1. Adds or updates the list of provider profiles. - // 2. Sets the current provider profile. - // 3. Sets the current mode's provider profile. - // 4. Copies the provider settings to the context. - // - // Note: 1, 2, and 4 can be done in one `ContextProxy` call: - // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) - // We should probably switch to that and verify that it works. - // I left the original implementation in just to be safe. - const activationSnapshot = this._snapshotProfileActivationState() - let priorModeConfigId: string | undefined - try { - priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) - } catch { - // Best-effort snapshot: when the prior mapping cannot be read the - // compensation restores the mapping as absent. - } - try { + let activationMode: Mode | undefined + let priorModeConfigId: string | undefined + try { + if (activate) { + const { mode } = await this.getState() + activationMode = mode + + // These promises do the following: + // 1. Adds or updates the list of provider profiles. + // 2. Sets the current provider profile. + // 3. Sets the current mode's provider profile. + // 4. Copies the provider settings to the context. + // + // Note: 1, 2, and 4 can be done in one `ContextProxy` call: + // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) + // We should probably switch to that and verify that it works. + // I left the original implementation in just to be safe. + try { + priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) + } catch { + // Best-effort snapshot: when the prior mapping cannot be read the + // compensation restores the mapping as absent. + } await Promise.all([ this.updateGlobalState( "listApiConfigMeta", @@ -2452,54 +2545,67 @@ export class ClineProvider // new settings in getState(). this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) - } catch (error) { - // Compensate every write that may have landed: the mode mapping - // first (it belongs to this write group), then the shared and - // per-view state. + + // 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, providerSettings) + + // Change the provider for the current task. + // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + + // Keep the current task's sticky provider profile in sync with the newly-activated profile. + await this.persistStickyProviderProfileToCurrentTask(name) + } else { + await this.updateGlobalState( + "listApiConfigMeta", + await this.providerSettingsManager.listConfig(), + ) + + // 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: providerSettings }) + // 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(providerSettings, { forceRebuild: true }) + } + } + await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + } + + await this.postStateToWebview() + return id + } catch (error) { + // Compensate every write that may have landed, each restore awaited + // on its own so one failure cannot strand the rest: the manager + // record and selection first (the manager store is the source of + // truth that the profile list and the shared settings are derived + // from), then the mode mapping, then the shared and per-view state. + // The original failure is re-thrown for the caller. + await this._restoreProviderManagerRecord(name, managerSnapshot) + await this._restoreProviderManagerSelection(managerSnapshot) + if (activationMode !== undefined) { try { - await this.providerSettingsManager.setModeConfig(mode, priorModeConfigId as string) + await this.providerSettingsManager.setModeConfig( + activationMode, + priorModeConfigId as string, + ) } catch (restoreError) { this.log( `[ClineProvider] profile-activation rollback failed for the mode mapping: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ) } - await this._restoreProfileActivationState(activationSnapshot) - throw error - } - - // 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, providerSettings) - - // Change the provider for the current task. - // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) - - // Keep the current task's sticky provider profile in sync with the newly-activated profile. - await this.persistStickyProviderProfileToCurrentTask(name) - } else { - await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) - - // 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: providerSettings }) - // 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(providerSettings, { forceRebuild: true }) - } } - await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + await this._restoreProfileActivationState(preWriteSnapshot) + throw error } - - await this.postStateToWebview() - return id }) } catch (error) { this.log( @@ -2661,6 +2767,10 @@ export class ClineProvider }, signal?: AbortSignal, ): Promise { + // The manager selection written by activateProfile belongs to the same + // mutation group as the writes below: snapshot it first so a failed group + // compensates the manager store, not just its copies. + const managerSelectionSnapshot = await this._snapshotProviderManagerSelection() const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) if (signal?.aborted) return @@ -2687,7 +2797,9 @@ export class ClineProvider this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) } catch (error) { - // Compensate every write that may have landed before the failure. + // Compensate every write that may have landed before the failure, + // including the manager selection written by activateProfile. + await this._restoreProviderManagerSelection(managerSelectionSnapshot) await this._restoreProfileActivationState(activationSnapshot) throw error } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 48fa850a6e..9ba0bc90bf 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3266,6 +3266,15 @@ describe("ClineProvider", () => { { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, ]) vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + const activateProfileSpy = vi + .spyOn(provider.providerSettingsManager, "activateProfile") + .mockResolvedValue({ name: "old-profile", apiProvider: providerIdentifiers.anthropic }) vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( new Error("mode write failed"), ) @@ -3286,6 +3295,14 @@ describe("ClineProvider", () => { "old-profile", ]) expect(setProviderSettingsSpy).toHaveBeenCalledTimes(2) + // The manager store is compensated too: the record written by saveConfig + // returns to its prior content and the manager selection returns to the + // prior profile. + expect(provider.providerSettingsManager.saveConfig).toHaveBeenCalledWith("new-profile", { + id: "old-rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + expect(activateProfileSpy).toHaveBeenCalledWith({ name: "old-profile" }) await provider.dispose() }) From 142f83614227705ffbac2ce50655906e9272564e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 11:47:35 +0800 Subject: [PATCH 50/65] test(f1c): wait for the first queued activation deterministically --- .../__tests__/ClineProvider.apiHandlerRebuild.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 4561d688fe..14608cb28b 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -461,8 +461,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const first = provider.activateProviderProfile({ name: "first-profile" }) const second = provider.activateProviderProfile({ name: "second-profile" }) - await Promise.resolve() - expect(events).toEqual(["first:start"]) + // The mutation now reads the manager selection snapshot before the + // manager write, so the first call lands a few microtasks later than a + // single tick would assume; wait for it deterministically instead. + await vi.waitFor(() => expect(events).toEqual(["first:start"])) resolveFirst() await first From a30bb6fe74dfeb58214b325f1c7f391d026c2beb Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 17:39:37 +0800 Subject: [PATCH 51/65] fix(f1c): confirm record absence with hasConfig before manager compensation --- src/core/webview/ClineProvider.ts | 18 ++++---- .../webview/__tests__/ClineProvider.spec.ts | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 465bb8c8ae..6ac9ad49ea 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2415,15 +2415,17 @@ export class ClineProvider let previousRecord: ProviderSettingsWithId | undefined let recordExisted: boolean | undefined try { - const { name: _name, ...record } = await this.providerSettingsManager.getProfile({ name }) - previousRecord = record as ProviderSettingsWithId - recordExisted = true - } catch (error: unknown) { - // A typed not-found proves the record is new; any other failure leaves the - // prior record unknown, and the restore must not delete what it cannot see. - if (error instanceof ProviderSettingsNotFoundError) { - recordExisted = false + // hasConfig distinguishes a confirmed absence from a read failure; the + // generic wrapper getProfile throws for a missing record cannot, because + // the manager does not type its not-found rejections. + recordExisted = await this.providerSettingsManager.hasConfig(name) + if (recordExisted) { + const { name: _name, ...record } = await this.providerSettingsManager.getProfile({ name }) + previousRecord = record as ProviderSettingsWithId } + } catch { + // Unreadable: the restore must not delete what it cannot see. + recordExisted = undefined } let previousCurrentProfileName: string | undefined try { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9ba0bc90bf..6addae1af1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3266,6 +3266,7 @@ describe("ClineProvider", () => { { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, ]) vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ name: "new-profile", id: "old-rec-id", @@ -3305,6 +3306,47 @@ describe("ClineProvider", () => { expect(activateProfileSpy).toHaveBeenCalledWith({ name: "old-profile" }) await provider.dispose() }) + it("deletes the newly created manager record when a later activation write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The profile is new: hasConfig confirms absence even though getProfile + // reports it through an untyped wrapper error. + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockRejectedValue( + new Error("Config with name 'new-profile' not found"), + ) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, + ]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + const deleteConfigSpy = vi + .spyOn(provider.providerSettingsManager, "deleteConfig") + .mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBeUndefined() + + // Compensation must delete the record the mutation created, not skip the + // restore because the lookup threw an untyped not-found. + expect(deleteConfigSpy).toHaveBeenCalledWith("new-profile") + await provider.dispose() + }) it("refreshes this view's buffer when a pinned profile is saved without activation", async () => { const provider = new ClineProvider( From e6efc4536036d8f1d04f1a491a7a0b916f44b989 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 18:30:39 +0800 Subject: [PATCH 52/65] chore: retrigger CI (workflow trigger missed) From 91b0d1aec57fa8948dc8eb5932a437811791a3ed Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 22:15:41 +0800 Subject: [PATCH 53/65] fix(f1c): keep view-state cache, pins, and rename re-point consistent --- src/core/webview/ClineProvider.ts | 148 ++++++++++++++++- .../webview/__tests__/ClineProvider.spec.ts | 150 ++++++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 51 ++++++ src/core/webview/webviewMessageHandler.ts | 14 ++ 4 files changed, 355 insertions(+), 8 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6ac9ad49ea..d059399b75 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -12,6 +12,7 @@ import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { + PROVIDER_SETTINGS_KEYS, type TaskProviderLike, type TaskProviderEvents, type GlobalState, @@ -204,9 +205,36 @@ type GetStateOptions = { includeTaskHistory?: boolean } +/** + * In-flight profile-create claims shared by every provider instance in this + * extension host. A rollback may only delete a record whose creation it can + * prove it owns: when two views concurrently create the same new name, both + * observe `hasConfig === false`, yet the surviving record belongs to the + * create that completed. A failed create must therefore skip the deletion + * while another create for the same name is still in flight, and the last + * claimant to settle performs it. + */ +const profileCreateClaims = new Map() + +function claimProfileCreate(name: string): void { + profileCreateClaims.set(name, (profileCreateClaims.get(name) ?? 0) + 1) +} + +/** Drops one claim and reports whether the caller was the last claimant. */ +function releaseProfileCreate(name: string): boolean { + const remaining = (profileCreateClaims.get(name) ?? 1) - 1 + if (remaining <= 0) { + profileCreateClaims.delete(name) + return true + } + profileCreateClaims.set(name, remaining) + return false +} + type ProfileActivationSnapshot = { listApiConfigMeta: ProviderSettingsEntry[] | undefined currentApiConfigName: string | undefined + viewCurrentApiConfigName: string | undefined providerSettings: RooCodeSettings viewApiConfiguration: ProviderSettings | undefined } @@ -215,6 +243,7 @@ type ProviderManagerSnapshot = { previousRecord: ProviderSettingsWithId | undefined recordExisted: boolean | undefined previousCurrentProfileName: string | undefined + claimedCreate: boolean } export class ClineProvider @@ -629,7 +658,23 @@ export class ClineProvider wroteEntry = true } - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + 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. @@ -2360,10 +2405,21 @@ export class ClineProvider * concurrent write group so a failure can compensate the writes that landed. */ private _snapshotProfileActivationState(): ProfileActivationSnapshot { + // Capture only the provider-settings keys. getValues() returns the whole + // global state, and restoring that copy would write unrelated keys + // (viewStates, mode, taskHistory, ...) back over changes that legitimately + // landed while the activation group was in flight. + const allValues = this.contextProxy.getValues() + const providerSettings = Object.fromEntries( + PROVIDER_SETTINGS_KEYS.map((key) => [key, allValues[key]]), + ) as RooCodeSettings return { listApiConfigMeta: this.contextProxy.getValue("listApiConfigMeta"), currentApiConfigName: this.contextProxy.getValue("currentApiConfigName"), - providerSettings: this.contextProxy.getValues(), + // This view's own pin: the rollback must restore it as-is instead of + // overwriting it with the shared selection. + viewCurrentApiConfigName: this.viewLocalState?.currentApiConfigName, + providerSettings, viewApiConfiguration: this.viewLocalState?.apiConfiguration, } } @@ -2382,7 +2438,11 @@ export class ClineProvider ) } try { - await this.setValue("currentApiConfigName", snapshot.currentApiConfigName) + // Restore the shared selection through the ContextProxy directly: + // this.setValue would also write the shared value into this view's pin + // and its durable viewStates entry, replacing the view's own selection + // with another view's choice. + await this.contextProxy.setValue("currentApiConfigName", snapshot.currentApiConfigName) } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the current profile selection: ${error instanceof Error ? error.message : String(error)}`, @@ -2396,7 +2456,13 @@ export class ClineProvider ) } try { - await this._saveViewLocalStateFromMutation({ apiConfiguration: snapshot.viewApiConfiguration }) + // Restore this view's own pin and overlay together: an absent pin stays + // absent (undefined deletes the entry) instead of materializing a pin + // from the shared value. + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: snapshot.viewCurrentApiConfigName, + apiConfiguration: snapshot.viewApiConfiguration, + }) } catch (error) { this.log( `[ClineProvider] profile-activation rollback failed for the view-local overlay: ${error instanceof Error ? error.message : String(error)}`, @@ -2414,6 +2480,7 @@ export class ClineProvider private async _snapshotProviderManagerState(name: string): Promise { let previousRecord: ProviderSettingsWithId | undefined let recordExisted: boolean | undefined + let claimedCreate = false try { // hasConfig distinguishes a confirmed absence from a read failure; the // generic wrapper getProfile throws for a missing record cannot, because @@ -2422,6 +2489,12 @@ export class ClineProvider if (recordExisted) { const { name: _name, ...record } = await this.providerSettingsManager.getProfile({ name }) previousRecord = record as ProviderSettingsWithId + } else { + // Confirmed absence: claim the create so a concurrent create of the + // same name by another view can make this rollback skip the deletion + // that would destroy the other view's successful save. + claimProfileCreate(name) + claimedCreate = true } } catch { // Unreadable: the restore must not delete what it cannot see. @@ -2433,7 +2506,7 @@ export class ClineProvider } catch { // Best-effort: the selection restore is skipped when unreadable. } - return { previousRecord, recordExisted, previousCurrentProfileName } + return { previousRecord, recordExisted, previousCurrentProfileName, claimedCreate } } /** @@ -2447,7 +2520,7 @@ export class ClineProvider } catch { // Best-effort: the selection restore is skipped when unreadable. } - return { previousRecord: undefined, recordExisted: undefined, previousCurrentProfileName } + return { previousRecord: undefined, recordExisted: undefined, claimedCreate: false, previousCurrentProfileName } } /** @@ -2460,7 +2533,13 @@ export class ClineProvider if (snapshot.recordExisted === true && snapshot.previousRecord) { await this.providerSettingsManager.saveConfig(name, snapshot.previousRecord) } else if (snapshot.recordExisted === false) { - await this.providerSettingsManager.deleteConfig(name) + // Delete only when this mutation owns the last create claim for the + // name: another view may have created the same profile concurrently + // and succeeded, and the record belongs to that save. + const lastClaimant = snapshot.claimedCreate ? releaseProfileCreate(name) : true + if (lastClaimant) { + await this.providerSettingsManager.deleteConfig(name) + } } } catch (error) { this.log( @@ -2505,7 +2584,13 @@ export class ClineProvider const preWriteSnapshot = this._snapshotProfileActivationState() const id = await this.providerSettingsManager.saveConfig(name, providerSettings) - if (signal.aborted) return id + if (signal.aborted) { + // The record landed; the create is complete, so the claim is spent. + if (managerSnapshot.claimedCreate) { + releaseProfileCreate(name) + } + return id + } let activationMode: Mode | undefined let priorModeConfigId: string | undefined @@ -2583,6 +2668,11 @@ export class ClineProvider } await this.postStateToWebview() + // The create completed: the claim is spent and later rollbacks of + // other creates must see this one as finished. + if (managerSnapshot.claimedCreate) { + releaseProfileCreate(name) + } return id } catch (error) { // Compensate every write that may have landed, each restore awaited @@ -2929,6 +3019,48 @@ export class ClineProvider ) } + /** + * 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 { + 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) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a32730efd5..95854b4478 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3425,6 +3425,156 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("does not delete a newly created record while another create for the same name is in flight", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + for (const instance of [providerA, providerB]) { + vi.spyOn(instance.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(instance.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + } + const deleteSpyA = vi.spyOn(providerA.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + const deleteSpyB = vi.spyOn(providerB.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + // Both views confirm the name is absent before saving it: each create is + // claimed so a rollback can tell whose deletion is safe. + const snapshotA = await providerA["_snapshotProviderManagerState"]("dup-profile") + const snapshotB = await providerB["_snapshotProviderManagerState"]("dup-profile") + expect(snapshotA.claimedCreate).toBe(true) + expect(snapshotB.claimedCreate).toBe(true) + + // A's activation fails while B's create for the same name is still in + // flight: the surviving record may belong to B, so A must not delete it. + await providerA["_restoreProviderManagerRecord"]("dup-profile", snapshotA) + expect(deleteSpyA).not.toHaveBeenCalled() + + // B fails too: the last claimant to settle owns the deletion. + await providerB["_restoreProviderManagerRecord"]("dup-profile", snapshotB) + expect(deleteSpyB).toHaveBeenCalledWith("dup-profile") + + await providerA.dispose() + await providerB.dispose() + }) + + it("keeps this view's own profile pin when an activation rollback restores the shared selection", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + // The view pins profile-a while another view's choice sits in the shared + // selection. + await provider.saveViewState("currentApiConfigName", "profile-a") + await provider.contextProxy.setValue("currentApiConfigName", "profile-b") + + const snapshot = provider["_snapshotProfileActivationState"]() + // The activation moved the shared selection; a later write failed. + await provider.setValue("currentApiConfigName", "profile-c") + await provider["_restoreProfileActivationState"](snapshot) + + // The shared selection returns to its prior value... + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("profile-b") + // ...but this view's own pin is restored to its own choice, not the + // shared one: the rollback must not replace the pin with profile-b. + expect(provider["viewLocalState"].currentApiConfigName).toBe("profile-a") + const viewStates = mockContext.globalState.get("viewStates") as Record> + expect(viewStates["stable-sidebar-view"].currentApiConfigName).toBe("profile-a") + await provider.dispose() + }) + + it("does not rewrite unrelated global keys when an activation rollback restores provider settings", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const snapshot = provider["_snapshotProfileActivationState"]() + // An unrelated key legitimately changes while the activation group is in + // flight. + await provider.contextProxy.setValue("customInstructions", "written during activation") + await provider["_restoreProfileActivationState"](snapshot) + // The rollback restores provider settings only; it must not replay the + // whole global-state copy over keys that moved in the meantime. + expect(provider.contextProxy.getValue("customInstructions")).toBe("written during activation") + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted view-state write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider.contextProxy.setValue("viewStates", { + "other-view": { mode: "code", updatedAt: 1 }, + }) + // The next durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["savePersistedViewState"]({ mode: "architect", updatedAt: 2 })).rejects.toThrow( + "durable write failed", + ) + + // The in-memory cache must match durable storage: an entry that never + // reached disk cannot stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "other-view": { mode: "code", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("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("clears this view's buffered apiConfiguration when directly activating a profile", async () => { const provider = new ClineProvider( mockContext, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 6eacf665fc..590bc0348d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -968,6 +968,10 @@ describe("webviewMessageHandler - webviewDidLaunch", () => { // The view pin is re-pinned to the first available profile, // and the shared global selection is left untouched: no global write, no global activation. expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("currentApiConfigName", "shared-profile") + // The stale view-local overlay is cleared with the pin: a buffer still + // holding the missing profile's settings would keep serving them on top of + // the re-pinned selection. + expect(mockClineProvider.saveViewState).toHaveBeenCalledWith("apiConfiguration", undefined) expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith( "currentApiConfigName", "shared-profile", @@ -2760,6 +2764,53 @@ describe("zooCodeSignOut", () => { }) }) +describe("webviewMessageHandler - renameApiConfiguration", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("re-points views pinned to the old name before the old name is deleted", async () => { + const order: string[] = [] + // Double assertion: the spec-wide provider double only declares the members + // most tests drive; this structural widening keeps the new test free of any. + const provider = mockClineProvider as unknown as { + providerSettingsManager: { + getProfile: ReturnType + saveConfig: ReturnType + deleteConfig: ReturnType + } + repointViewLocalStateAfterProfileRename: ReturnType + activateProviderProfile: ReturnType + } + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + 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"]) + }) +}) + describe("webviewMessageHandler - kimiCodeSignIn", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 38ce1ab9c6..601679c683 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -673,6 +673,11 @@ export const webviewMessageHandler = async ( // first listed profile) so the view adopts the shared choice; the // global selection itself is left untouched. await provider.saveViewState("currentApiConfigName", globalConfigName) + // The buffered overlay belongs to the profile that no longer + // resolves: drop it so getState() falls back to the settings of the + // re-pinned selection instead of serving the stale profile's + // provider, model, and keys to new tasks in this view. + await provider.saveViewState("apiConfiguration", undefined) // Fall through: refresh listApiConfigMeta and post listApiConfig // to this webview below. } else { @@ -2464,6 +2469,15 @@ export const webviewMessageHandler = async ( // Create a new configuration with the new name and old ID. await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) + // 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. + await provider.repointViewLocalStateAfterProfileRename(oldName, newName, { + ...message.apiConfiguration, + id, + }) + // Delete the old configuration. await provider.providerSettingsManager.deleteConfig(oldName) From d9c3a7e9800f566fcf6ad005c6642e612bcb6577 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 16 Oct 2026 22:37:51 +0800 Subject: [PATCH 54/65] chore: retrigger CI (windows runner crash in webview-ui test process) From 0df5a26277b3fee4a381a50d7a3f89e6399db887 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 00:09:40 +0800 Subject: [PATCH 55/65] fix: re-point closed-view pins on rename, undo half-finished renames, clear overlay before re-pin, restore rename spec doubles --- src/core/webview/ClineProvider.ts | 40 ++++++ .../webview/__tests__/ClineProvider.spec.ts | 28 +++++ .../__tests__/webviewMessageHandler.spec.ts | 114 ++++++++++++++++-- src/core/webview/webviewMessageHandler.ts | 30 +++-- 4 files changed, 194 insertions(+), 18 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d059399b75..447a480b10 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -702,6 +702,42 @@ export class ClineProvider 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. @@ -3032,6 +3068,10 @@ export class ClineProvider 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) { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 95854b4478..2e6f37ae26 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3574,6 +3574,34 @@ describe("ClineProvider", () => { 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( diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 590bc0348d..6f42e10a12 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -996,6 +996,32 @@ describe("webviewMessageHandler - webviewDidLaunch", () => { expect(mockClineProvider.activateProviderProfile).not.toHaveBeenCalled() }) + it("clears the stale overlay before persisting the re-pin so a failed pin stays retryable", async () => { + vi.mocked(mockClineProvider.providerSettingsManager.hasConfig).mockImplementation( + async (name: string) => name === "shared-profile", + ) + const calls: Array<[string, unknown]> = [] + const originalSaveViewState = mockClineProvider.saveViewState + mockClineProvider.saveViewState = vi.fn(async (key: string, value: unknown) => { + calls.push([key, value]) + if (key === "currentApiConfigName") { + throw new Error("pin write failed") + } + }) + try { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + } finally { + mockClineProvider.saveViewState = originalSaveViewState + } + + // The overlay clear must land first: if the pin write then fails, the merged + // selection stays invalid and the repair retries on the next launch, instead of + // a valid pin serving the deleted profile's buffered settings. + expect(calls[0]).toEqual(["apiConfiguration", undefined]) + expect(calls).toContainEqual(["currentApiConfigName", "shared-profile"]) + }) + it("records the legacy repair without activating a profile when no name is listed", async () => { double.providerSettingsManager.listConfig = vi .fn() @@ -2765,23 +2791,37 @@ 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 + } + 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[] = [] - // Double assertion: the spec-wide provider double only declares the members - // most tests drive; this structural widening keeps the new test free of any. - const provider = mockClineProvider as unknown as { - providerSettingsManager: { - getProfile: ReturnType - saveConfig: ReturnType - deleteConfig: ReturnType - } - repointViewLocalStateAfterProfileRename: ReturnType - activateProviderProfile: ReturnType - } provider.providerSettingsManager = { getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), saveConfig: vi.fn().mockResolvedValue(undefined), @@ -2809,6 +2849,58 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { // old name still resolves, instead of dangling for a window. expect(order).toEqual(["repoint", "delete"]) }) + + it("does not delete the old profile when the re-point fails", async () => { + provider.providerSettingsManager = { + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + saveConfig: vi.fn().mockResolvedValue(undefined), + deleteConfig: vi.fn().mockResolvedValue(undefined), + } + provider.repointViewLocalStateAfterProfileRename = vi.fn().mockRejectedValue(new Error("repoint failed")) + provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "renameApiConfiguration", + values: { oldName: "old-profile", newName: "new-profile" }, + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + }) + + // A failed re-point must abort before the deletion: deleting the old name + // while views still pin it would dangle every affected view. + expect(provider.providerSettingsManager.deleteConfig).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 = { + getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + 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")) + }) }) describe("webviewMessageHandler - kimiCodeSignIn", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 601679c683..ffca48af1d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -672,12 +672,13 @@ export const webviewMessageHandler = async ( // Re-pin this view to the still-valid shared global selection (not the // first listed profile) so the view adopts the shared choice; the // global selection itself is left untouched. - await provider.saveViewState("currentApiConfigName", globalConfigName) - // The buffered overlay belongs to the profile that no longer - // resolves: drop it so getState() falls back to the settings of the - // re-pinned selection instead of serving the stale profile's - // provider, model, and keys to new tasks in this view. + // Clear the buffered overlay first: it belongs to the profile that + // no longer resolves. If the pin write below then fails, the invalid + // selection stays retryable on the next launch; pinning first would + // leave a valid pin serving the stale profile's provider, model, and + // keys with the repair skipped. await provider.saveViewState("apiConfiguration", undefined) + await provider.saveViewState("currentApiConfigName", globalConfigName) // Fall through: refresh listApiConfigMeta and post listApiConfig // to this webview below. } else { @@ -2478,8 +2479,23 @@ export const webviewMessageHandler = async ( id, }) - // Delete the old configuration. - await provider.providerSettingsManager.deleteConfig(oldName) + // 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 + // and drop the new record so the pre-rename state survives intact. + try { + await provider.providerSettingsManager.deleteConfig(oldName) + } catch (error) { + try { + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, { + ...message.apiConfiguration, + id, + }) + await provider.providerSettingsManager.deleteConfig(newName) + } catch { + // Best-effort compensation; the original failure is what callers see. + } + throw error + } // Re-activate to update the global settings related to the // currently activated provider profile. From 2ec3a0aa233ca8f0968af2eaf2c3e1b42721c9e8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 01:28:56 +0800 Subject: [PATCH 56/65] fix: spend the create claim when the profile save itself fails --- src/core/webview/ClineProvider.ts | 12 ++++++- .../webview/__tests__/ClineProvider.spec.ts | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 447a480b10..63c88f20bf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2618,7 +2618,17 @@ export class ClineProvider // copies. The shared/per-view snapshot is taken at the same point. const managerSnapshot = await this._snapshotProviderManagerState(name) const preWriteSnapshot = this._snapshotProfileActivationState() - const id = await this.providerSettingsManager.saveConfig(name, providerSettings) + let id: string + try { + id = await this.providerSettingsManager.saveConfig(name, providerSettings) + } catch (error) { + // saveConfig assigns the record before awaiting the store write, so a + // rejection may still have landed the record: route through the + // manager-record rollback (which deletes a landed record and spends the + // create claim) instead of escaping the group with the claim leaked. + await this._restoreProviderManagerRecord(name, managerSnapshot) + throw error + } if (signal.aborted) { // The record landed; the create is complete, so the claim is spent. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 2e6f37ae26..fa95385954 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3467,6 +3467,39 @@ describe("ClineProvider", () => { await providerB.dispose() }) + it("spends the create claim when the profile save itself fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + const deleteSpy = vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "saveConfig") + .mockRejectedValueOnce(new Error("store write failed")) + .mockResolvedValue("profile-id") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The first create fails inside saveConfig: its rollback must spend the claim + // even though the record write itself is what failed. + await provider.upsertProviderProfile("leaky-profile", { apiProvider: providerIdentifiers.openai }, false) + + // A second create of the same name now fails during activation instead. + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + await provider.upsertProviderProfile("leaky-profile", { apiProvider: providerIdentifiers.openai }, true) + + // A claim leaked by the first create would make this rollback defer to a + // create that never happened and strand the landed record: the deletion + // must land. + expect(deleteSpy).toHaveBeenCalledWith("leaky-profile") + await provider.dispose() + }) + it("keeps this view's own profile pin when an activation rollback restores the shared selection", async () => { const provider = new ClineProvider( mockContext, From 4e25bb087b140e6b5aef2415ed5a8632e24418f5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 07:33:20 +0800 Subject: [PATCH 57/65] fix(webview): hand the stored profile record to rename re-points, roll back cached viewStates, and reject prototype-member stored view ids --- src/core/webview/ClineProvider.ts | 18 +++++++++- .../webview/__tests__/ClineProvider.spec.ts | 32 +++++++++++++++++ .../__tests__/webviewMessageHandler.spec.ts | 35 +++++++++++++++++-- src/core/webview/webviewMessageHandler.ts | 17 ++++----- webview-ui/src/utils/__tests__/vscode.spec.ts | 19 ++++++++++ webview-ui/src/utils/vscode.ts | 11 +++--- 6 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 63c88f20bf..2b73b33246 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -784,7 +784,23 @@ export class ClineProvider states[nextViewStateId] = previous } - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + 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. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index fa95385954..4b5f36ecf9 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3578,6 +3578,38 @@ describe("ClineProvider", () => { 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("re-points live views pinned to a renamed profile onto the new name", async () => { const provider = new ClineProvider( mockContext, diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 6f42e10a12..8edd95a80f 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2823,7 +2823,7 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { it("re-points views pinned to the old name before the old name is deleted", async () => { const order: string[] = [] provider.providerSettingsManager = { - getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), saveConfig: vi.fn().mockResolvedValue(undefined), deleteConfig: vi.fn(async () => { order.push("delete") @@ -2852,7 +2852,7 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { it("does not delete the old profile when the re-point fails", async () => { provider.providerSettingsManager = { - getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + getProfile: vi.fn().mockResolvedValue({ id: "profile-id", apiProvider: providerIdentifiers.anthropic }), saveConfig: vi.fn().mockResolvedValue(undefined), deleteConfig: vi.fn().mockResolvedValue(undefined), } @@ -2873,7 +2873,7 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { it("restores the old pin and removes the new profile when the deletion fails", async () => { provider.providerSettingsManager = { - getProfile: vi.fn().mockResolvedValue({ id: "profile-id" }), + 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") { @@ -2901,6 +2901,35 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { 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 = { + 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", + }) + }) }) describe("webviewMessageHandler - kimiCodeSignIn", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ffca48af1d..c32c752d04 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2470,14 +2470,18 @@ export const webviewMessageHandler = async ( // Create a new configuration with the new name and old ID. await provider.providerSettingsManager.saveConfig(newName, { ...message.apiConfiguration, id }) + // 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. + const { name: _renamedName, ...normalizedRenamed } = + await provider.providerSettingsManager.getProfile({ name: newName }) + // 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. - await provider.repointViewLocalStateAfterProfileRename(oldName, newName, { - ...message.apiConfiguration, - id, - }) + await provider.repointViewLocalStateAfterProfileRename(oldName, newName, normalizedRenamed) // 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 @@ -2486,10 +2490,7 @@ export const webviewMessageHandler = async ( await provider.providerSettingsManager.deleteConfig(oldName) } catch (error) { try { - await provider.repointViewLocalStateAfterProfileRename(newName, oldName, { - ...message.apiConfiguration, - id, - }) + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, normalizedRenamed) await provider.providerSettingsManager.deleteConfig(newName) } catch { // Best-effort compensation; the original failure is what callers see. diff --git a/webview-ui/src/utils/__tests__/vscode.spec.ts b/webview-ui/src/utils/__tests__/vscode.spec.ts index 6153e07fd4..d030e04951 100644 --- a/webview-ui/src/utils/__tests__/vscode.spec.ts +++ b/webview-ui/src/utils/__tests__/vscode.spec.ts @@ -54,6 +54,25 @@ describe("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, diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index fc0f035de9..f64a774e58 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -52,13 +52,16 @@ export class VSCodeAPIWrapper { : {} const existingViewStateId = stateObject.viewStateId - // Mirror the ClineProvider.setViewStateId normalization so the webview never - // registers an id the extension would rewrite or reject: trim, replace unsafe - // characters, and drop whitespace-only and "__proto__" values. + // 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 && normalizedViewStateId !== "__proto__") { + if (normalizedViewStateId && !Object.prototype.hasOwnProperty.call(Object.prototype, normalizedViewStateId)) { return normalizedViewStateId } From 5999ef37d67e2a778cffccc18a040a3471a57ef8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 12:32:55 +0800 Subject: [PATCH 58/65] fix(webview): build activation writes from the stored record, roll back view-state maps, and re-point closed views on deletion --- src/core/webview/ClineProvider.ts | 91 ++++++++- .../webview/__tests__/ClineProvider.spec.ts | 188 ++++++++++++++++++ 2 files changed, 271 insertions(+), 8 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2b73b33246..a7cb9a6d2c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -237,6 +237,7 @@ type ProfileActivationSnapshot = { viewCurrentApiConfigName: string | undefined providerSettings: RooCodeSettings viewApiConfiguration: ProviderSettings | undefined + viewStates: Record } type ProviderManagerSnapshot = { @@ -695,7 +696,23 @@ export class ClineProvider const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { const states = this.getPersistedViewStates({ fresh: true }) delete states[viewStateId] - await this.contextProxy.setValue("viewStates", states) + try { + await this.contextProxy.setValue("viewStates", states) + } catch (error) { + // updateGlobalState applies the new map to the in-memory cache before + // the durable write, so a rejected write leaves the cache missing an + // entry that durable storage still holds. Restore the cache to the + // value storage actually holds (undefined when the key was never + // written); the cache update lands even if this compensating write + // also fails, so readers cannot observe the loss either way. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } }) ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) @@ -731,13 +748,28 @@ export class ClineProvider } if (changed) { - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + 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: restore the cache to the value durable + // storage actually holds so closed-view pins cannot serve re-pointed + // names that never reached disk, then let the caller compensate. + try { + await this.contextProxy.setValue("viewStates", this.context.globalState.get("viewStates")) + } catch { + // The cache is updated before the durable attempt: it matches the + // durable value regardless of whether this restore write landed. + } + throw error + } } }) ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) await write } + /** * Keeps only the most recently updated entries of the persisted view states map, * bounded by MAX_PERSISTED_VIEW_STATES so the global key cannot grow unboundedly. @@ -2473,6 +2505,10 @@ export class ClineProvider viewCurrentApiConfigName: this.viewLocalState?.currentApiConfigName, providerSettings, viewApiConfiguration: this.viewLocalState?.apiConfiguration, + // The activation's pin write merges into and prunes the durable map: the + // rollback needs the exact pre-activation map to bring back any sibling the + // pruning evicted, entry metadata included. + viewStates: this.getPersistedViewStates({ fresh: true }), } } @@ -2520,6 +2556,21 @@ export class ClineProvider `[ClineProvider] profile-activation rollback failed for the view-local overlay: ${error instanceof Error ? error.message : String(error)}`, ) } + try { + // Restore the exact pre-activation map through the serialized view-state + // queue, last so it wins over the overlay restore's own pin write: a + // sibling the activation's pruning evicted comes back with its original + // entry metadata. + const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { + await this.contextProxy.setValue("viewStates", { ...snapshot.viewStates }) + }) + ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) + await restore + } catch (error) { + this.log( + `[ClineProvider] profile-activation rollback failed for the durable viewStates map: ${error instanceof Error ? error.message : String(error)}`, + ) + } } /** @@ -2654,6 +2705,25 @@ export class ClineProvider 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. + } + let activationMode: Mode | undefined let priorModeConfigId: string | undefined try { @@ -2687,7 +2757,7 @@ export class ClineProvider // 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 @@ -2698,11 +2768,11 @@ export class ClineProvider // 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, providerSettings) + 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) @@ -2717,16 +2787,16 @@ export class ClineProvider // (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: providerSettings }) + 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(providerSettings, { forceRebuild: true }) + this.updateTaskApiHandlerIfNeeded(normalizedSettings, { forceRebuild: true }) } } - await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings) + await this.refreshViewLocalStateForUpdatedProfile(name, normalizedSettings) } await this.postStateToWebview() @@ -2866,6 +2936,11 @@ export class ClineProvider // deleted profile's name and configuration. await this.rePinViewLocalStateForDeletedProfile(profileToDelete.name, profileToActivate, survivingSettings) + // Closed views keep their durable viewStates entry after disposal: a pin + // naming the deleted profile would rehydrate a missing profile name on + // the next load. Re-point those persisted pins to the survivor too. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + await this.postStateToWebview() } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4b5f36ecf9..0db79fabde 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3348,6 +3348,88 @@ describe("ClineProvider", () => { expect(activateProfileSpy).toHaveBeenCalledWith({ name: "old-profile" }) await provider.dispose() }) + it("re-points closed views' persisted pins onto the surviving profile after a deletion", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-delete-view") + // A closed view's durable pin and this view's own pin both name the doomed + // profile; the deletion must move them to the survivor. + await provider.contextProxy.setValue("viewStates", { + "closed-view": { currentApiConfigName: "doomed-profile", updatedAt: 1 }, + "stable-delete-view": { mode: "code", currentApiConfigName: "doomed-profile", updatedAt: 2 }, + }) + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "doomed-profile", id: "doomed-id", apiProvider: providerIdentifiers.anthropic }, + { name: "keeper-profile", id: "keeper-id", apiProvider: providerIdentifiers.openrouter }, + ]) + await provider.saveViewState("currentApiConfigName", "doomed-profile") + // The deleted profile is the shared selection: the survivor resolution needs it. + await provider.contextProxy.setValue("currentApiConfigName", "doomed-profile") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.openrouter, + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("keeper-id") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteProviderProfile({ + name: "doomed-profile", + id: "doomed-id", + apiProvider: providerIdentifiers.anthropic, + }) + + const states = provider.contextProxy.getValue("viewStates") as Record> + expect(states["closed-view"].currentApiConfigName).toBe("keeper-profile") + await provider.dispose() + }) + + it("upserts activation writes from the stored normalized record, not the raw payload", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + // The stored record is the normalized shape saveConfig persists: the schema + // parse strips the stray cross-provider field and keeps the stored model. + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + id: "profile-id", + name: "profile-a", + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.upsertProviderProfile("profile-a", { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "raw-model", + openRouterApiKey: "cross-provider-stray", + }) + + // The shared store receives the stored record: the raw payload's stray + // cross-provider field must not reach it (or the sibling overlays and + // task handler refreshed from the same value). + expect(setProviderSettingsSpy).toHaveBeenCalledWith({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "stored-model", + }) + await provider.dispose() + }) + it("deletes the newly created manager record when a later activation write fails", async () => { const provider = new ClineProvider( mockContext, @@ -3610,6 +3692,111 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("restores the cached viewStates map when a clear write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-sidebar-view") + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + // The clear's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["clearPersistedViewState"]()).rejects.toThrow("durable write failed") + + // The cache must match durable storage: the entry was never removed on + // disk, so it must stay visible through getValue. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the cached viewStates map when a persisted re-point write fails durably", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + + // The re-point's durable write fails; the cache was already updated optimistically. + const updateSpy = vi.spyOn(mockContext.globalState, "update").mockImplementationOnce(async () => { + throw new Error("durable write failed") + }) + + await expect(provider["repointPersistedViewStates"]("profile-old", "profile-new")).rejects.toThrow( + "durable write failed", + ) + + // The cache must match durable storage: the re-pointed pins never reached disk, + // so the pre-re-point map must stay visible. + expect(provider.contextProxy.getValue("viewStates")).toEqual({ + "stable-sidebar-view": { currentApiConfigName: "profile-old", updatedAt: 1 }, + "other-view": { mode: "code", currentApiConfigName: "profile-keep", updatedAt: 2 }, + }) + updateSpy.mockRestore() + await provider.dispose() + }) + + it("restores the complete durable viewStates map when an activation rollback follows a pruning pin write", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-activation-view") + // A full map: the activation's own pin write prunes the oldest entry. + const seeded: Record = {} + for (let i = 1; i <= 50; i++) { + seeded["view-" + i] = { mode: "code", updatedAt: i } + } + await provider.contextProxy.setValue("viewStates", seeded) + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + // The rollback restores the exact pre-activation map: the sibling the + // pruning evicted is back with its original metadata. + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["view-1"]).toEqual({ mode: "code", updatedAt: 1 }) + await provider.dispose() + }) + it("re-points live views pinned to a renamed profile onto the new name", async () => { const provider = new ClineProvider( mockContext, @@ -8977,6 +9164,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { id: "saved-id", name: "saved-profile", apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", }) vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) From 636365a57a8c79145683b99247fa83ad41ef58c4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 17:34:20 +0800 Subject: [PATCH 59/65] fix(webview): close rename compensation gaps and reject colliding target names - Reject a rename whose target name already exists before saving: webview name validation lets a case-only collision reach the handler, and the rollback would otherwise delete a record the rename never owned. - Keep the pre-rename stored record and roll the views back to it when the old-name deletion fails, instead of the attempted payload that was never persisted under the old name. - Undo the rename's own save when the normalized read-back or the view re-point fails, so a failed rename cannot leave both names resolving to the same id. - Log failed rename compensation instead of discarding it. --- .../__tests__/webviewMessageHandler.spec.ts | 153 +++++++++++++++++- src/core/webview/webviewMessageHandler.ts | 73 +++++++-- 2 files changed, 210 insertions(+), 16 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4886ba50e8..02dccbf056 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -2807,6 +2807,7 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { getProfile: ReturnType saveConfig: ReturnType deleteConfig: ReturnType + hasConfig: ReturnType } repointViewLocalStateAfterProfileRename: ReturnType activateProviderProfile: ReturnType @@ -2832,6 +2833,7 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { 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 () => { @@ -2859,13 +2861,15 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { expect(order).toEqual(["repoint", "delete"]) }) - it("does not delete the old profile when the re-point fails", async () => { + 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), } - provider.repointViewLocalStateAfterProfileRename = vi.fn().mockRejectedValue(new Error("repoint failed")) + const repoint = vi.fn().mockRejectedValueOnce(new Error("repoint failed")) + provider.repointViewLocalStateAfterProfileRename = repoint provider.activateProviderProfile = vi.fn().mockResolvedValue(undefined) await webviewMessageHandler(mockClineProvider, { @@ -2874,14 +2878,24 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, }) - // A failed re-point must abort before the deletion: deleting the old name - // while views still pin it would dangle every affected view. - expect(provider.providerSettingsManager.deleteConfig).not.toHaveBeenCalled() + // 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) => { @@ -2913,6 +2927,7 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { 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, @@ -2939,6 +2954,134 @@ describe("webviewMessageHandler - renameApiConfiguration", () => { 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", () => { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7c2b98be1f..92c1cdc295 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2495,8 +2495,21 @@ 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 }) @@ -2504,27 +2517,65 @@ export const webviewMessageHandler = async ( // 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. - const { name: _renamedName, ...normalizedRenamed } = - await provider.providerSettingsManager.getProfile({ name: newName }) + // 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. - await provider.repointViewLocalStateAfterProfileRename(oldName, newName, normalizedRenamed) + // 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 - // and drop the new record so the pre-rename state survives intact. + // 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, normalizedRenamed) + await provider.repointViewLocalStateAfterProfileRename(newName, oldName, previousRecord) await provider.providerSettingsManager.deleteConfig(newName) - } catch { - // Best-effort compensation; the original failure is what callers see. + } catch (compensationError) { + provider.log( + `Rename compensation failed after delete rejection: ${JSON.stringify(compensationError, Object.getOwnPropertyNames(compensationError), 2)}`, + ) } throw error } From aa2135a8b7842b662c20f53488db0c572e67c75b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 19:03:06 +0800 Subject: [PATCH 60/65] fix(webview): harden activation rollback against in-flight races The direct-activation group rejected on the first failing write, so the compensation could restore stores while a sibling write of the same group was still in flight and land on top of the restore. Settle the group before compensating, matching the upsert path. The durable viewStates rollback replayed the whole pre-activation map, which erased entries sibling instances persisted during the activation window. Restore only the entries the activation pruned; every other entry in the map is newer than the snapshot and stays. --- src/core/webview/ClineProvider.ts | 28 ++++-- .../webview/__tests__/ClineProvider.spec.ts | 98 +++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 004ed51caf..87b731868a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2587,12 +2587,22 @@ export class ClineProvider ) } try { - // Restore the exact pre-activation map through the serialized view-state - // queue, last so it wins over the overlay restore's own pin write: a - // sibling the activation's pruning evicted comes back with its original - // entry metadata. + // Restore pruned siblings through the serialized view-state queue, last + // so it lands after the overlay restore's own pin write: a sibling the + // activation's pruning evicted comes back with its original entry + // metadata. Merge, do not replace: the mutation queue is per-instance, + // so sibling views keep writing to the durable map while an activation + // group is in flight, and replaying the snapshot would silently revert + // those newer writes. const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { - await this.contextProxy.setValue("viewStates", { ...snapshot.viewStates }) + const current = this.getPersistedViewStates({ fresh: true }) + const merged = { ...current } + for (const [viewId, entry] of Object.entries(snapshot.viewStates)) { + if (!(viewId in merged)) { + merged[viewId] = entry + } + } + await this.contextProxy.setValue("viewStates", merged) }) ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) await restore @@ -3149,7 +3159,8 @@ export class ClineProvider // See `upsertProviderProfile` for a description of what this is doing. const activationSnapshot = this._snapshotProfileActivationState() try { - await Promise.all([ + // `allSettled` so the compensation below never races a write still in flight. + const activationResults = await Promise.allSettled([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), // Route through setValue so the in-memory viewLocalState buffer tracks the // activated profile: a plain ContextProxy write would leave a stale loaded @@ -3162,6 +3173,11 @@ export class ClineProvider // new settings in getState(). this._saveViewLocalStateFromMutation({ apiConfiguration: undefined }), ]) + const failedActivation = activationResults.find( + (result): result is PromiseRejectedResult => result.status === `rejected`, + ) + + if (failedActivation) throw failedActivation.reason } catch (error) { // Compensate every write that may have landed before the failure, // including the manager selection written by activateProfile. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index f775faba26..f6cb8cedd3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3348,6 +3348,53 @@ describe("ClineProvider", () => { expect(activateProfileSpy).toHaveBeenCalledWith({ name: "old-profile" }) await provider.dispose() }) + it("rolls back the direct activation writes when one concurrent write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed the prior shared selection so the rollback restores a real state. + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + await provider.contextProxy.setValue("listApiConfigMeta", [ + { name: "old-profile", id: "old-id", apiProvider: providerIdentifiers.anthropic }, + ]) + // @ts-ignore - Replace providerSettingsManager with a test double. + provider.providerSettingsManager = { + activateProfile: vi.fn().mockResolvedValue({ + name: "new-profile", + id: "new-id", + apiProvider: providerIdentifiers.openai, + }), + listConfig: vi + .fn() + .mockResolvedValue([ + { name: "new-profile", id: "new-id", apiProvider: providerIdentifiers.openai }, + ]), + getCurrentProfileName: vi.fn().mockResolvedValue("old-profile"), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + vi.spyOn(provider.contextProxy, "setProviderSettings").mockRejectedValue( + new Error("shared settings write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect(provider.activateProviderProfile({ name: "new-profile" })).rejects.toThrow( + "shared settings write failed", + ) + + // The compensation waits for every group write to settle before restoring: + // the selection and the profile list return to the prior state even though + // the shared settings write rejected mid-group. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + const restoredEntries = (provider.contextProxy.getValue("listApiConfigMeta") ?? []).map( + (entry) => entry.name, + ) + expect(restoredEntries).toEqual(["old-profile"]) + await provider.dispose() + }) it("re-points closed views' persisted pins onto the surviving profile after a deletion", async () => { const provider = new ClineProvider( mockContext, @@ -3796,6 +3843,57 @@ describe("ClineProvider", () => { expect(restored["view-1"]).toEqual({ mode: "code", updatedAt: 1 }) await provider.dispose() }) + it("keeps sibling viewStates writes that land during an activation rollback", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-merge-view") + await provider.contextProxy.setValue("viewStates", { "old-view": { mode: "code", updatedAt: 1 } }) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // A sibling instance persists its own entry while the activation group is + // in flight; the rollback must not replay the stale snapshot over it. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + const current = (provider.contextProxy.getValue("viewStates") ?? {}) as Record + await provider.contextProxy.setValue("viewStates", { + ...current, + "sibling-view": { mode: "architect", updatedAt: 99 }, + }) + return [] + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + // The rollback restores what the activation pruned without erasing the + // sibling's newer entry. + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["sibling-view"]).toEqual({ mode: "architect", updatedAt: 99 }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + await provider.dispose() + }) it("re-points live views pinned to a renamed profile onto the new name", async () => { const provider = new ClineProvider( From 79488675d5fcde592d5db15ebbd0a04bcbcf7a51 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 19:56:51 +0800 Subject: [PATCH 61/65] fix(webview): keep the queue owned through activation compensation A direct activation that hung past the mutation timeout released the provider-profile queue while its rollback was still pending: a later activation could complete first, and the late restore would then overwrite the later activation's selection and settings. Run the multi-write activation to completion, as the upsert path already does. --- src/core/webview/ClineProvider.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 87b731868a..948cd60ead 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3129,8 +3129,15 @@ export class ClineProvider skipCurrentTaskRebuild?: boolean }, ) { - return this.enqueueProviderProfileMutation((signal) => - this.activateProviderProfileUnlocked(args, options, signal), + return this.enqueueProviderProfileMutation( + (signal) => this.activateProviderProfileUnlocked(args, options, signal), + { + // The queue stays owned until this run - including its compensation - settles: + // a group write that hangs past the mutation timeout must not let a later + // activation complete before this rollback, or the late restore would + // overwrite the later activation's selection and settings. + runToCompletion: true, + }, ) } From 254642ebb472dd2ef497ed0a2744cabfe423677e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 20:12:22 +0800 Subject: [PATCH 62/65] test(webview): cover the run-to-completion activation contract The timeout test asserted the old contract - abort on timeout and let the queue advance - which the queue-ownership fix replaces. Assert the new behavior instead: the timed-out activation is not aborted, completes its writes, and the queue stays owned until it settles, so the second activation runs only afterwards and the late rollback cannot overwrite it. --- .../ClineProvider.apiHandlerRebuild.spec.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 2dd802be33..5fe151674a 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -889,7 +889,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) - test("timed-out mutations abort before writing state and advance the queue", async () => { + test("timed-out activations run to completion and hold the queue", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") @@ -921,20 +921,23 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) await firstResult - // Queue advanced immediately on timeout — second enqueues now. + // The queue stays owned until the hung activation settles: the second + // activation waits, so the late rollback cannot land on top of it. const second = provider.activateProviderProfile({ name: "second-profile" }) - // activateProfile not yet called for second (it runs in the next microtask). + await vi.advanceTimersByTimeAsync(0) expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) - // Resolve the first activation's inner promise so its in-flight mock can return. - // The aborted signal prevents it from writing any state. + // The first activation was not aborted: it completes its writes before + // the queue advances to the second. resolveFirst() + await vi.advanceTimersByTimeAsync(0) await expect(second).resolves.toBeUndefined() expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(2) - // Aborted first activation wrote nothing; only second profile is set. - expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "first-profile") + expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "first-profile") expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") - expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; aborting in-flight mutation") + expect(logSpy).toHaveBeenCalledWith( + "Provider profile mutation timed out; waiting for it to finish before the next mutation", + ) } finally { vi.useRealTimers() } From af4721520abe07ccac9b6c73b904dca0d2bbbb8c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 20:58:06 +0800 Subject: [PATCH 63/65] fix(provider): skip timed-out queued mutations, spare cleared pins and saved records - A provider-profile mutation whose caller already timed out while the callback was still queued no longer starts: it cannot change the selected profile while reporting failure. - Track the viewStates entries the activation's own writes evict and restore exactly those on rollback, so a sibling's intentional pin clear after the snapshot is never resurrected. - Record successful profile creates in the claim bookkeeping: a failed create now spares a record a sibling create saved, and the bookkeeping is cleared when the last claimant settles. - Assert the final shared selection in the run-to-completion timeout test. --- src/core/webview/ClineProvider.ts | 104 +++++++++++---- .../ClineProvider.apiHandlerRebuild.spec.ts | 52 ++++++++ .../webview/__tests__/ClineProvider.spec.ts | 118 ++++++++++++++++++ 3 files changed, 253 insertions(+), 21 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 948cd60ead..ea32904ec5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -237,6 +237,18 @@ function claimProfileCreate(name: string): void { profileCreateClaims.set(name, (profileCreateClaims.get(name) ?? 0) + 1) } +/** + * Names whose create claim has already landed: a failed create must not + * delete the record a successful sibling create saved, even when it settles + * last. + */ +const profileCreatesSucceeded = new Set() + +/** Records that a create for this name landed, so failed siblings spare the record. */ +function markProfileCreateSucceeded(name: string): void { + profileCreatesSucceeded.add(name) +} + /** Drops one claim and reports whether the caller was the last claimant. */ function releaseProfileCreate(name: string): boolean { const remaining = (profileCreateClaims.get(name) ?? 1) - 1 @@ -341,13 +353,21 @@ export class ClineProvider options: { runToCompletion?: boolean } = {}, ): Promise { const controller = new AbortController() + // Set when the caller's timeout fires while this mutation is still queued behind a + // longer-running one: the callback must not start after its caller already saw a + // timeout, or the mutation would change state while reporting failure. + let callerTimedOut = false // Run fn after either outcome so a rejected mutation never poisons the queue. - const run = this.providerProfileMutationQueue.then( - () => fn(controller.signal), - () => fn(controller.signal), - ) + const start = () => { + if (callerTimedOut) { + return Promise.reject(new Error("Provider profile mutation timed out while queued")) + } + return fn(controller.signal) + } + const run = this.providerProfileMutationQueue.then(start, start) const callerResult = this.withProviderProfileMutationTimeout(run, () => { if (options.runToCompletion) { + callerTimedOut = true this.log("Provider profile mutation timed out; waiting for it to finish before the next mutation") return } @@ -436,6 +456,13 @@ export class ClineProvider */ private viewLocalState: Partial = {} + /** + * View ids evicted from the durable viewStates map by this instance's writes + * since the last activation snapshot: the rollback restores exactly these + * entries, so a sibling's intentional pin clear is never resurrected. + */ + private prunedViewIdsDuringActivation: string[] = [] + /** * This view's pinned profile name from the view-local buffer. Exposed for * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` @@ -689,8 +716,14 @@ export class ClineProvider wroteEntry = true } + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !(viewId in pruned)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + try { - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + await this.contextProxy.setValue("viewStates", pruned) } catch (error) { // updateGlobalState applies the new map to the in-memory cache before // the durable write, so a rejected write leaves the cache claiming an @@ -778,8 +811,14 @@ export class ClineProvider } if (changed) { + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !(viewId in pruned)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + try { - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + await this.contextProxy.setValue("viewStates", pruned) } catch (error) { // updateGlobalState applies the new map to the in-memory cache // before the durable write: restore the cache to the value durable @@ -846,8 +885,14 @@ export class ClineProvider states[nextViewStateId] = previous } + const pruned = this.prunePersistedViewStates(states) + const evicted = Object.keys(states).filter((viewId) => !(viewId in pruned)) + if (evicted.length > 0) { + this.prunedViewIdsDuringActivation.push(...evicted) + } + try { - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + await this.contextProxy.setValue("viewStates", pruned) } catch (error) { // updateGlobalState applies the new map to the in-memory cache before // the durable write, so a rejected re-key would leave the cache serving @@ -2519,6 +2564,8 @@ export class ClineProvider * concurrent write group so a failure can compensate the writes that landed. */ private _snapshotProfileActivationState(): ProfileActivationSnapshot { + // The rollback may only restore entries this activation's own writes evict. + this.prunedViewIdsDuringActivation = [] // Capture only the provider-settings keys. getValues() returns the whole // global state, and restoring that copy would write unrelated keys // (viewStates, mode, taskHistory, ...) back over changes that legitimately @@ -2587,18 +2634,20 @@ export class ClineProvider ) } try { - // Restore pruned siblings through the serialized view-state queue, last - // so it lands after the overlay restore's own pin write: a sibling the - // activation's pruning evicted comes back with its original entry - // metadata. Merge, do not replace: the mutation queue is per-instance, - // so sibling views keep writing to the durable map while an activation - // group is in flight, and replaying the snapshot would silently revert - // those newer writes. + // Restore the siblings the activation's pruning evicted, last, so it + // lands after the overlay restore's own pin write, with their original + // entry metadata. Only tracked evictions are restored: an entry a + // sibling deleted on purpose after the snapshot is newer than the + // snapshot too, and resurrecting it would undo the sibling's clear on + // reload. The mutation queue is per-instance, so sibling views keep + // writing to the durable map while an activation group is in flight; + // merge, never replace, to leave those newer writes alone. const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { const current = this.getPersistedViewStates({ fresh: true }) const merged = { ...current } - for (const [viewId, entry] of Object.entries(snapshot.viewStates)) { - if (!(viewId in merged)) { + for (const viewId of this.prunedViewIdsDuringActivation) { + const entry = snapshot.viewStates[viewId] + if (entry && !(viewId in merged)) { merged[viewId] = entry } } @@ -2681,7 +2730,12 @@ export class ClineProvider // and succeeded, and the record belongs to that save. const lastClaimant = snapshot.claimedCreate ? releaseProfileCreate(name) : true if (lastClaimant) { - await this.providerSettingsManager.deleteConfig(name) + // A successful claimant owns the record: delete only when no claimant + // succeeded, and clear the bookkeeping either way. + const anySucceeded = profileCreatesSucceeded.delete(name) + if (!anySucceeded) { + await this.providerSettingsManager.deleteConfig(name) + } } } } catch (error) { @@ -2857,9 +2911,13 @@ export class ClineProvider } if (signal.aborted) { - // The record landed; the create is complete, so the claim is spent. + // The record landed; the create is complete, so the claim is spent and + // the outcome recorded: a failed sibling create must spare the record. if (managerSnapshot.claimedCreate) { - releaseProfileCreate(name) + markProfileCreateSucceeded(name) + if (releaseProfileCreate(name)) { + profileCreatesSucceeded.delete(name) + } } return id } @@ -2960,9 +3018,13 @@ export class ClineProvider await this.postStateToWebview() // The create completed: the claim is spent and later rollbacks of - // other creates must see this one as finished. + // other creates must see this one as finished - and spare the record + // it saved. if (managerSnapshot.claimedCreate) { - releaseProfileCreate(name) + markProfileCreateSucceeded(name) + if (releaseProfileCreate(name)) { + profileCreatesSucceeded.delete(name) + } } return id } catch (error) { diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 5fe151674a..55f3350a71 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -889,6 +889,55 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) + test("an activation that times out while queued behind another never starts", async () => { + vi.useFakeTimers() + let releaseFirst!: () => void + const firstActivation = new Promise((resolve) => { + releaseFirst = resolve + }) + provider["providerSettingsManager"].activateProfile = vi + .fn() + .mockImplementationOnce(async () => { + await firstActivation + return { + name: "first-profile", + id: "first-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4", + } + }) + .mockResolvedValueOnce({ + name: "second-profile", + id: "second-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + + try { + // The first activation outlives the second call's timeout window: the + // second caller rejects while its callback is still queued. + const first = provider.activateProviderProfile({ name: "first-profile" }) + const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(1000) + const second = provider.activateProviderProfile({ name: "second-profile" }) + const secondResult = expect(second).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + await firstResult + await secondResult + + // The first activation settles and the queue advances: the second must + // not start after its caller already saw a timeout, or it would change + // the selected profile while reporting failure. + releaseFirst() + await vi.advanceTimersByTimeAsync(0) + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) + expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "second-profile") + } finally { + vi.useRealTimers() + } + }) + test("timed-out activations run to completion and hold the queue", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") @@ -935,6 +984,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(2) expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "first-profile") expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") + // The final shared selection is the second profile: no late write puts + // the first profile back on top. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("second-profile") expect(logSpy).toHaveBeenCalledWith( "Provider profile mutation timed out; waiting for it to finish before the next mutation", ) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index f6cb8cedd3..9ff17f84db 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3596,6 +3596,67 @@ describe("ClineProvider", () => { await providerB.dispose() }) + it("keeps the record when a sibling create succeeded before this create failed", async () => { + const providerA = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + for (const instance of [providerA, providerB]) { + vi.spyOn(instance.providerSettingsManager, "hasConfig").mockResolvedValue(false) + vi.spyOn(instance.providerSettingsManager, "getCurrentProfileName").mockResolvedValue(undefined) + vi.spyOn(instance.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(instance.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(instance.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(instance.providerSettingsManager, "saveConfig").mockResolvedValue("b-id") + vi.spyOn(instance.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "dup-profile", + id: "b-id", + apiProvider: providerIdentifiers.openai, + }) + vi.spyOn(instance.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "dup-profile", + id: "b-id", + apiProvider: providerIdentifiers.openai, + }) + vi.spyOn(instance, "postStateToWebview").mockResolvedValue(undefined) + } + const deleteSpyA = vi.spyOn(providerA.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + + // A confirms the name is absent and claims the create. + const snapshotA = await providerA["_snapshotProviderManagerState"]("dup-profile") + expect(snapshotA.claimedCreate).toBe(true) + + // B creates the same profile successfully: its claim is spent and the + // record belongs to B's save. + await expect( + providerB.upsertProviderProfile("dup-profile", { apiProvider: providerIdentifiers.openai }, true), + ).resolves.toBe("b-id") + + // A's activation group fails after B already landed: deleting would + // discard the profile B saved. + await providerA["_restoreProviderManagerRecord"]("dup-profile", snapshotA) + expect(deleteSpyA).not.toHaveBeenCalled() + + // The bookkeeping is cleared once the last claimant settles: a later + // failed create for the same name owns its deletion again. + const snapshotA2 = await providerA["_snapshotProviderManagerState"]("dup-profile") + await providerA["_restoreProviderManagerRecord"]("dup-profile", snapshotA2) + expect(deleteSpyA).toHaveBeenCalledWith("dup-profile") + + await providerA.dispose() + await providerB.dispose() + }) + it("spends the create claim when the profile save itself fails", async () => { const provider = new ClineProvider( mockContext, @@ -3895,6 +3956,63 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("does not resurrect a sibling pin cleared after the activation snapshot", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-clear-view") + await provider.contextProxy.setValue("viewStates", { + "sibling-view": { currentApiConfigName: "sibling-profile", updatedAt: 5 }, + "old-view": { mode: "code", updatedAt: 1 }, + }) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + // Mid-group, after the rollback snapshot, the sibling intentionally clears + // its only persisted selection: the deletion is newer than the snapshot and + // must survive the rollback. + vi.spyOn(provider.providerSettingsManager, "listConfig").mockImplementation(async () => { + const current = (provider.contextProxy.getValue("viewStates") ?? {}) as Record< + string, + { mode?: string; currentApiConfigName?: string; updatedAt?: number } + > + delete current["sibling-view"] + await provider.contextProxy.setValue("viewStates", { ...current }) + return [] + }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockRejectedValue( + new Error("mode write failed"), + ) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // The outer upsert catch converts the failure into an undefined id. + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + const restored = provider.contextProxy.getValue("viewStates") as Record + // The rollback must not treat the sibling's clear as activation pruning. + expect(restored["sibling-view"]).toBeUndefined() + expect(restored["old-view"]).toEqual({ mode: "code", updatedAt: 1 }) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("old-profile") + await provider.dispose() + }) + it("re-points live views pinned to a renamed profile onto the new name", async () => { const provider = new ClineProvider( mockContext, From d1fc05da6757c2cfc6c935836db3d9e3ac82b57b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 21:24:36 +0800 Subject: [PATCH 64/65] fix(provider): harden eviction checks and queue-settled test assertions - Use Object.hasOwn for the eviction and merge checks so view ids that collide with Object.prototype keys are still tracked and restored. - Wait for the mutation queue tail in the queued-timeout test before the negative assertions, and assert the first profile's write landed. --- src/core/webview/ClineProvider.ts | 8 ++++---- .../__tests__/ClineProvider.apiHandlerRebuild.spec.ts | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ea32904ec5..9505e7505c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -717,7 +717,7 @@ export class ClineProvider } const pruned = this.prunePersistedViewStates(states) - const evicted = Object.keys(states).filter((viewId) => !(viewId in pruned)) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) if (evicted.length > 0) { this.prunedViewIdsDuringActivation.push(...evicted) } @@ -812,7 +812,7 @@ export class ClineProvider if (changed) { const pruned = this.prunePersistedViewStates(states) - const evicted = Object.keys(states).filter((viewId) => !(viewId in pruned)) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) if (evicted.length > 0) { this.prunedViewIdsDuringActivation.push(...evicted) } @@ -886,7 +886,7 @@ export class ClineProvider } const pruned = this.prunePersistedViewStates(states) - const evicted = Object.keys(states).filter((viewId) => !(viewId in pruned)) + const evicted = Object.keys(states).filter((viewId) => !Object.hasOwn(pruned, viewId)) if (evicted.length > 0) { this.prunedViewIdsDuringActivation.push(...evicted) } @@ -2647,7 +2647,7 @@ export class ClineProvider const merged = { ...current } for (const viewId of this.prunedViewIdsDuringActivation) { const entry = snapshot.viewStates[viewId] - if (entry && !(viewId in merged)) { + if (entry && !Object.hasOwn(merged, viewId)) { merged[viewId] = entry } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 55f3350a71..01701e662a 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -930,7 +930,9 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // not start after its caller already saw a timeout, or it would change // the selected profile while reporting failure. releaseFirst() - await vi.advanceTimersByTimeAsync(0) + // The queue tail settles only after the second run has been evaluated. + await provider["providerProfileMutationQueue"].catch(() => undefined) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("first-profile") expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "second-profile") } finally { From d48f1922450ad50ba8085a205d9ad172c9422321 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 17 Oct 2026 23:17:14 +0800 Subject: [PATCH 65/65] fix(provider): fail closed on unverified profile saves and compensate failed deletions - Re-read the stored record after saveConfig and fail the upsert when the read-back fails, compensating the landed save with the pre-save snapshot instead of letting an unvalidated caller payload drive activation writes. - Snapshot the manager record and every persistence surface before deleteProviderProfile commits its manager delete, and restore them (record, profile-list merge, selection, shared settings, affected view pins) when a later write in the deletion group fails. - Keep the mutation queue owned by a mode switch past its timeout via runToCompletion so the next queued mutation cannot overlap in-flight writes. - Track intentionally cleared/re-keyed view ids as tombstones so activation rollback never resurrects a sibling entry the sibling cleared mid-window. --- src/core/webview/ClineProvider.ts | 257 +++++++++++----- .../webview/__tests__/ClineProvider.spec.ts | 276 +++++++++++++++++- 2 files changed, 456 insertions(+), 77 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9505e7505c..a4ca622385 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -290,6 +290,14 @@ export class ClineProvider private static readonly MAX_PERSISTED_VIEW_STATES = 50 private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 private static persistedViewStateWriteQueue: Promise = Promise.resolve() + + /** + * View ids whose durable entry a queued write intentionally removed + * (unpin, clear, or re-key move). Activation rollback treats these as + * tombstones and never resurrects a tracked eviction the clear has + * since deleted; a write that lands an entry lifts its tombstone. + */ + private static clearedViewStateIds = new Set() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -710,9 +718,11 @@ export class ClineProvider let wroteEntry = false if (!next.mode && !next.currentApiConfigName) { delete states[viewStateId] + ClineProvider.clearedViewStateIds.add(viewStateId) } else { next.updatedAt = values.updatedAt ?? Date.now() states[viewStateId] = next + ClineProvider.clearedViewStateIds.delete(viewStateId) wroteEntry = true } @@ -759,6 +769,7 @@ export class ClineProvider const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { const states = this.getPersistedViewStates({ fresh: true }) delete states[viewStateId] + ClineProvider.clearedViewStateIds.add(viewStateId) try { await this.contextProxy.setValue("viewStates", states) } catch (error) { @@ -880,9 +891,11 @@ export class ClineProvider } delete states[previousViewStateId] + ClineProvider.clearedViewStateIds.add(previousViewStateId) if (!states[nextViewStateId]) { states[nextViewStateId] = previous + ClineProvider.clearedViewStateIds.delete(nextViewStateId) } const pruned = this.prunePersistedViewStates(states) @@ -2340,8 +2353,12 @@ export class ClineProvider * current task. Pass null to apply only global mode/profile effects for a pending child. */ public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { - return this.enqueueProviderProfileMutation((signal) => - this.handleModeSwitchUnlocked(newMode, targetTask, signal), + return this.enqueueProviderProfileMutation( + (signal) => this.handleModeSwitchUnlocked(newMode, targetTask, signal), + // The switch and its nested activation must own the queue until every + // write - including compensation - settles: a timeout must not let the + // next queued mutation overlap writes that are still in flight. + { runToCompletion: true }, ) } @@ -2647,7 +2664,7 @@ export class ClineProvider const merged = { ...current } for (const viewId of this.prunedViewIdsDuringActivation) { const entry = snapshot.viewStates[viewId] - if (entry && !Object.hasOwn(merged, viewId)) { + if (entry && !Object.hasOwn(merged, viewId) && !ClineProvider.clearedViewStateIds.has(viewId)) { merged[viewId] = entry } } @@ -2926,10 +2943,11 @@ export class ClineProvider // 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 + // store, sibling view state, or task configuration. Fail closed when + // the read-back fails: the raw caller payload must never stand in for + // the validated record, so the landed save is compensated and the + // mutation group fails instead of propagating unvalidated settings. + let normalizedSettings: ProviderSettings try { const { name: _savedName, @@ -2937,8 +2955,14 @@ export class ClineProvider ...storedRecord } = await this.providerSettingsManager.getProfile({ name }) normalizedSettings = storedRecord as ProviderSettings - } catch { - // Best-effort read-back; see the comment above. + } catch (error) { + // Fail closed: compensate the landed manager record and fail the + // group so raw webview data cannot reach shared writes, sibling + // overlays, or task configuration. + await this._restoreProviderManagerRecord(name, managerSnapshot) + throw new Error( + `Provider profile '${name}' could not be read back after save: ${error instanceof Error ? error.message : String(error)}`, + ) } let activationMode: Mode | undefined @@ -3062,6 +3086,23 @@ export class ClineProvider throw new Error("You cannot delete the last profile") } + // Snapshot the manager record and every persistence surface this + // deletion rewrites BEFORE the manager delete commits, so a failure + // inside the write group below can be compensated instead of leaving + // the secrets-backed record gone while metadata and view pins still + // reference it. + let previousRecord: Awaited> | undefined + try { + previousRecord = await this.providerSettingsManager.getProfile({ name: profileToDelete.name }) + } catch { + // Absent record: the idempotent not-found path below still prunes the + // stale list entry, and compensation skips the record restore. + } + const previousEntries = this.getProviderProfileEntries() + const previousSelection = globalSettings.currentApiConfigName + const previousSharedSettings = this.contextProxy.getProviderSettings() + const previousViewStates = this.getPersistedViewStates({ fresh: true }) + // Remove the profile from the settings store (context.secrets) so it cannot be // resurrected by a later listApiConfigMeta sync. A not-found rejection means // the secret was already gone (e.g. pruned by an earlier run): branch on the @@ -3081,76 +3122,150 @@ export class ClineProvider ) } - const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) - - // Write only the profile list back: replaying the full settings snapshot - // captured above would also rewrite unrelated keys (including viewStates, - // which ClineProvider mutates directly in storage for concurrent views) - // with this view's stale cached copy. - await this.contextProxy.setValue("listApiConfigMeta", entries) - - // Resolve the surviving profile's settings so this view and any other - // live view still pinned to the deleted profile can be re-pinned with - // a matching configuration. - let survivingSettings: ProviderSettings | undefined try { - const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ - name: profileToActivate, - }) - survivingSettings = settings as ProviderSettings - } catch (error) { - this.log( - `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - - // Capture this view's pin before any rewrite: a view pinned to the - // deleted profile while the global selection points elsewhere must still be - // reconfigured, or getState() would keep the deleted profile's settings under - // the surviving profile's name. - const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name - const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName - - if (viewWasPinnedToDeleted) { - // This view's pin now dangles: re-point it. setValue also persists the - // survivor to the shared store, which covers the deleted-was-global case - // for every other view as well as this one. - await this.setValue("currentApiConfigName", profileToActivate) - } else if (deletedWasGlobal) { - // The shared selection changed, but this view's own pin still names a - // surviving profile: update the shared store only, leaving the - // view-local pin untouched. - await this.contextProxy.setValue("currentApiConfigName", profileToActivate) - } + const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + + // Write only the profile list back: replaying the full settings snapshot + // captured above would also rewrite unrelated keys (including viewStates, + // which ClineProvider mutates directly in storage for concurrent views) + // with this view's stale cached copy. + await this.contextProxy.setValue("listApiConfigMeta", entries) + + // Resolve the surviving profile's settings so this view and any other + // live view still pinned to the deleted profile can be re-pinned with + // a matching configuration. + let survivingSettings: ProviderSettings | undefined + try { + const { name: _survivingName, ...settings } = await this.providerSettingsManager.getProfile({ + name: profileToActivate, + }) + survivingSettings = settings as ProviderSettings + } catch (error) { + this.log( + `[deleteProviderProfile] Unable to resolve API profile '${profileToActivate}': ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } - if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { - // The deleted profile was the active one (globally, or for this view), so - // the shared provider keys still carry its settings; replace them so - // getState() reports the surviving profile's configuration. - await this.contextProxy.setProviderSettings(survivingSettings) + // Capture this view's pin before any rewrite: a view pinned to the + // deleted profile while the global selection points elsewhere must still be + // reconfigured, or getState() would keep the deleted profile's settings under + // the surviving profile's name. + const viewWasPinnedToDeleted = this.viewLocalState.currentApiConfigName === profileToDelete.name + const deletedWasGlobal = profileToDelete.name === globalSettings.currentApiConfigName if (viewWasPinnedToDeleted) { - // This view's nested overlay (viewLocalState.apiConfiguration, seeded - // by loadViewState) still serves the deleted profile's configuration: - // replace it with the survivor's so the re-pointed pin serves matching - // settings. A view pinned to another profile keeps its own overlay. - await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) + // This view's pin now dangles: re-point it. setValue also persists the + // survivor to the shared store, which covers the deleted-was-global case + // for every other view as well as this one. + await this.setValue("currentApiConfigName", profileToActivate) + } else if (deletedWasGlobal) { + // The shared selection changed, but this view's own pin still names a + // surviving profile: update the shared store only, leaving the + // view-local pin untouched. + await this.contextProxy.setValue("currentApiConfigName", profileToActivate) } - } - // 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) + if ((deletedWasGlobal || viewWasPinnedToDeleted) && survivingSettings) { + // The deleted profile was the active one (globally, or for this view), so + // the shared provider keys still carry its settings; replace them so + // getState() reports the surviving profile's configuration. + await this.contextProxy.setProviderSettings(survivingSettings) + + if (viewWasPinnedToDeleted) { + // This view's nested overlay (viewLocalState.apiConfiguration, seeded + // by loadViewState) still serves the deleted profile's configuration: + // replace it with the survivor's so the re-pointed pin serves matching + // settings. A view pinned to another profile keeps its own overlay. + await this._saveViewLocalStateFromMutation({ apiConfiguration: survivingSettings }) + } + } - // Closed views keep their durable viewStates entry after disposal: a pin - // naming the deleted profile would rehydrate a missing profile name on - // the next load. Re-point those persisted pins to the survivor too. - await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + // 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() + // Closed views keep their durable viewStates entry after disposal: a pin + // naming the deleted profile would rehydrate a missing profile name on + // the next load. Re-point those persisted pins to the survivor too. + await this.repointPersistedViewStates(profileToDelete.name, profileToActivate) + + await this.postStateToWebview() + } catch (error) { + // The manager delete committed but a later write failed: restore the + // deleted record first, then the shared metadata, selection, and shared + // settings the deletion had rewritten, and finally merge the affected + // persisted pins back. The merge re-reads the durable map and rewrites + // only entries that named the deleted profile in the pre-delete + // snapshot, so concurrent viewStates updates from sibling views + // survive. Each restore is independent and logged; the original + // failure is what the caller sees. + if (previousRecord) { + try { + const { name: _previousName, id: _previousId, ...previousSettings } = previousRecord + await this.providerSettingsManager.saveConfig(profileToDelete.name, previousSettings) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the manager record '${profileToDelete.name}': ${ + restoreError instanceof Error ? restoreError.message : String(restoreError) + }`, + ) + } + } + try { + // Merge rather than clobber: another instance may have added + // profiles between the snapshot and the compensation. + const currentEntries = this.getProviderProfileEntries() + const deletedEntry = previousEntries.find((entry) => entry.name === profileToDelete.name) + if (deletedEntry && !currentEntries.some((entry) => entry.name === profileToDelete.name)) { + await this.contextProxy.setValue("listApiConfigMeta", [...currentEntries, deletedEntry]) + } + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the profile list: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + await this.contextProxy.setValue("currentApiConfigName", previousSelection) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the shared selection: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + await this.contextProxy.setProviderSettings(previousSharedSettings) + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the shared provider settings: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + try { + const restore = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const current = this.getPersistedViewStates({ fresh: true }) + const merged = { ...current } + let changed = false + for (const [viewId, entry] of Object.entries(previousViewStates)) { + if (entry?.currentApiConfigName !== profileToDelete.name) { + continue + } + merged[viewId] = entry + changed = true + } + if (changed) { + await this.contextProxy.setValue("viewStates", merged) + } + }) + ClineProvider.persistedViewStateWriteQueue = restore.catch(() => {}) + await restore + } catch (restoreError) { + this.log( + `[deleteProviderProfile] failed to restore the persisted view pins: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + throw error + } } private async persistStickyProviderProfileToCurrentTask( diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9ff17f84db..8f64a504eb 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2746,6 +2746,11 @@ describe("ClineProvider", () => { } vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("fresh-id") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([profile]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "fresh-profile", + id: "fresh-id", + apiProvider: providerIdentifiers.openrouter, + }) vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) await provider.setValue("currentApiConfigName", "stale-profile") @@ -2786,6 +2791,207 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("fails the upsert and compensates the record when the post-save read-back fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The pre-save snapshot sees the prior record; the post-save read-back + // fails: the raw caller payload must never stand in for the validated + // stored record. + let saved = false + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockImplementation(async () => { + saved = true + return "p-id" + }) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async () => { + if (saved) { + throw new Error("read-back down") + } + return { + name: "p", + id: "p-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + } + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("p") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + + await expect( + provider["persistProviderProfile"]( + "p", + { apiProvider: providerIdentifiers.anthropic, apiKey: "raw-key" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("could not be read back") + + // The landed save was compensated with the prior record, and no + // activation write carried the raw payload into the shared store. + expect(saveSpy).toHaveBeenLastCalledWith("p", expect.objectContaining({ apiKey: "prior-key" })) + expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "p") + + await provider.dispose() + }) + + it("restores the manager record and shared state when a post-delete write fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const oldProfile: ProviderSettingsEntry = { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + } + const keeperProfile: ProviderSettingsEntry = { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + } + await provider.contextProxy.setValue("listApiConfigMeta", [oldProfile, keeperProfile]) + await provider.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockImplementation(async (params) => { + if ("name" in params && params.name === "old-profile") { + return { + name: "old-profile", + id: "old-id", + apiProvider: providerIdentifiers.openrouter, + apiKey: "old-key", + } + } + return { + name: "keeper-profile", + id: "keeper-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "keeper-key", + } + }) + vi.spyOn(provider.providerSettingsManager, "deleteConfig").mockResolvedValue(undefined) + const saveSpy = vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("x") + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + // The first write after the committed manager delete fails: the + // deletion must be compensated, not left half-applied. + vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key) => { + if (key === "listApiConfigMeta") { + throw new Error("state write down") + } + }) + + await expect(provider.deleteProviderProfile(oldProfile)).rejects.toThrow("state write down") + + // The secrets-backed record was restored with its pre-delete settings. + expect(saveSpy).toHaveBeenCalledWith("old-profile", expect.objectContaining({ apiKey: "old-key" })) + + await provider.dispose() + }) + + it("keeps the mutation queue owned by a mode switch past its timeout", async () => { + vi.useFakeTimers() + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + // Bracket access: handleModeSwitchUnlocked is private; the double + // assertion is the only way to install the gate on the prototype. + const proto = ClineProvider.prototype as unknown as { + handleModeSwitchUnlocked: (...args: unknown[]) => Promise + } + const switchSpy = vi.spyOn(proto, "handleModeSwitchUnlocked").mockReturnValue(gate) + + vi.spyOn(provider.customModesManager, "getCustomModes").mockResolvedValue([]) + // The caller-side timeout rejection is expected: runToCompletion only + // keeps the QUEUE waiting for the run, it does not silence the caller. + const switchSettled = provider.handleModeSwitch("programmer").catch(() => undefined) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS + 1000) + + // The queue tail is still the mode switch: a later mutation must + // not start while the switch's writes are still in flight. + let secondStarted = false + void provider["enqueueProviderProfileMutation"](async () => { + secondStarted = true + }) + await vi.advanceTimersByTimeAsync(0) + expect(secondStarted).toBe(false) + + release() + await switchSettled + await vi.advanceTimersByTimeAsync(0) + expect(secondStarted).toBe(true) + + switchSpy.mockRestore() + vi.useRealTimers() + await provider.dispose() + }) + + it("does not resurrect a pruned sibling entry that the sibling cleared mid-window", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("stable-tomb-view") + // A full 50-entry map: the activation write evicts the oldest entry (the + // sibling's), the sibling then clears it mid-window, and the tombstone + // must outrank the tracked eviction during rollback. + const seeded: Record = { + "sibling-view": { currentApiConfigName: "sibling-profile", updatedAt: 0 }, + } + for (let i = 0; i < 49; i++) { + seeded["filler-" + i] = { currentApiConfigName: "filler-profile", updatedAt: i + 1 } + } + await provider.contextProxy.setValue("viewStates", seeded) + await provider.contextProxy.setValue("currentApiConfigName", "old-profile") + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("new-id") + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("old-mode-id") + vi.spyOn(provider.providerSettingsManager, "hasConfig").mockResolvedValue(true) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "new-profile", + id: "rec-id", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "getCurrentProfileName").mockResolvedValue("old-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "old-profile", + apiProvider: providerIdentifiers.anthropic, + }) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) + // After the activation write evicted the oldest entry, the sibling + // intentionally clears it; the rollback must leave it absent. + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockImplementation(async () => { + await provider["clearPersistedViewState"]("sibling-view") + throw new Error("mode write failed") + }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await expect( + provider.upsertProviderProfile("new-profile", { apiProvider: providerIdentifiers.anthropic }, true), + ).resolves.toBeUndefined() + + const restored = provider.contextProxy.getValue("viewStates") as Record + expect(restored["sibling-view"]).toBeUndefined() + expect(restored["filler-0"]).toBeDefined() + await provider.dispose() + }) it("should swallow only the typed not-found signal when pruning a stale profile entry", async () => { const provider = new ClineProvider( mockContext, @@ -3274,6 +3480,11 @@ describe("ClineProvider", () => { // Typed spies on the real manager (instance-scoped): the upsert-activate path // reads only these methods, so no manager replacement is needed. vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("profile-b-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "profile-b", + id: "profile-b-id", + apiProvider: providerIdentifiers.anthropic, + }) vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ { name: "profile-b", id: "profile-b-id", apiProvider: providerIdentifiers.anthropic }, ]) @@ -3536,6 +3747,12 @@ describe("ClineProvider", () => { }) vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("pinned-id") + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "pinned-profile", + id: "pinned-id", + apiProvider: providerIdentifiers.zooGateway, + zooSessionToken: "new-token", + }) vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ { name: "pinned-profile", id: "pinned-id", apiProvider: providerIdentifiers.zooGateway }, ]) @@ -5934,13 +6151,22 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack1: Record> = {} ;(provider as any).providerSettingsManager = { listConfig: vi .fn() .mockResolvedValue([ { name: "test-config", id: "test-id", apiProvider: providerIdentifiers.anthropic }, ]), - saveConfig: vi.fn().mockResolvedValue("test-id"), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack1[name] = settings + return "test-id" + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: "test-id", + ...savedForReadBack1[name], + })), setModeConfig: vi.fn(), } as any @@ -6712,9 +6938,18 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack2: Record> = {} ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack2[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack2[name], + })), listConfig: vi .fn() .mockResolvedValue([ @@ -6757,9 +6992,18 @@ describe("ClineProvider", () => { ;(buildApiHandler as any).mockImplementationOnce(() => { throw new Error("API handler error") }) + const savedForReadBack3: Record> = {} ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack3[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack3[name], + })), listConfig: vi .fn() .mockResolvedValue([ @@ -6800,9 +7044,18 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const savedForReadBack4: Record> = {} ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), - saveConfig: vi.fn().mockResolvedValue(undefined), + saveConfig: vi.fn(async (name: string, settings: Record) => { + savedForReadBack4[name] = settings + return undefined + }), + getProfile: vi.fn(async ({ name }: { name: string }) => ({ + name, + id: undefined, + ...savedForReadBack4[name], + })), listConfig: vi .fn() .mockResolvedValue([ @@ -9452,8 +9705,12 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("saved-id") vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) - vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) - + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "saved-profile", + id: "saved-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) await provider.upsertProviderProfile( "saved-profile", { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" }, @@ -9496,6 +9753,13 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([]) vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "saved-profile", + id: "saved-id", + apiProvider: providerIdentifiers.anthropic, + apiKey: "new-key", + }) + await provider.upsertProviderProfile( "saved-profile", { apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" },