From e67ab35cdd9c154d5bda7b21d35d08a76f7390af Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:00:17 +0800 Subject: [PATCH 01/51] feat(webview): durable per-view viewStates pipeline (N2) Split unit N2 of #1546: viewStates read/write queue, claim rekey, prune, clear, load/save and export/import sanitization on top of N1 identity. Buffer/mutation/history content remains for N3. --- .husky/pre-commit | 26 - .husky/pre-push | 42 -- .../src/__tests__/global-settings.test.ts | 43 ++ packages/types/src/__tests__/index.test.ts | 5 + packages/types/src/global-settings.ts | 17 + packages/types/src/vscode-extension-host.ts | 15 + src/core/config/ContextProxy.ts | 3 + src/core/config/ProviderSettingsManager.ts | 20 +- .../config/__tests__/ContextProxy.spec.ts | 16 + .../__tests__/ProviderSettingsManager.spec.ts | 7 +- .../config/__tests__/importExport.spec.ts | 45 ++ src/core/config/importExport.ts | 8 + src/core/webview/ClineProvider.ts | 651 +++++++++++++++++- .../webview/__tests__/ClineProvider.spec.ts | 50 ++ 14 files changed, 861 insertions(+), 87 deletions(-) delete mode 100644 .husky/pre-commit delete mode 100644 .husky/pre-push diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index f8f420dac7..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,26 +0,0 @@ -branch="$(git rev-parse --abbrev-ref HEAD)" - -if [ "$branch" = "main" ]; then - echo "You can't commit directly to main - please check out a branch." - exit 1 -fi - -# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. -if [ "$OS" = "Windows_NT" ]; then - pnpm_cmd="pnpm.cmd" -else - if command -v pnpm >/dev/null 2>&1; then - pnpm_cmd="pnpm" - else - pnpm_cmd="npx pnpm" - fi -fi - -# Detect if running on Windows and use npx.cmd, otherwise use npx. -if [ "$OS" = "Windows_NT" ]; then - npx_cmd="npx.cmd" -else - npx_cmd="npx" -fi - -$npx_cmd lint-staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100644 index 4cf91d9580..0000000000 --- a/.husky/pre-push +++ /dev/null @@ -1,42 +0,0 @@ -branch="$(git rev-parse --abbrev-ref HEAD)" - -if [ "$branch" = "main" ]; then - echo "You can't push directly to main - please check out a branch." - exit 1 -fi - -# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. -if [ "$OS" = "Windows_NT" ]; then - pnpm_cmd="pnpm.cmd" -else - if command -v pnpm >/dev/null 2>&1; then - pnpm_cmd="pnpm" - else - pnpm_cmd="npx pnpm" - fi -fi - -$pnpm_cmd run check-types - -# Use dotenvx to securely load .env.local and run commands that depend on it -if [ -f ".env.local" ]; then - # Check if RUN_TESTS_ON_PUSH is set to true and run tests with dotenvx - if npx dotenvx get RUN_TESTS_ON_PUSH -f .env.local 2>/dev/null | grep -q "^true$"; then - npx dotenvx run -f .env.local -- $pnpm_cmd run test - fi -else - # Fallback: run tests if RUN_TESTS_ON_PUSH is set in regular environment - if [ "$RUN_TESTS_ON_PUSH" = "true" ]; then - $pnpm_cmd run test - fi -fi - -# Check for new changesets. -NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ') -echo "Changeset files: $NEW_CHANGESETS" - -if [ "$NEW_CHANGESETS" = "0" ]; then - echo "-------------------------------------------------------------------------------------" - echo "Changes detected. Please run 'pnpm changeset' to create a changeset if applicable." - echo "-------------------------------------------------------------------------------------" -fi diff --git a/packages/types/src/__tests__/global-settings.test.ts b/packages/types/src/__tests__/global-settings.test.ts index f2b2e7e8e5..b6b01cccf0 100644 --- a/packages/types/src/__tests__/global-settings.test.ts +++ b/packages/types/src/__tests__/global-settings.test.ts @@ -39,6 +39,49 @@ describe("alwaysDenyUnapprovedCommands global setting", () => { }) }) +describe("viewStates global setting", () => { + it("parses a valid per-view record", () => { + const parsed = globalSettingsSchema.parse({ + viewStates: { + "stable-view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1730000000000 }, + "stable-view-2": {}, + }, + }) + expect(parsed.viewStates?.["stable-view-1"]).toEqual({ + mode: "code", + currentApiConfigName: "profile-a", + updatedAt: 1730000000000, + }) + expect(parsed.viewStates?.["stable-view-2"]).toEqual({}) + expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates") + }) + + it("parses an entry that carries the host-minted claim token", () => { + // The claim binds the entry to the webview that wrote it; a legacy entry without one + // still parses, because refusing it is setViewStateId's job, not the schema's. + const parsed = globalSettingsSchema.parse({ + viewStates: { + "stable-view-1": { mode: "code", updatedAt: 1730000000000, claimToken: "7f9c-host-minted" }, + "stable-view-2": { mode: "code" }, + }, + }) + expect(parsed.viewStates?.["stable-view-1"]?.claimToken).toBe("7f9c-host-minted") + expect(parsed.viewStates?.["stable-view-2"]).toEqual({ mode: "code" }) + }) + + it("rejects invalid field values inside a view entry", () => { + expect(() => globalSettingsSchema.parse({ viewStates: { v: { mode: 42 } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { currentApiConfigName: 42 } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { updatedAt: "soon" } } })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: { claimToken: 42 } } })).toThrow() + }) + + it("rejects non-object viewStates values", () => { + expect(() => globalSettingsSchema.parse({ viewStates: "not-a-record" })).toThrow() + expect(() => globalSettingsSchema.parse({ viewStates: { v: "not-an-entry" } })).toThrow() + }) +}) + describe("collapseCommandOutputByDefault global setting", () => { it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => { const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value }) diff --git a/packages/types/src/__tests__/index.test.ts b/packages/types/src/__tests__/index.test.ts index 15441d48fd..b4cee22f8c 100644 --- a/packages/types/src/__tests__/index.test.ts +++ b/packages/types/src/__tests__/index.test.ts @@ -3,6 +3,10 @@ import { GLOBAL_STATE_KEYS } from "../index.js" describe("GLOBAL_STATE_KEYS", () => { + it("should contain registered durable per-view state", () => { + expect(GLOBAL_STATE_KEYS).toContain("viewStates") + }) + it("should contain provider settings keys", () => { expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled") }) @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => { it("should not contain secret state keys", () => { expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey") + expect(GLOBAL_STATE_KEYS).not.toContain("apiKey") }) it("should contain OpenAI Compatible base URL setting", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..b09bae2635 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -114,6 +114,22 @@ 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(), + /** + * Host-minted capability token binding this entry to the webview that wrote it. + * Minted by the extension host (`crypto.randomUUID()`), delivered to that webview and + * never accepted from webview input. An entry is only re-adopted for a webview that + * presents this token, so a view cannot read another (e.g. closed) view's selections. + */ + claimToken: z.string().optional(), +}) + /** * GlobalSettings */ @@ -122,6 +138,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 a5393ddc66..bc6e6c6279 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -111,6 +111,7 @@ export interface ExtensionMessage { | "originalContent" | "rooHistoryImportProgress" | "themeFixtureProbeRequest" + | "viewStateClaim" text?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } @@ -169,6 +170,13 @@ export interface ExtensionMessage { // eslint-disable-next-line @typescript-eslint/no-explicit-any values?: Record requestId?: string + /** + * For `viewStateClaim`: the durable `viewStates` key the delivered claim governs. The + * message is only ever sent to the webview that key belongs to. + */ + viewStateId?: string + /** For `viewStateClaim`: the host-minted token the webview stores and echoes on launch. */ + viewStateClaim?: string themeFixture?: WebviewThemeFixture promptText?: string results?: @@ -669,6 +677,13 @@ export interface WebviewMessage { | "openRulesDirectory" | "themeFixtureProbeResponse" text?: string + viewStateId?: string + /** + * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. + * The webview only echoes what it was given; the host rejects an id whose stored claim + * does not match, so a view cannot adopt another view's persisted selections. + */ + viewStateClaim?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1250a7baca..4e20305f48 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,9 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + // Per-view selection state is machine-local: it keeps flowing through the + // normal runtime and pruning paths but must not transfer between settings. + viewStates: true, }) export class ContextProxy { diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 01695d7994..306253578a 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -55,6 +55,19 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +/** + * Signals that a profile's configuration no longer exists. Callers that treat an + * already-deleted profile as an idempotent no-op branch on this type instead of + * matching error message text, which a profile name containing the phrase could + * otherwise spoof. + */ +export class ProviderSettingsNotFoundError extends Error { + constructor(message: string) { + super(message) + this.name = "ProviderSettingsNotFoundError" + } +} + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" private readonly defaultConfigId = this.generateId() @@ -511,7 +524,7 @@ export class ProviderSettingsManager { const providerProfiles = await this.load() if (!providerProfiles.apiConfigs[name]) { - throw new Error(`Config '${name}' not found`) + throw new ProviderSettingsNotFoundError(`Config '${name}' not found`) } if (Object.keys(providerProfiles.apiConfigs).length === 1) { @@ -522,6 +535,11 @@ export class ProviderSettingsManager { await this.store(providerProfiles) }) } catch (error) { + // A missing config is a caller-meaningful signal, not a failure: rethrow it + // unwrapped so callers can branch on the type instead of message text. + if (error instanceof ProviderSettingsNotFoundError) { + throw error + } throw new Error(`Failed to delete config: ${error}`) } } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index b4d3b1b8aa..f9fa9cdb15 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -740,4 +740,20 @@ Output only the summary of the conversation so far, without any additional comme expect(customSupportPromptsUpdateCalls.length).toBe(0) }) }) + + describe("export", () => { + it("should exclude viewStates from the exported settings", async () => { + await proxy.setValue("viewStates", { + "stable-sidebar-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + await proxy.setValue("customInstructions", "global instructions") + + const exported = await proxy.export() + + // Per-view selection state is machine-local and must never transfer + // between settings, while ordinary global settings keep round-tripping. + expect(exported).not.toHaveProperty("viewStates") + expect(exported?.customInstructions).toBe("global instructions") + }) + }) }) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index a1fc9ac7de..3b93b7cd22 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -12,7 +12,7 @@ import { import { clearAllMocks } from "../../../test-utils/reset" import { makeExtensionContext } from "../../../test-utils/vscode" -import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError, ProviderProfiles } from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock // buildApiHandler with the real @roo-code/types model definitions so the token-field @@ -973,6 +973,11 @@ describe("ProviderSettingsManager", () => { }), ) + // The typed not-found signal is the contract callers branch on: a profile + // name containing "not found" must not be matchable via message text. + await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) await expect(providerSettingsManager.deleteConfig("nonexistent")).rejects.toThrow( "Config 'nonexistent' not found", ) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 90db81d8a1..b8dff9c0ed 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -331,6 +331,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 9882039ad3..29c9497552 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -98,6 +98,14 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { for (const [key, rawValue] of Object.entries(rawGlobalSettings)) { const path = `globalSettings.${key}` + + // Per-view selection state is machine-local: it round-trips through the + // normal runtime and pruning paths, but importing it would pin selections + // from another machine's views on this one. + if (key === "viewStates") { + continue + } + const schema = globalSettingsShape[key as keyof GlobalSettings] if (!schema) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3d88df632b..f5f09244ff 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -59,6 +59,7 @@ import { isRetiredProvider, modelIdKeys, providerIdentifiers, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -112,7 +113,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" @@ -139,6 +140,14 @@ import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +type PersistedViewState = NonNullable[string] + +/** + * Values that can be held in a view-local state buffer (in-memory) and, for the + * non-secret subset, persisted durably per stable view id. + */ +type ViewLocalStateValues = Partial & Partial + /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -219,6 +228,10 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 + private static readonly MAX_PERSISTED_VIEW_STATES = 50 + private static readonly MAX_VIEW_STATE_ID_LENGTH = 64 + private static persistedViewStateWriteQueue: Promise = Promise.resolve() private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< @@ -351,6 +364,45 @@ 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. + * A durable entry under this id is bound to the webview that wrote it by a host-minted + * claim token (see setViewStateId), so the entry - and the profile its pin brings back + * into the state post - can outlive this instance without becoming adoptable by any + * other webview. + */ + private viewStateId: string + + /** + * Whether this instance wrote an entry under its own session-local temporary id. + * Temporary ids restart from 0 on every extension host start, so an entry under the + * same name can belong to a different session. Only re-key entries this instance wrote. + */ + private wroteUnderTemporaryViewStateId = false + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + + /** + * This view's pinned profile name from the view-local buffer. Exposed for + * sibling-instance inspection (getAllInstances() filtering): `viewLocalState` + * is private and must not be reached through bracket access. + */ + get pinnedProfileName(): string | undefined { + return this.viewLocalState.currentApiConfigName + } + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "oct-2026-v3.90.0-providers-tools-reasoning" // v3.90.0 providers and models, safer tool execution, reasoning and streaming @@ -366,14 +418,17 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService void this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -398,6 +453,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -520,6 +578,462 @@ export class ClineProvider } } + /** + * Reads the registered viewStates map, returning a defensive copy. + * When fresh is set, the map is read directly from globalState (bypassing the + * ContextProxy cache) so serialized writes never observe a stale in-memory value. + */ + private getPersistedViewStates(options: { fresh?: boolean } = {}): Record { + const viewStates = options.fresh + ? this.context.globalState.get("viewStates") + : this.contextProxy.getValue("viewStates") + + if (!viewStates || typeof viewStates !== "object" || Array.isArray(viewStates)) { + return Object.create(null) + } + + // Copy onto a null prototype. A view id that names an Object.prototype key + // ("constructor", "toString", "valueOf", ...) must not resolve through the + // prototype chain and be mistaken for a persisted entry. + return Object.assign(Object.create(null), viewStates) + } + + /** + * Persists this view's non-secret selections through the serialized write queue. + * The write re-reads the map fresh and merges into the existing entry, removing the + * entry entirely when nothing persistable remains, so concurrent views cannot clobber it. + * The entry is keyed by the view id active when the change was made. Writes captured + * while the provider still holds its temporary (pre-launch) id persist under that id + * and are re-keyed to the stable view id when the webview registers one, so a change + * that lands before the launch message stays durable instead of being lost. + */ + private async savePersistedViewState(values: Partial): Promise { + // Capture the id at change time: a write belongs to the view that was active + // when the change was made, even if a newer id is registered while it is queued. + const viewStateId = this.viewStateId + // Set by the queued write when it had to mint a claim, delivered after it lands. + let mintedClaimToken: string | undefined + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + // The first write to an unowned temporary id claims the slot instead of merging + // into it: temporary names restart from 0 on every host start, so an entry under + // this name can be a leftover from an earlier session, and merging would adopt its + // fields and later re-key them under the stable id. Entries under a stable id, or + // under the temporary id this instance already authored, keep the merge semantics. + const ownsTargetEntry = viewStateId !== this.viewId || this.wroteUnderTemporaryViewStateId + const current = ownsTargetEntry ? (states[viewStateId] ?? {}) : {} + const next: PersistedViewState = { ...current } + + if ("mode" in values) { + if (values.mode === undefined || values.mode === null) { + delete next.mode + } else { + next.mode = values.mode + } + } + + if ("currentApiConfigName" in values) { + if (values.currentApiConfigName === undefined || values.currentApiConfigName === null) { + delete next.currentApiConfigName + } else { + next.currentApiConfigName = values.currentApiConfigName + } + } + + let wroteEntry = false + if (!next.mode && !next.currentApiConfigName) { + delete states[viewStateId] + } else { + next.updatedAt = values.updatedAt ?? Date.now() + // Bind the entry to this webview. An existing entry keeps the token it was + // adopted with (so a reload can still prove ownership); a new entry - or one + // written before claims existed - gets a fresh host-minted token. The token is + // never taken from `values`: it is host-owned, and webview input must not be + // able to choose or supply one. + if (!next.claimToken) { + next.claimToken = crypto.randomUUID() + mintedClaimToken = next.claimToken + } + states[viewStateId] = next + wroteEntry = true + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + // Authorship is recorded only after the durable write lands: a failed persistence + // must not mark the temporary id as owned, or a later load would adopt an entry + // that was never actually stored. + if (wroteEntry && viewStateId === this.viewId) { + this.wroteUnderTemporaryViewStateId = true + } + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + + // Delivered only once the durable write landed: a webview must never hold a claim the + // host did not store, or the next launch would present a token the entry cannot match. + if (mintedClaimToken) { + this.postViewStateClaimToWebview(viewStateId, mintedClaimToken) + } + } + + /** + * Removes the given view's entry from the registered viewStates map. + * Runs through the serialized write queue to avoid racing concurrent view-state writes. + */ + private async clearPersistedViewState(viewStateId = this.viewStateId): Promise { + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + delete states[viewStateId] + await this.contextProxy.setValue("viewStates", states) + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Re-binds a claim-less viewStates entry to this webview with a freshly minted claim. + * Entries written before claims existed cannot be attributed to any webview, so their + * selections are dropped instead of being carried over: keeping them would let the new + * token re-expose them on a later launch, which is exactly the disclosure this binding + * exists to prevent. Runs through the serialized write queue like every other mutation. + */ + private async rebindUnboundViewStateEntry(viewStateId: string): Promise { + const claimToken = crypto.randomUUID() + let rebound = false + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + const states = this.getPersistedViewStates({ fresh: true }) + const current = states[viewStateId] + // A concurrent registration may have bound this key while this write was queued: + // never overwrite an existing claim, and let the caller refuse the stale adoption. + if (current && typeof current.claimToken === "string" && current.claimToken) { + return + } + states[viewStateId] = { claimToken, updatedAt: Date.now() } + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + rebound = true + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + + return rebound ? claimToken : undefined + } + + /** + * Delivers the host-minted claim that governs a durable viewStates key to this view's + * webview. The webview only stores it and echoes it back on the next launch: it can + * never mint or choose a claim itself. + */ + private postViewStateClaimToWebview(viewStateId: string, claimToken: string): void { + void this.postMessageToWebview({ type: "viewStateClaim", viewStateId, viewStateClaim: claimToken }) + } + + /** + * 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. An entry this instance never wrote is + * left alone, so a stale entry that happens to carry the same session-local name is + * not adopted. + */ + private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { + const previousViewStateId = this.viewId + + const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + // An entry under this name can belong to a different session, because temporary + // ids restart from 0 on every extension host start. Only re-key entries this + // instance wrote, otherwise a stale entry from another session is adopted. + if (!this.wroteUnderTemporaryViewStateId) { + return + } + + const states = this.getPersistedViewStates({ fresh: true }) + const previous = states[previousViewStateId] + + if (!previous) { + return + } + + delete states[previousViewStateId] + + if (!states[nextViewStateId]) { + states[nextViewStateId] = previous + } + + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + + // Only a successful write clears authorship: a failed attempt leaves the entry + // under the temporary name, and a retry must still be able to re-key it. + this.wroteUnderTemporaryViewStateId = false + }) + + ClineProvider.persistedViewStateWriteQueue = write.catch(() => {}) + await write + } + + /** + * Registers this provider's stable view identifier and loads any persisted selections it owns. + * The identifier is sanitized so it remains a safe object key in the shared viewStates map. + * + * `presentedClaim` is the host-minted token this webview was given for that id. It is the + * only proof of ownership: an entry that carries a claim is loaded only when the presented + * token matches it, so a webview cannot adopt another (e.g. disposed, still durable) view's + * id and read that view's pinned profile - and the profile secrets copied into the state + * post - back out of it. The check runs before any re-keying or loading. + */ + public async setViewStateId(viewStateId: string | undefined, presentedClaim?: string): Promise { + // The webview generates this id on purpose - it has to survive a webview reload, + // which recreates the view on both sides - so the host cannot mint it instead. + // What the host owns is which view may hold a given persisted key, so everything + // below validates the supplied value before it becomes one. + if (typeof viewStateId !== "string") { + // A crafted postMessage can carry any JSON value here even though the message + // type declares a string; the persisted key must stay a string. + this.log(`[setViewStateId] Ignoring a non-string viewStateId for view ${this.viewId}.`) + return + } + + const normalizedViewStateId = viewStateId.trim().replace(/[^A-Za-z0-9_-]/g, "_") + + if ( + !normalizedViewStateId || + normalizedViewStateId === this.viewStateId || + // Reject "__proto__" and any other prototype-chain key. The map is copied + // onto a null prototype, so these names can no longer resolve through the + // chain, but they are still rejected as ids so a caller cannot store an + // entry under a name that looks like an inherited member. + normalizedViewStateId === "__proto__" + ) { + return + } + + // Bound the key: the webview generates a UUID, so a much longer value is not a + // view id, and an unbounded key is unbounded growth in the shared viewStates map. + if (normalizedViewStateId.length > ClineProvider.MAX_VIEW_STATE_ID_LENGTH) { + this.log( + `[setViewStateId] Ignoring an oversized viewStateId (${normalizedViewStateId.length} chars) for view ${this.viewId}.`, + ) + return + } + + const claimant = ClineProvider.getAllInstances().find( + (instance) => instance !== this && instance.viewStateId === normalizedViewStateId, + ) + if (claimant) { + // Ownership check: a key another live view holds must not be adopted. Doing so + // would load that view's pinned profile, nested overlay, and mode into this + // provider, and this webview could then overwrite that view's persisted entry. + // Staying on the temporary id keeps this view working against the shared state. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: already claimed by live view ${claimant.viewId}.`, + ) + return + } + + // Claim binding, checked before any re-key or load so a refused registration can + // neither read nor mutate a persisted entry. Read fresh from storage rather than + // through the ContextProxy cache: a security check must not trust a stale copy. + const persistedEntry = this.getPersistedViewStates({ fresh: true })[normalizedViewStateId] + + if (persistedEntry) { + const storedClaim = persistedEntry.claimToken + + if (typeof storedClaim !== "string" || !storedClaim) { + // A pre-claim entry cannot be attributed to any webview, so its selections are + // never loaded. Re-establish the binding with a fresh host-minted claim and hand + // that claim to this webview: the key becomes adoptable again (no upgrade brick) + // and the unbound selections are gone, so the new token cannot resurrect them. + const reboundClaim = await this.rebindUnboundViewStateEntry(normalizedViewStateId) + + if (reboundClaim === undefined) { + // A concurrent registration bound the key while the re-bind was queued: this + // launch adopts a key another webview now owns, so refuse it instead of + // handing out a claim that is not ours. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: bound concurrently by another registration.`, + ) + return + } + + this.postViewStateClaimToWebview(normalizedViewStateId, reboundClaim) + this.log( + `[setViewStateId] Re-bound claim-less viewStates entry ${normalizedViewStateId} for view ${this.viewId}: unbound selections were not loaded.`, + ) + } else if (typeof presentedClaim !== "string" || presentedClaim !== storedClaim) { + // Fail closed: the entry belongs to a webview that never presented this view's + // claim. Rejection is side-effect free, so presenting an id cannot probe, mutate + // or delete another view's entry. + this.log( + `[setViewStateId] Refusing viewStateId ${normalizedViewStateId} for view ${this.viewId}: claim missing or mismatched.`, + ) + return + } + } + + const previousViewStateId = this.viewStateId + + this.viewStateId = normalizedViewStateId + + try { + // Re-key any durable entry written under the temporary pre-launch id before + // loading, so the load sees the view's own pre-registration selections. + await this.rekeyPersistedViewStateEntry(this.viewStateId) + + await this.loadViewState() + + // Re-affirm the binding for the id just adopted: a claim minted while this view was + // still on its temporary id now governs this key (the entry was re-keyed), so the + // webview is told the canonical (viewStateId, claim) pair to echo on its next launch. + const adoptedClaim = this.getPersistedViewStates({ fresh: true })[this.viewStateId]?.claimToken + + if (adoptedClaim) { + this.postViewStateClaimToWebview(this.viewStateId, adoptedClaim) + } + } catch (error) { + // A persistence failure must not leave the provider holding an id that was + // never registered: restore the previous id so a later launch retries the + // registration and the load instead of the guard above early-returning for + // the failed id. + this.viewStateId = previousViewStateId + throw error + } + } + + /** + * Loads non-secret persisted selections from the registered viewStates map. + * Missing entries are intentionally left unset so getState() falls back to shared ContextProxy values. + * Fields mutated while the async profile lookup is in flight are reapplied on top of the + * loaded state, field by field, so in-flight user selections are not clobbered by the load. + */ + private async loadViewState(): Promise { + // Capture the id this load is for: a newer id registered while an async + // profile lookup is in flight must not be overwritten by this stale load. + const loadedForViewId = this.viewStateId + + // While the stable id has not been registered yet, the key is this instance's + // session-local name. Temporary names restart from 0 on every host start, so an + // entry under the same name can belong to a different session. Load only entries + // this instance authored. + if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { + return + } + try { + const persisted = this.getPersistedViewStates()[loadedForViewId] + const loadedState: Partial = {} + + // Snapshot the in-memory buffer before the async profile lookup. The + // mutation paths update viewLocalState in place, so a shallow copy is + // what makes fields mutated during the load window observable below. + const preLoadBuffer = { ...this.viewLocalState } + + if (persisted?.mode) { + // A persisted mode may reference a custom mode that was deleted after it was + // pinned: restore it only when the slug still resolves, so a stale slug cannot + // shadow the shared mode from getState(). + const customModes = await this.customModesManager.getCustomModes() + if (getModeBySlug(persisted.mode, customModes)) { + loadedState.mode = persisted.mode as Mode + } else { + this.log(`[loadViewState] Ignoring unknown persisted mode "${persisted.mode}"`) + } + } + + if (persisted?.currentApiConfigName) { + loadedState.currentApiConfigName = persisted.currentApiConfigName + + try { + const { name: _name, ...apiConfiguration } = await this.providerSettingsManager.getProfile({ + name: persisted.currentApiConfigName, + }) + loadedState.apiConfiguration = apiConfiguration as ProviderSettings + } catch (error) { + this.log( + `[loadViewState] Unable to resolve API profile '${persisted.currentApiConfigName}' for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (this.viewStateId !== loadedForViewId) { + this.log(`[loadViewState] Discarding stale state for superseded view id ${loadedForViewId}`) + return + } + + // Reapply the buffer fields that changed while the load was in flight, + // tracking the change instead of testing against undefined: a field cleared + // during the load window must stay cleared (the loaded value must not + // resurrect it), and a field written to a new value must win over it. + // Untouched fields keep the persisted values authoritative, and the + // pre-load buffer is never merged wholesale so stale temporary-id state + // cannot override the stable persisted state. + const postLoadBuffer = this.viewLocalState + const mergedState: Partial = { ...loadedState } + + if (!Object.is(preLoadBuffer.mode, postLoadBuffer.mode)) { + if (postLoadBuffer.mode === undefined) { + delete mergedState.mode + } else { + mergedState.mode = postLoadBuffer.mode + } + } + + if (!Object.is(preLoadBuffer.currentApiConfigName, postLoadBuffer.currentApiConfigName)) { + if (postLoadBuffer.currentApiConfigName === undefined) { + delete mergedState.currentApiConfigName + } else { + mergedState.currentApiConfigName = postLoadBuffer.currentApiConfigName + } + } + + if (!Object.is(preLoadBuffer.apiConfiguration, postLoadBuffer.apiConfiguration)) { + if (postLoadBuffer.apiConfiguration === undefined) { + delete mergedState.apiConfiguration + } else { + mergedState.apiConfiguration = postLoadBuffer.apiConfiguration + } + } + + this.viewLocalState = mergedState + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Saves a single view-local state value. The in-memory buffer is always updated; the + * non-secret subset (mode, currentApiConfigName) is persisted durably under the view + * id active when the change was made, re-keyed to the stable id on registration. + */ + public async saveViewState( + key: K, + value: ViewLocalStateValues[K] | undefined, + ): Promise { + await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -847,7 +1361,6 @@ export class ClineProvider ) } } - async dispose() { if (this._disposed) { return @@ -1232,7 +1745,6 @@ export class ClineProvider ) return run } - private async createTaskWithHistoryItemUnlocked( historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }, options?: { startTask?: boolean }, @@ -1469,7 +1981,6 @@ export class ClineProvider return task } - public async postMessageToWebview(message: ExtensionMessage) { if (this._disposed) { return @@ -1725,7 +2236,6 @@ export class ClineProvider this.handleModeSwitchUnlocked(newMode, targetTask, signal), ) } - private async handleModeSwitchUnlocked( newMode: Mode, targetTask: Task | null | undefined, @@ -2033,9 +2543,17 @@ export class ClineProvider // `allSettled` so a caller that rolls back after a failure never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.updateGlobalState("currentApiConfigName", name), + // 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: also refresh this view's buffer so a stale + // loaded apiConfiguration cannot keep shadowing the new settings in + // getState(). + this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", @@ -2045,6 +2563,10 @@ export class ClineProvider if (signal.aborted) return id + // Other live views may have buffered this profile's settings earlier; + // refresh them so their getState() cannot report the updated profile's + // name with stale settings. + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -2053,6 +2575,21 @@ 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 }) + // 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 }) + } + } } if (signal.aborted) return id @@ -2060,7 +2597,6 @@ export class ClineProvider await this.postStateToWebview() return id } - async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName @@ -2126,7 +2662,6 @@ export class ClineProvider this.activateProviderProfileUnlocked(args, options, signal), ) } - private async activateProviderProfileUnlocked( args: { name: string } | { id: string }, options?: { @@ -2249,9 +2784,6 @@ export class ClineProvider await this.upsertProviderProfile(currentApiConfigName, newConfiguration) } - - // Zoo Code Auth - async handleZooCodeCallback(token: string) { // Auth mutation (token storage, subscription check, success toast) was already // performed by handleAuthCallback() in handleUri.ts before this method was called. @@ -3301,7 +3833,6 @@ export class ClineProvider private getGlobalState(key: K) { return this.contextProxy.getValue(key) } - public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) } @@ -3309,17 +3840,53 @@ export class ClineProvider public getValue(key: K) { return this.contextProxy.getValue(key) } - public getValues() { return this.contextProxy.getValues() } - public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) } - // dev + /** + * 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) + } + + /** + * 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 = {} + } async resetState() { const answer = await vscode.window.showInformationMessage( t("common:confirmation.reset_state"), @@ -4651,4 +5218,54 @@ export class ClineProvider return vscode.Uri.file(filePath).toString() } } + + /** + * 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, + } + } + } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 93543435e2..478cc76393 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, @@ -28,12 +29,15 @@ 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" +import { t } from "../../../i18n" import { ClineProvider } from "../ClineProvider" import { webviewMessageHandler } from "../webviewMessageHandler" +import type { MdmService } from "../../../services/mdm/MdmService" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" @@ -419,6 +423,52 @@ afterAll(() => { vi.restoreAllMocks() }) +/** + * Minimal profile shape the stalled getProfile double resolves to. The + * settings fields are optional so a lookup that resolves name-only (a + * profile with no configured provider) also type-checks. + */ +type StalledProfile = { + name: string + apiProvider?: string + openRouterModelId?: string +} + +/** + * Swap the provider's ProviderSettingsManager for a double whose getProfile + * stalls until the test resolves it, so tests can mutate view state while + * loadViewState is in flight. The double only backs getProfile, the member + * loadViewState awaits; the documented @ts-ignore replaces the per-test + * suppressions the inlined copies used. + */ +function stallProviderSettingsProfile(provider: ClineProvider) { + let resolveProfile: (value: StalledProfile) => void = () => {} + const getProfileSpy = vi.fn(() => { + // Only one lookup is resolvable: resolveProfile is bound to the first + // promise's resolver, so a second getProfile would strand its promise. + // Fail loudly instead of hanging. + if (getProfileSpy.mock.calls.length > 1) { + throw new Error( + "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", + ) + } + return new Promise((resolve) => (resolveProfile = resolve)) + }) + // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // Return a stable wrapper around the closure binding: resolveProfile is + // reassigned to the pending promise's resolver once getProfile is called, + // so returning the variable directly would hand the test the initial no-op. + return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } +} + +/** + * The host-minted claim stored with a durable view-states entry. A test that adopts an + * entry another provider wrote has to present it, exactly like a webview does on launch. + */ +function persistedViewStateClaim(context: vscode.ExtensionContext, viewStateId: string): string | undefined { + return context.globalState.get("viewStates")?.[viewStateId]?.claimToken +} describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { From 7906df6ccda71c01620ed7d46a870ed317f79024 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:58:44 +0800 Subject: [PATCH 02/51] test(webview): carry the identity and durable viewStates tests with N2 N2 spec now includes the N1 identity tests plus the persistence/load/prune/rekey/export-import coverage; the setValues buffer test moves to the buffer unit. --- .../webview/__tests__/ClineProvider.spec.ts | 833 +++++++++++++++++- 1 file changed, 810 insertions(+), 23 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 67336deb47..4bb3e49635 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,4 +1,4 @@ -// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts import fs from "fs" import * as path from "path" @@ -29,15 +29,12 @@ 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" -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" @@ -423,24 +420,12 @@ 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(() => { @@ -462,13 +447,6 @@ function stallProviderSettingsProfile(provider: ClineProvider) { return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } } -/** - * The host-minted claim stored with a durable view-states entry. A test that adopts an - * entry another provider wrote has to present it, exactly like a webview does on launch. - */ -function persistedViewStateClaim(context: vscode.ExtensionContext, viewStateId: string): string | undefined { - return context.globalState.get("viewStates")?.[viewStateId]?.claimToken -} describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -3628,6 +3606,815 @@ describe("ClineProvider", () => { ]) }) }) + + describe("setViewStateId", () => { + it('should ignore "__proto__" and keep the temporary viewId', async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("__proto__") + + // "__proto__" is rejected before assignment so a per-view entry can never be + // keyed through the Object.prototype setter: the temporary id stays active and + // nothing is persisted under the reserved name. + expect(provider["viewStateId"]).toBe(provider.viewId) + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"]).toEqual({}) + + await provider.dispose() + }) + + it("restores the previous view id when the registration write fails so a later launch retries", async () => { + const contextProxy = new ContextProxy(mockContext) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + contextProxy, + new WebviewFocusTracker(), + ) + // Seed a pre-launch entry under the temporary id through the real write path, + // so the re-key has real work to do and records this instance as its author. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + + const setValueSpy = vi.spyOn(contextProxy, "setValue").mockRejectedValue(new Error("storage down")) + + await expect(provider["setViewStateId"]("stable-sidebar-view")).rejects.toThrow("storage down") + + // The failed id must not stick: the provider keeps its previous (temporary) + // id so a later launch retries registration and the load instead of the + // guard early-returning for an id that was never persisted. + expect(provider["viewStateId"]).toBe(provider.viewId) + + // A later retry succeeds once the storage write works again, and the + // pre-launch entry lands under the registered id. + setValueSpy.mockRestore() + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewStateId"]).toBe("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "architect", updatedAt: 1, claimToken: expect.any(String) }, + }) + + await provider.dispose() + }) + }) + + describe("view state claim binding", () => { + it("refuses a viewStateId bound by a concurrent registration through setViewStateId", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const beforeViewStateId = provider["viewStateId"] + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + // A claim-less entry is adoptable at the first read, but a concurrent registration + // binds the key while this launch's queued re-bind is waiting its turn. + await provider.contextProxy.setValue("viewStates", { "legacy-view": { updatedAt: 1 } }) + ClineProvider["persistedViewStateWriteQueue"] = Promise.resolve().then(async () => { + await provider.contextProxy.setValue("viewStates", { + "legacy-view": { claimToken: "winner", updatedAt: 2 }, + }) + }) + + await provider["setViewStateId"]("legacy-view") + + // The launch must not adopt a key another webview now owns: no claim is delivered, + // the provider stays on its temporary id, and the winner's claim is untouched. + expect(postSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "viewStateClaim" })) + expect(provider["viewStateId"]).toBe(beforeViewStateId) + const states = provider.contextProxy.getValue("viewStates") as Record + expect(states["legacy-view"].claimToken).toBe("winner") + ClineProvider["persistedViewStateWriteQueue"] = Promise.resolve() + postSpy.mockRestore() + await provider.dispose() + }) + + it("does not overwrite a claim bound by a concurrent registration", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { "legacy-view": { updatedAt: 1 } }) + // A concurrent registration binds this key while the re-bind write is queued. + ClineProvider["persistedViewStateWriteQueue"] = Promise.resolve().then(async () => { + await provider.contextProxy.setValue("viewStates", { + "legacy-view": { claimToken: "winner", updatedAt: 2 }, + }) + }) + + const claim = await provider["rebindUnboundViewStateEntry"]("legacy-view") + + // The queued re-bind must not replace the claim the other registration wrote; it + // reports that nothing was rebound so the caller can refuse the stale adoption. + expect(claim).toBeUndefined() + const states = provider.contextProxy.getValue("viewStates") as Record + expect(states["legacy-view"].claimToken).toBe("winner") + ClineProvider["persistedViewStateWriteQueue"] = Promise.resolve() + await provider.dispose() + }) + + const createProvider = () => + new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const storedEntries = () => + mockContext.globalState.get>("viewStates") ?? {} + + test("refuses a disposed view's id when no claim is presented, and loads nothing", async () => { + // The vulnerable path: a view registers a durable entry, is disposed, and a second + // webview presents the same id. dispose() removes the instance but leaves the entry, + // so only the claim can stop the second webview from loading the first one's pinned + // profile (and the profile's secrets) through the state post. + const owner = createProvider() + await owner["setViewStateId"]("closed-view") + await owner.saveViewState("mode", "architect") + await owner.saveViewState("currentApiConfigName", "profile-with-secrets") + + const storedClaim = storedEntries()["closed-view"]?.claimToken + expect(storedClaim).toEqual(expect.any(String)) + + await owner.dispose() + + const intruder = createProvider() + const postMessageSpy = vi.spyOn(intruder, "postMessageToWebview").mockResolvedValue(undefined) + + await intruder["setViewStateId"]("closed-view") + + // Fail closed: the id is not adopted, no selections reach this view and no state is + // posted back to the webview that asked for it. + expect(intruder["viewStateId"]).toBe(intruder.viewId) + expect(intruder["viewLocalState"]).toStrictEqual({}) + expect((await intruder.getState()).currentApiConfigName).not.toBe("profile-with-secrets") + expect(postMessageSpy).not.toHaveBeenCalled() + + // A refused adoption is side-effect free: presenting an id must not probe or rewrite + // the entry that a different webview owns. + expect(storedEntries()["closed-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-with-secrets", + claimToken: storedClaim, + }) + + await intruder.dispose() + }) + + test("refuses a mismatched claim", async () => { + const owner = createProvider() + await owner["setViewStateId"]("closed-view") + await owner.saveViewState("currentApiConfigName", "profile-with-secrets") + await owner.dispose() + + const intruder = createProvider() + const postMessageSpy = vi.spyOn(intruder, "postMessageToWebview").mockResolvedValue(undefined) + + // A webview-generated value is not a claim: only the token the host minted matches. + await intruder["setViewStateId"]("closed-view", "webview-chosen-claim") + + expect(intruder["viewStateId"]).toBe(intruder.viewId) + expect(intruder["viewLocalState"]).toStrictEqual({}) + expect(postMessageSpy).not.toHaveBeenCalled() + + await intruder.dispose() + }) + + test("does not churn the claim on later writes to the same entry", async () => { + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + await provider["setViewStateId"]("minted-view") + await provider.saveViewState("mode", "architect") + + const mintedClaim = storedEntries()["minted-view"]?.claimToken + expect(mintedClaim).toEqual(expect.any(String)) + + await provider.saveViewState("currentApiConfigName", "profile-b") + + // A merge into an entry this view already owns keeps its token, so the claim the + // webview holds keeps working and is not re-sent. + expect(storedEntries()["minted-view"]?.claimToken).toBe(mintedClaim) + expect( + postMessageSpy.mock.calls.map(([message]) => message).filter((m) => m.type === "viewStateClaim"), + ).toHaveLength(1) + }) + + test("re-binds a claim-less legacy entry instead of loading its selections", async () => { + // An entry written before claims existed cannot be attributed to any webview: its + // selections are refused, and the key is re-bound with a fresh host-minted claim so + // the upgrade does not brick per-view persistence. + await provider.contextProxy.setValue("viewStates", { + "legacy-view": { mode: "architect", currentApiConfigName: "profile-a", updatedAt: 1 }, + }) + + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider["setViewStateId"]("legacy-view") + + // No stored selection is adopted... + expect(provider["viewStateId"]).toBe("legacy-view") + expect(provider["viewLocalState"]).toStrictEqual({}) + + // ...the old entry is replaced by a claim-bearing one with the unbound selections gone... + const rebound = storedEntries()["legacy-view"] + expect(rebound).toEqual({ claimToken: expect.any(String), updatedAt: expect.any(Number) }) + expect(rebound).not.toHaveProperty("mode") + expect(rebound).not.toHaveProperty("currentApiConfigName") + + // ...and the fresh claim is handed to this webview so the next launch can prove ownership. + expect(postMessageSpy).toHaveBeenCalledWith({ + type: "viewStateClaim", + viewStateId: "legacy-view", + viewStateClaim: rebound?.claimToken, + }) + + await provider.dispose() + + const reopened = createProvider() + await reopened["setViewStateId"]("legacy-view", rebound?.claimToken) + + expect(reopened["viewStateId"]).toBe("legacy-view") + expect(reopened["viewLocalState"]).toStrictEqual({}) + + await reopened.dispose() + }) + + test("still refuses an id a live view holds, even when the claim matches", async () => { + const owner = createProvider() + await owner["setViewStateId"]("shared-live-view") + await owner.saveViewState("mode", "architect") + const claim = storedEntries()["shared-live-view"]?.claimToken + + const sibling = createProvider() + + try { + await sibling["setViewStateId"]("shared-live-view", claim) + + // The live-instance guard runs first and keeps working: a second live view never + // shares a key with the view that holds it. + expect(sibling["viewStateId"]).toBe(sibling.viewId) + expect(sibling["viewLocalState"]).toStrictEqual({}) + expect(owner["viewStateId"]).toBe("shared-live-view") + } finally { + await sibling.dispose() + await owner.dispose() + } + }) + }) + + describe("view state persistence edge cases", () => { + it("should read viewStates from the ContextProxy cache when not fresh", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("viewStates", { "stable-sidebar-view": { mode: "architect" } }) + expect(provider["getPersistedViewStates"]()).toEqual({ "stable-sidebar-view": { mode: "architect" } }) + await provider.dispose() + }) + + it("should treat a corrupted non-object viewStates value as an empty map", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // A string in storage is corrupt: the fresh-read guard must not spread it. + await mockContext.globalState.update("viewStates", "corrupted-storage-value") + expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) + await provider.dispose() + }) + + it("should merge saved fields, drop cleared fields and delete emptied entries", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + const states = () => mockContext.globalState.get>("viewStates") ?? {} + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "profile-a") + const merged = states()["stable-sidebar-view"] + expect(merged).toMatchObject({ mode: "architect", currentApiConfigName: "profile-a" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Saved mode for viewId")) + await save("mode", undefined) + expect(states()["stable-sidebar-view"]).toStrictEqual({ + currentApiConfigName: "profile-a", + updatedAt: expect.any(Number), + claimToken: expect.any(String), + }) + 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), + claimToken: expect.any(String), + }) + await provider.saveViewState("currentApiConfigName", "profile-c") + await provider.saveViewState("mode", "architect") + expect(states()["stable-sidebar-view"]).toMatchObject({ + mode: "architect", + currentApiConfigName: "profile-c", + }) + await save("currentApiConfigName", null) + expect(states()["stable-sidebar-view"]).not.toHaveProperty("currentApiConfigName") + await save("mode", null) + expect(states()["stable-sidebar-view"]).toBeUndefined() + expect(provider["viewLocalState"]).toStrictEqual({}) // buffer ends fully cleared + await provider.dispose() + }) + + it("should rekey a pre-launch entry under the temporary id to the registered stable id", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Seed through the write path so this instance is the author of the entry under + // its temporary id; an entry authored by another session must not be adopted. + await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + await provider["setViewStateId"]("stable-sidebar-view") + expect(mockContext.globalState.get("viewStates")).toEqual({ + // The minted claim travels with the re-keyed entry, so the webview's token keeps working. + "stable-sidebar-view": { mode: "architect", updatedAt: 1, claimToken: expect.any(String) }, + }) + await provider.dispose() + }) + + it("should keep the stable entry and drop the temporary entry when both exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Author the temporary entry through the write path so this instance owns it, + // then add a stable entry that already exists in storage. + await provider["savePersistedViewState"]({ mode: "temp-mode", updatedAt: 1 }) + await provider.contextProxy.setValue("viewStates", { + [provider.viewId]: { mode: "temp-mode", updatedAt: 1 }, + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5, claimToken: "stable-claim" }, + }) + await provider["setViewStateId"]("stable-sidebar-view", "stable-claim") + expect(mockContext.globalState.get("viewStates")).toEqual({ + "stable-sidebar-view": { mode: "stable-mode", updatedAt: 5, claimToken: "stable-claim" }, + }) + await provider.dispose() + }) + + it("should prune by updatedAt regardless of insertion order", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: (index * 7) % 55 }, + ]), + ) + const pruned = provider["prunePersistedViewStates"](states) + expect(Object.keys(pruned)).toHaveLength(50) + // view-1/view-54 survive the true newest-50 selection; view-8 (updatedAt 1) does not. + expect(pruned["view-1"]).toEqual({ mode: "mode-1", updatedAt: 7 }) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 48 }) + expect(pruned["view-8"]).toBeUndefined() + await provider.dispose() + }) + + it("should log a successful empty load when no persisted entry exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).toEqual({}) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should keep the persisted profile name and log when the profile lookup fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a failing test double. + provider.providerSettingsManager = { getProfile: vi.fn().mockRejectedValue(new Error("profile missing")) } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unable to resolve API profile 'my-profile'")) + await provider.dispose() + }) + + it("should discard a stale load when the viewStateId changes during the profile lookup", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace providerSettingsManager with a test double that registers a newer id. + provider.providerSettingsManager = { + getProfile: vi.fn().mockImplementation(() => { + provider["viewStateId"] = "superseded-view" + return Promise.resolve({ name: "my-profile", apiProvider: providerIdentifiers.openrouter }) + }), + } + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider["setViewStateId"]("stable-sidebar-view") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + const staleMsg = expect.stringContaining("Discarding stale state for superseded view id") + expect(logSpy).toHaveBeenCalledWith(staleMsg) + await provider.dispose() + }) + + it("should not restore a persisted mode whose custom mode no longer exists", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + // The file-level modes mock resolves every slug to a mode; drop the pinned slug. + const modesModule = vi.mocked(await import("../../../shared/modes")) + const originalMode = modesModule.getModeBySlug("code") + modesModule.getModeBySlug.mockImplementation(((slug: string) => + slug === "deleted-custom-mode" ? undefined : originalMode) as typeof modesModule.getModeBySlug) + try { + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("mode", "deleted-custom-mode") + // Reload the persisted entry: the custom mode was deleted in the meantime. + await provider["loadViewState"]() + // The stale slug must not be restored into the buffer. + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Ignoring unknown persisted mode "deleted-custom-mode"'), + ) + } finally { + modesModule.getModeBySlug.mockReturnValue(originalMode) + } + await provider.dispose() + }) + + it("should keep every persisted field authoritative when the load is untouched", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const { getProfile: getProfileSpy, resolveProfile } = stallProviderSettingsProfile(provider) + // @ts-ignore - Replace customModesManager with a test double (no custom modes). + provider.customModesManager = { getCustomModes: vi.fn().mockResolvedValue([]), dispose: vi.fn() } + await provider.saveViewState("mode", "code") + await provider.saveViewState("currentApiConfigName", "cfg-a") + const load = provider["setViewStateId"]("stable-sidebar-view") + + // Let the stalled lookup begin so the in-flight mutation and the resolver + // target the pending promise rather than the initial no-op. + await vi.waitFor(() => expect(getProfileSpy).toHaveBeenCalledTimes(1)) + // No mutation while the lookup is in flight: the persisted values must win. + resolveProfile({ name: "cfg-a", apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }) + await load + + expect(provider["viewLocalState"]).toEqual({ + mode: "code", + currentApiConfigName: "cfg-a", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + await provider.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState and persist mode through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await provider["setViewStateId"]("stable-sidebar-view") + + await provider.saveViewState("mode", "architect") + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + }) + expect(contextProxySpy).toHaveBeenCalledWith( + "viewStates", + expect.objectContaining({ + "stable-sidebar-view": expect.objectContaining({ + mode: "architect", + updatedAt: expect.any(Number), + }), + }), + ) + expect(contextProxySpy).not.toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", expect.anything()) + + await provider.dispose() + }) + + it("should update viewLocalState and persist currentApiConfigName through registered viewStates", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("currentApiConfigName", "my-profile") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "my-profile" }, + }) + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration without persisting provider settings or secrets", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const testApiConfig = { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "claude-3.5-sonnet", + openRouterApiKey: "secret-key", + } + + await provider["setViewStateId"]("stable-sidebar-view") + await provider.saveViewState("apiConfiguration", testApiConfig) + + expect(provider["viewLocalState"].apiConfiguration).toEqual(testApiConfig) + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("mode", "architect") + expect(provider["viewLocalState"].mode).toBe("architect") + + await provider.saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "mode")).toBe(false) + + await provider.dispose() + }) + + it("should clear the currentApiConfigName override when saveViewState receives undefined", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider.saveViewState("currentApiConfigName", "my-profile") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + + await provider.saveViewState("currentApiConfigName", undefined) + + expect(Object.prototype.hasOwnProperty.call(provider["viewLocalState"], "currentApiConfigName")).toBe(false) + + await provider.dispose() + }) + + it("should not update viewLocalState when durable view-state persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("persist failed")) + + await provider["setViewStateId"]("stable-sidebar-view") + + await expect(provider.saveViewState("mode", "architect")).rejects.toThrow("persist failed") + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + expect(provider.contextProxy.getValue("viewStates")).toBeUndefined() + + await provider.dispose() + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("loadViewState", () => { + it("should keep viewLocalState empty when no stable per-view values exist", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await vi.waitFor(() => { + expect(provider["viewLocalState"]).toEqual({}) + }) + + const state = await provider.getState() + // No per-view entry exists and the proxy's global-state cache is empty + // (initialize() is never called in this fixture; only "taskHistory" passes + // through to the context store), so getState() falls back to the shared + // defaults: mode "code" (defaultModeSlug) and currentApiConfigName "default". + expect(state.mode).toBe("code") + expect(state.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + + // Register a stable id so the load is not skipped by the ownership guard: + // only entries this instance authored are loaded under a temporary name. + await provider["setViewStateId"]("stable-sidebar-view") + + provider["viewLocalState"] = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValue").mockImplementation(() => { + throw new Error("load failed") + }) + + await provider["loadViewState"]() + + expect(provider["viewLocalState"].mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("persisted view state pruning", () => { + it("should keep the newest 50 persisted view states", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const states = Object.fromEntries( + Array.from({ length: 55 }, (_, index) => [ + `view-${index}`, + { mode: `mode-${index}`, updatedAt: index }, + ]), + ) + + const pruned = provider["prunePersistedViewStates"](states) + + expect(Object.keys(pruned)).toHaveLength(50) + expect(pruned["view-54"]).toEqual({ mode: "mode-54", updatedAt: 54 }) + expect(pruned["view-5"]).toEqual({ mode: "mode-5", updatedAt: 5 }) + expect(pruned["view-4"]).toBeUndefined() + + await provider.dispose() + }) + }) + + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + + new WebviewFocusTracker(), + ) + const provider2 = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider1["setViewStateId"]("stable-sidebar-view") + await provider2["setViewStateId"]("stable-editor-view") + + await Promise.all([ + provider1.saveViewState("mode", "architect"), + provider2.saveViewState("currentApiConfigName", "editor-profile"), + ]) + + expect(mockContext.globalState.get("viewStates")).toMatchObject({ + "stable-sidebar-view": { mode: "architect" }, + "stable-editor-view": { currentApiConfigName: "editor-profile" }, + }) + + await provider1.dispose() + await provider2.dispose() + }) }) describe("webviewMessageHandler no-floating-promises coverage", () => { From f560872c380d475393ba7206076d8025f7b79b6c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:59:01 +0800 Subject: [PATCH 03/51] chore: untrack generated husky hooks --- .husky/_/post-checkout | 3 --- .husky/_/post-commit | 3 --- .husky/_/post-merge | 3 --- .husky/_/pre-push | 3 --- .husky/pre-commit | 26 -------------------------- .husky/pre-push | 42 ------------------------------------------ 6 files changed, 80 deletions(-) delete mode 100644 .husky/_/post-checkout delete mode 100644 .husky/_/post-commit delete mode 100644 .husky/_/post-merge delete mode 100644 .husky/_/pre-push delete mode 100644 .husky/pre-commit delete mode 100644 .husky/pre-push diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout deleted file mode 100644 index 5abf8ed93f..0000000000 --- a/.husky/_/post-checkout +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-checkout "$@" diff --git a/.husky/_/post-commit b/.husky/_/post-commit deleted file mode 100644 index b8b76c2c42..0000000000 --- a/.husky/_/post-commit +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-commit "$@" diff --git a/.husky/_/post-merge b/.husky/_/post-merge deleted file mode 100644 index 726f909891..0000000000 --- a/.husky/_/post-merge +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-merge "$@" diff --git a/.husky/_/pre-push b/.husky/_/pre-push deleted file mode 100644 index 5f26dc4552..0000000000 --- a/.husky/_/pre-push +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs pre-push "$@" diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index f8f420dac7..0000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,26 +0,0 @@ -branch="$(git rev-parse --abbrev-ref HEAD)" - -if [ "$branch" = "main" ]; then - echo "You can't commit directly to main - please check out a branch." - exit 1 -fi - -# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. -if [ "$OS" = "Windows_NT" ]; then - pnpm_cmd="pnpm.cmd" -else - if command -v pnpm >/dev/null 2>&1; then - pnpm_cmd="pnpm" - else - pnpm_cmd="npx pnpm" - fi -fi - -# Detect if running on Windows and use npx.cmd, otherwise use npx. -if [ "$OS" = "Windows_NT" ]; then - npx_cmd="npx.cmd" -else - npx_cmd="npx" -fi - -$npx_cmd lint-staged diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100644 index 4cf91d9580..0000000000 --- a/.husky/pre-push +++ /dev/null @@ -1,42 +0,0 @@ -branch="$(git rev-parse --abbrev-ref HEAD)" - -if [ "$branch" = "main" ]; then - echo "You can't push directly to main - please check out a branch." - exit 1 -fi - -# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. -if [ "$OS" = "Windows_NT" ]; then - pnpm_cmd="pnpm.cmd" -else - if command -v pnpm >/dev/null 2>&1; then - pnpm_cmd="pnpm" - else - pnpm_cmd="npx pnpm" - fi -fi - -$pnpm_cmd run check-types - -# Use dotenvx to securely load .env.local and run commands that depend on it -if [ -f ".env.local" ]; then - # Check if RUN_TESTS_ON_PUSH is set to true and run tests with dotenvx - if npx dotenvx get RUN_TESTS_ON_PUSH -f .env.local 2>/dev/null | grep -q "^true$"; then - npx dotenvx run -f .env.local -- $pnpm_cmd run test - fi -else - # Fallback: run tests if RUN_TESTS_ON_PUSH is set in regular environment - if [ "$RUN_TESTS_ON_PUSH" = "true" ]; then - $pnpm_cmd run test - fi -fi - -# Check for new changesets. -NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ') -echo "Changeset files: $NEW_CHANGESETS" - -if [ "$NEW_CHANGESETS" = "0" ]; then - echo "-------------------------------------------------------------------------------------" - echo "Changes detected. Please run 'pnpm changeset' to create a changeset if applicable." - echo "-------------------------------------------------------------------------------------" -fi From 634099884ec54f71d4d54743f5b44d7367b0f528 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:59:19 +0800 Subject: [PATCH 04/51] chore: restore main tracked husky hooks --- .husky/pre-commit | 26 ++++++++++++++++++++++++++ .husky/pre-push | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 .husky/pre-commit create mode 100644 .husky/pre-push diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000000..f8f420dac7 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,26 @@ +branch="$(git rev-parse --abbrev-ref HEAD)" + +if [ "$branch" = "main" ]; then + echo "You can't commit directly to main - please check out a branch." + exit 1 +fi + +# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. +if [ "$OS" = "Windows_NT" ]; then + pnpm_cmd="pnpm.cmd" +else + if command -v pnpm >/dev/null 2>&1; then + pnpm_cmd="pnpm" + else + pnpm_cmd="npx pnpm" + fi +fi + +# Detect if running on Windows and use npx.cmd, otherwise use npx. +if [ "$OS" = "Windows_NT" ]; then + npx_cmd="npx.cmd" +else + npx_cmd="npx" +fi + +$npx_cmd lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000000..4cf91d9580 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,42 @@ +branch="$(git rev-parse --abbrev-ref HEAD)" + +if [ "$branch" = "main" ]; then + echo "You can't push directly to main - please check out a branch." + exit 1 +fi + +# Detect if running on Windows and use pnpm.cmd, otherwise use pnpm. +if [ "$OS" = "Windows_NT" ]; then + pnpm_cmd="pnpm.cmd" +else + if command -v pnpm >/dev/null 2>&1; then + pnpm_cmd="pnpm" + else + pnpm_cmd="npx pnpm" + fi +fi + +$pnpm_cmd run check-types + +# Use dotenvx to securely load .env.local and run commands that depend on it +if [ -f ".env.local" ]; then + # Check if RUN_TESTS_ON_PUSH is set to true and run tests with dotenvx + if npx dotenvx get RUN_TESTS_ON_PUSH -f .env.local 2>/dev/null | grep -q "^true$"; then + npx dotenvx run -f .env.local -- $pnpm_cmd run test + fi +else + # Fallback: run tests if RUN_TESTS_ON_PUSH is set in regular environment + if [ "$RUN_TESTS_ON_PUSH" = "true" ]; then + $pnpm_cmd run test + fi +fi + +# Check for new changesets. +NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ') +echo "Changeset files: $NEW_CHANGESETS" + +if [ "$NEW_CHANGESETS" = "0" ]; then + echo "-------------------------------------------------------------------------------------" + echo "Changes detected. Please run 'pnpm changeset' to create a changeset if applicable." + echo "-------------------------------------------------------------------------------------" +fi From 98490187ee0429811c7f966482fc7ca3eec92c59 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:01:54 +0800 Subject: [PATCH 05/51] feat(webview): view-local buffer reads and setValue persistence (N3) Split unit N3: setValue/getValues view-local buffer and failed-write rollback on top of N2; setValues/reset/ack/history remain for N4/N5. --- src/core/webview/ClineProvider.ts | 28 +++- .../webview/__tests__/ClineProvider.spec.ts | 122 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9d812cd8e9..f3d4a0d637 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3918,15 +3918,26 @@ export class ClineProvider private getGlobalState(key: K) { return this.contextProxy.getValue(key) } + public async setValue(key: K, value: RooCodeSettings[K]) { + const previousSharedValues = { [key]: this.contextProxy.getValue(key) } as RooCodeSettings await this.contextProxy.setValue(key, value) + try { + await this._saveViewLocalStateFromMutation({ [key]: value }) + } catch (error) { + // The per-view record did not land: restore the prior shared value so the two + // persistence layers cannot diverge, then surface the original failure. + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues) + throw error + } } public getValue(key: K) { return this.contextProxy.getValue(key) } + public getValues() { - return this.contextProxy.getValues() + return { ...this.contextProxy.getValues(), ...this.viewLocalState } } public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) @@ -5353,4 +5364,19 @@ export class ClineProvider } } } + + /** + * Restore shared values captured before a setter wrote them, used when the paired + * per-view persistence failed so the shared store cannot move ahead of the per-view + * record. A failed rollback is logged; the original failure is what callers see. + */ + private async _restoreSharedValuesAfterFailedViewWrite(previous: RooCodeSettings): Promise { + try { + await this.contextProxy.setValues(previous) + } catch (rollbackError) { + this.log( + `[ClineProvider] Shared-state rollback after failed per-view write failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4bb3e49635..6356835cc9 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -447,6 +447,10 @@ function stallProviderSettingsProfile(provider: ClineProvider) { return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } } +function persistedViewStateClaim(context: vscode.ExtensionContext, viewStateId: string): string | undefined { + return context.globalState.get("viewStates")?.[viewStateId]?.claimToken +} + describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { @@ -4415,6 +4419,124 @@ describe("ClineProvider", () => { await provider1.dispose() await provider2.dispose() }) + + describe("view state persistence edge cases", () => { + it("should load persisted mode, profile name and resolved profile into viewLocalState", async () => { + const writer = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await writer["setViewStateId"]("shared-view") + await writer.saveViewState("mode", "architect") + await writer.saveViewState("currentApiConfigName", "my-profile") + + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const logSpy = vi.spyOn(provider, "log") + const getProfileSpy = vi.fn().mockResolvedValue({ + name: "my-profile", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "model-x", + }) + // @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup. + provider.providerSettingsManager = { getProfile: getProfileSpy } + // The ownership check requires the writer to release the id before the + // next view adopts it; the durable entry it wrote stays in storage. + await writer.dispose() + + await provider.contextProxy.setValue( + "viewStates", + mockContext.globalState.get("viewStates"), + ) + // The host-minted claim the writer received is what lets this view re-adopt its entry. + await provider["setViewStateId"]("shared-view", persistedViewStateClaim(mockContext, "shared-view")) + expect(provider["viewLocalState"]).toEqual({ + mode: "architect", + currentApiConfigName: "my-profile", + apiConfiguration: { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "model-x" }, + }) + expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" }) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId")) + await provider.dispose() + }) + + it("should apply setValue mutations to global state and keep or clear the right buffer fields", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const apiConfiguration = { apiProvider: providerIdentifiers.openrouter } + await provider.saveViewState("mode", "architect") + await provider.saveViewState("currentApiConfigName", "my-profile") + await provider.saveViewState("apiConfiguration", apiConfiguration) + // A mutation of an unrelated key reaches global state without dropping buffered fields. + await provider.setValue("writeDelayMs", 500) + expect(mockContext.globalState.get("writeDelayMs")).toBe(500) + expect(provider.getValues().writeDelayMs).toBe(500) + expect(provider["viewLocalState"].mode).toBe("architect") + expect(provider["viewLocalState"].currentApiConfigName).toBe("my-profile") + expect(provider["viewLocalState"].apiConfiguration).toBe(apiConfiguration) + await provider.setValue("mode", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("mode") + await provider.setValue("currentApiConfigName", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("currentApiConfigName") + await provider.dispose() + }) + + it("should remove the buffered apiConfiguration when it is cleared", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const save = provider.saveViewState.bind(provider) as (key: string, value: unknown) => Promise + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await provider.saveViewState("apiConfiguration", undefined) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.saveViewState("apiConfiguration", { apiProvider: providerIdentifiers.openrouter }) + await save("apiConfiguration", null) + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + await provider.dispose() + }) + + it("should roll back the shared write when setValue per-view persistence fails", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("mode", "architect") + const originalUpdate = mockContext.globalState.update + mockContext.globalState.update = vi.fn().mockImplementation((key: string) => { + if (key === "viewStates") { + return Promise.reject(new Error("storage unavailable")) + } + return Promise.resolve() + }) + + await expect(provider.setValue("mode", "code")).rejects.toThrow("storage unavailable") + // The shared store must not move ahead of the per-view record that failed. + expect(provider.getValue("mode")).toBe("architect") + + mockContext.globalState.update = originalUpdate + await provider.dispose() + }) + }) }) describe("webviewMessageHandler no-floating-promises coverage", () => { From 1357d5b602c4afa5e9b3f6e47a17ccbc493edb9c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:11:46 +0800 Subject: [PATCH 06/51] chore: strip BOM and fix lint imports --- .husky/_/post-checkout | 3 +++ .husky/_/post-commit | 3 +++ .husky/_/post-merge | 3 +++ .husky/_/pre-push | 3 +++ src/core/webview/ClineProvider.ts | 2 +- src/core/webview/__tests__/ClineProvider.spec.ts | 3 +-- 6 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .husky/_/post-checkout create mode 100644 .husky/_/post-commit create mode 100644 .husky/_/post-merge create mode 100644 .husky/_/pre-push diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout new file mode 100644 index 0000000000..5abf8ed93f --- /dev/null +++ b/.husky/_/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/.husky/_/post-commit b/.husky/_/post-commit new file mode 100644 index 0000000000..b8b76c2c42 --- /dev/null +++ b/.husky/_/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/.husky/_/post-merge b/.husky/_/post-merge new file mode 100644 index 0000000000..726f909891 --- /dev/null +++ b/.husky/_/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/.husky/_/pre-push b/.husky/_/pre-push new file mode 100644 index 0000000000..5f26dc4552 --- /dev/null +++ b/.husky/_/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9d812cd8e9..7db54cf4ac 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -113,7 +113,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4bb3e49635..0e033397da 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,4 +1,4 @@ -// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts import fs from "fs" import * as path from "path" @@ -14,7 +14,6 @@ import { type ClineMessage, type ExtensionMessage, type ExtensionState, - type RooCodeSettings, type WebviewMessage, ORGANIZATION_ALLOW_ALL, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, From 9d8859968f153d7db0f5ce09179ccc56b92dfc4c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:11:49 +0800 Subject: [PATCH 07/51] chore: strip BOM and fix lint imports --- src/core/webview/ClineProvider.ts | 2 +- src/core/webview/__tests__/ClineProvider.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f3d4a0d637..031e832b74 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -113,7 +113,7 @@ import { buildApiHandler } from "../../api" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/providers/fetchers/lmstudio" import { ContextProxy } from "../config/ContextProxy" -import { ProviderSettingsManager, ProviderSettingsNotFoundError } from "../config/ProviderSettingsManager" +import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { PendingActionSettlementError, Task } from "../task/Task" diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6356835cc9..a6dde04808 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,4 +1,4 @@ -// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts import fs from "fs" import * as path from "path" From c59f82f117b3281894ebec711c2db398872b20bd Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:12:24 +0800 Subject: [PATCH 08/51] chore: untrack generated husky hooks again --- .husky/_/post-checkout | 3 --- .husky/_/post-commit | 3 --- .husky/_/post-merge | 3 --- .husky/_/pre-push | 3 --- 4 files changed, 12 deletions(-) delete mode 100644 .husky/_/post-checkout delete mode 100644 .husky/_/post-commit delete mode 100644 .husky/_/post-merge delete mode 100644 .husky/_/pre-push diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout deleted file mode 100644 index 5abf8ed93f..0000000000 --- a/.husky/_/post-checkout +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-checkout "$@" diff --git a/.husky/_/post-commit b/.husky/_/post-commit deleted file mode 100644 index b8b76c2c42..0000000000 --- a/.husky/_/post-commit +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-commit "$@" diff --git a/.husky/_/post-merge b/.husky/_/post-merge deleted file mode 100644 index 726f909891..0000000000 --- a/.husky/_/post-merge +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-merge "$@" diff --git a/.husky/_/pre-push b/.husky/_/pre-push deleted file mode 100644 index 5f26dc4552..0000000000 --- a/.husky/_/pre-push +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs pre-push "$@" From afa9ab308633cd8d008bf91f594bbe2b8b283852 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:56:49 +0800 Subject: [PATCH 09/51] fix(webview): address review findings on view-state durability - omit viewStates (host-minted claim tokens) from settings export - keep a pre-launch selection when registration re-keys onto an existing stable entry - narrow saveViewState keys to the durable per-view subset - skip rollback when another provider wrote a newer shared value - restore the ContextProxy cache when globalState.update rejects - clarify that the claim is enforced by setViewStateId; launch wiring follows --- .husky/_/post-checkout | 3 +++ .husky/_/post-commit | 3 +++ .husky/_/post-merge | 3 +++ .husky/_/pre-push | 3 +++ packages/types/src/vscode-extension-host.ts | 6 +++-- src/core/config/ContextProxy.ts | 14 +++++++++-- .../__tests__/ProviderSettingsManager.spec.ts | 11 +++++++++ src/core/webview/ClineProvider.ts | 24 ++++++++++++++++--- 8 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 .husky/_/post-checkout create mode 100644 .husky/_/post-commit create mode 100644 .husky/_/post-merge create mode 100644 .husky/_/pre-push diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout new file mode 100644 index 0000000000..5abf8ed93f --- /dev/null +++ b/.husky/_/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-checkout "$@" diff --git a/.husky/_/post-commit b/.husky/_/post-commit new file mode 100644 index 0000000000..b8b76c2c42 --- /dev/null +++ b/.husky/_/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-commit "$@" diff --git a/.husky/_/post-merge b/.husky/_/post-merge new file mode 100644 index 0000000000..726f909891 --- /dev/null +++ b/.husky/_/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs post-merge "$@" diff --git a/.husky/_/pre-push b/.husky/_/pre-push new file mode 100644 index 0000000000..5f26dc4552 --- /dev/null +++ b/.husky/_/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } +git lfs pre-push "$@" diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 233fa7538f..c4e7788928 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -681,8 +681,10 @@ export interface WebviewMessage { viewStateId?: string /** * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. - * The webview only echoes what it was given; the host rejects an id whose stored claim - * does not match, so a view cannot adopt another view's persisted selections. + * The webview only echoes what it was given; the host enforces the match in + * `ClineProvider.setViewStateId` when a view registers this id, so a view cannot adopt + * another view's persisted selections. Forwarding these fields from the launch handler + * is wired by the follow-up launch unit. */ viewStateClaim?: string taskId?: string diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 4e20305f48..8678e02219 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -363,13 +363,23 @@ export class ContextProxy { return value !== undefined ? value : defaultValue } - updateGlobalState(key: K, value: GlobalState[K]) { + async updateGlobalState(key: K, value: GlobalState[K]) { if (isPassThroughStateKey(key)) { return this.originalContext.globalState.update(key, value) } + const previousCachedValue = this.stateCache[key] this.stateCache[key] = value - return this.originalContext.globalState.update(key, value) + + try { + return await this.originalContext.globalState.update(key, value) + } catch (error) { + // A rejected write must not leave the cache ahead of storage: callers that then + // read the cached value (e.g. the view-state load path) would act on a selection + // that was never persisted. + this.stateCache[key] = previousCachedValue + throw error + } } private getAllGlobalState(): GlobalState { diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 3b93b7cd22..c17c241fa3 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -983,6 +983,17 @@ describe("ProviderSettingsManager", () => { ) }) + it('keeps a storage failure whose message mentions "not found" out of the typed not-found signal', async () => { + mockSecrets.get.mockRejectedValue(new Error("vault: secret not found")) + + // A message-based classifier would treat this as a missing profile. The typed + // signal must stay reserved for a missing config entry. + await expect(providerSettingsManager.deleteConfig("default")).rejects.not.toBeInstanceOf( + ProviderSettingsNotFoundError, + ) + await expect(providerSettingsManager.deleteConfig("default")).rejects.toThrow("Failed to delete config") + }) + it("should throw error when trying to delete last remaining config", async () => { mockSecrets.get.mockResolvedValue( JSON.stringify({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7db54cf4ac..5b4a9aed35 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -148,6 +148,13 @@ type PersistedViewState = NonNullable[string] */ type ViewLocalStateValues = Partial & Partial +/** + * The subset of view-local values that has a durable per-view record. `saveViewState` + * and the mutation path handle exactly these keys, so the generic stays narrow enough + * for the compiler to reject e.g. `writeDelayMs`. + */ +type PersistedViewStateValues = Pick + /** * 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 @@ -774,8 +781,19 @@ export class ClineProvider delete states[previousViewStateId] - if (!states[nextViewStateId]) { + const existingStableEntry = states[nextViewStateId] + + if (!existingStableEntry) { states[nextViewStateId] = previous + } else { + // Registration can bind a claim-less legacy key just before this pre-launch entry + // is re-keyed. Keep the selections the stable entry already carries, but fill in + // anything it lacks so a mode chosen before launch is not silently dropped. + states[nextViewStateId] = { + ...previous, + ...existingStableEntry, + updatedAt: Math.max(previous.updatedAt ?? 0, existingStableEntry.updatedAt ?? 0), + } } await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) @@ -1024,9 +1042,9 @@ export class ClineProvider * 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( + public async saveViewState( key: K, - value: ViewLocalStateValues[K] | undefined, + value: PersistedViewStateValues[K] | undefined, ): Promise { await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) From b620419ed478efe5ebd208e21fb6b96355b95ca9 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:56:52 +0800 Subject: [PATCH 10/51] fix(webview): address review findings on view-state durability - omit viewStates (host-minted claim tokens) from settings export - keep a pre-launch selection when registration re-keys onto an existing stable entry - narrow saveViewState keys to the durable per-view subset - skip rollback when another provider wrote a newer shared value - restore the ContextProxy cache when globalState.update rejects - clarify that the claim is enforced by setViewStateId; launch wiring follows --- packages/types/src/vscode-extension-host.ts | 6 ++- src/core/config/ContextProxy.ts | 14 +++++- src/core/webview/ClineProvider.ts | 50 ++++++++++++++++++--- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 233fa7538f..c4e7788928 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -681,8 +681,10 @@ export interface WebviewMessage { viewStateId?: string /** * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. - * The webview only echoes what it was given; the host rejects an id whose stored claim - * does not match, so a view cannot adopt another view's persisted selections. + * The webview only echoes what it was given; the host enforces the match in + * `ClineProvider.setViewStateId` when a view registers this id, so a view cannot adopt + * another view's persisted selections. Forwarding these fields from the launch handler + * is wired by the follow-up launch unit. */ viewStateClaim?: string taskId?: string diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 4e20305f48..8678e02219 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -363,13 +363,23 @@ export class ContextProxy { return value !== undefined ? value : defaultValue } - updateGlobalState(key: K, value: GlobalState[K]) { + async updateGlobalState(key: K, value: GlobalState[K]) { if (isPassThroughStateKey(key)) { return this.originalContext.globalState.update(key, value) } + const previousCachedValue = this.stateCache[key] this.stateCache[key] = value - return this.originalContext.globalState.update(key, value) + + try { + return await this.originalContext.globalState.update(key, value) + } catch (error) { + // A rejected write must not leave the cache ahead of storage: callers that then + // read the cached value (e.g. the view-state load path) would act on a selection + // that was never persisted. + this.stateCache[key] = previousCachedValue + throw error + } } private getAllGlobalState(): GlobalState { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 031e832b74..6d98cc638b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -148,6 +148,13 @@ type PersistedViewState = NonNullable[string] */ type ViewLocalStateValues = Partial & Partial +/** + * The subset of view-local values that has a durable per-view record. `saveViewState` + * and the mutation path handle exactly these keys, so the generic stays narrow enough + * for the compiler to reject e.g. `writeDelayMs`. + */ +type PersistedViewStateValues = Pick + /** * 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 @@ -774,8 +781,19 @@ export class ClineProvider delete states[previousViewStateId] - if (!states[nextViewStateId]) { + const existingStableEntry = states[nextViewStateId] + + if (!existingStableEntry) { states[nextViewStateId] = previous + } else { + // Registration can bind a claim-less legacy key just before this pre-launch entry + // is re-keyed. Keep the selections the stable entry already carries, but fill in + // anything it lacks so a mode chosen before launch is not silently dropped. + states[nextViewStateId] = { + ...previous, + ...existingStableEntry, + updatedAt: Math.max(previous.updatedAt ?? 0, existingStableEntry.updatedAt ?? 0), + } } await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) @@ -1024,9 +1042,9 @@ export class ClineProvider * 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( + public async saveViewState( key: K, - value: ViewLocalStateValues[K] | undefined, + value: PersistedViewStateValues[K] | undefined, ): Promise { await this._saveViewLocalStateFromMutation({ [key]: value } as ViewLocalStateValues) @@ -3927,7 +3945,9 @@ export class ClineProvider } catch (error) { // The per-view record did not land: restore the prior shared value so the two // persistence layers cannot diverge, then surface the original failure. - await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues) + await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, { + [key]: value, + } as RooCodeSettings) throw error } } @@ -5370,9 +5390,27 @@ export class ClineProvider * per-view persistence failed so the shared store cannot move ahead of the per-view * record. A failed rollback is logged; the original failure is what callers see. */ - private async _restoreSharedValuesAfterFailedViewWrite(previous: RooCodeSettings): Promise { + private async _restoreSharedValuesAfterFailedViewWrite( + previous: RooCodeSettings, + attempted: RooCodeSettings, + ): Promise { + // Roll back only the keys this attempt wrote and that still hold the value it wrote. + // A concurrent provider may have written a newer value in the meantime; restoring the + // captured previous value would then clobber it for every view that reads shared state. + const rollback: RooCodeSettings = {} as RooCodeSettings + + for (const key of Object.keys(attempted) as (keyof RooCodeSettings)[]) { + if (this.contextProxy.getValue(key) === attempted[key]) { + ;(rollback as Record)[key] = previous[key] + } + } + + if (Object.keys(rollback).length === 0) { + return + } + try { - await this.contextProxy.setValues(previous) + await this.contextProxy.setValues(rollback) } catch (rollbackError) { this.log( `[ClineProvider] Shared-state rollback after failed per-view write failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, From 86c0288fbaad33d6ba35ebb3c39a821262f9e9a7 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:03:22 +0800 Subject: [PATCH 11/51] fix(config): keep a newer cached value when an older global-state write rejects Track a per-key write generation so a rejected write only rolls the cache back while it is still the newest write for that key; also stop tracking the generated .husky/_ hook files. --- .husky/_/post-checkout | 3 --- .husky/_/post-commit | 3 --- .husky/_/post-merge | 3 --- .husky/_/pre-push | 3 --- src/core/config/ContextProxy.ts | 14 +++++++++---- .../config/__tests__/ContextProxy.spec.ts | 21 +++++++++++++++++++ 6 files changed, 31 insertions(+), 16 deletions(-) delete mode 100644 .husky/_/post-checkout delete mode 100644 .husky/_/post-commit delete mode 100644 .husky/_/post-merge delete mode 100644 .husky/_/pre-push diff --git a/.husky/_/post-checkout b/.husky/_/post-checkout deleted file mode 100644 index 5abf8ed93f..0000000000 --- a/.husky/_/post-checkout +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-checkout' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-checkout "$@" diff --git a/.husky/_/post-commit b/.husky/_/post-commit deleted file mode 100644 index b8b76c2c42..0000000000 --- a/.husky/_/post-commit +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-commit' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-commit "$@" diff --git a/.husky/_/post-merge b/.husky/_/post-merge deleted file mode 100644 index 726f909891..0000000000 --- a/.husky/_/post-merge +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'post-merge' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs post-merge "$@" diff --git a/.husky/_/pre-push b/.husky/_/pre-push deleted file mode 100644 index 5f26dc4552..0000000000 --- a/.husky/_/pre-push +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -command -v git-lfs >/dev/null 2>&1 || { printf >&2 "\n%s\n\n" "This repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting the 'pre-push' file in the hooks directory (set by 'core.hookspath'; usually '.git/hooks')."; exit 2; } -git lfs pre-push "$@" diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 8678e02219..1e7815e8dd 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -45,6 +45,7 @@ export class ContextProxy { private readonly originalContext: vscode.ExtensionContext private stateCache: GlobalState + private stateWriteGenerations = new Map() private secretCache: SecretState private _isInitialized = false @@ -369,15 +370,20 @@ export class ContextProxy { } const previousCachedValue = this.stateCache[key] + const generation = (this.stateWriteGenerations.get(key) ?? 0) + 1 + this.stateWriteGenerations.set(key, generation) this.stateCache[key] = value try { return await this.originalContext.globalState.update(key, value) } catch (error) { - // A rejected write must not leave the cache ahead of storage: callers that then - // read the cached value (e.g. the view-state load path) would act on a selection - // that was never persisted. - this.stateCache[key] = previousCachedValue + // A rejected write must not leave the cache ahead of storage, but a *newer* write + // for the same key may already have replaced the cached value: restoring here would + // then roll the cache back behind storage. Only undo this write while it is still + // the newest one for the key. + if (this.stateWriteGenerations.get(key) === generation) { + this.stateCache[key] = previousCachedValue + } throw error } } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index f9fa9cdb15..79cb0471d2 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -120,6 +120,27 @@ describe("ContextProxy", () => { expect(result).toBe("deepseek") }) + it("should not let an older rejected write clobber a newer cached value", async () => { + let rejectFirst: (error: Error) => void = () => {} + const firstWriteResult = new Promise((_, reject) => { + rejectFirst = reject + }) + + mockGlobalState.update.mockReset() + mockGlobalState.update.mockReturnValueOnce(firstWriteResult) + mockGlobalState.update.mockResolvedValueOnce(undefined) + + const firstWrite = proxy.updateGlobalState("apiProvider", "anthropic") + await proxy.updateGlobalState("apiProvider", "openai") + + // The older write rejects after a newer value already reached the cache: rolling + // the cache back to the pre-first-write value would diverge from storage. + rejectFirst(new Error("disk full")) + await expect(firstWrite).rejects.toThrow("disk full") + + expect(proxy.getGlobalState("apiProvider")).toBe("openai") + }) + it("should bypass cache for pass-through state keys", async () => { // Setup mock return value mockGlobalState.get.mockReturnValue("pass-through-value") From b2966352aec26a5a6cd62426fc53442224405ef6 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:03:34 +0800 Subject: [PATCH 12/51] fix(config): keep a newer cached value when an older global-state write rejects Track a per-key write generation so a rejected write only rolls the cache back while it is still the newest write for that key; also stop tracking the generated .husky/_ hook files. --- src/core/config/ContextProxy.ts | 14 +++++++++---- .../config/__tests__/ContextProxy.spec.ts | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 8678e02219..1e7815e8dd 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -45,6 +45,7 @@ export class ContextProxy { private readonly originalContext: vscode.ExtensionContext private stateCache: GlobalState + private stateWriteGenerations = new Map() private secretCache: SecretState private _isInitialized = false @@ -369,15 +370,20 @@ export class ContextProxy { } const previousCachedValue = this.stateCache[key] + const generation = (this.stateWriteGenerations.get(key) ?? 0) + 1 + this.stateWriteGenerations.set(key, generation) this.stateCache[key] = value try { return await this.originalContext.globalState.update(key, value) } catch (error) { - // A rejected write must not leave the cache ahead of storage: callers that then - // read the cached value (e.g. the view-state load path) would act on a selection - // that was never persisted. - this.stateCache[key] = previousCachedValue + // A rejected write must not leave the cache ahead of storage, but a *newer* write + // for the same key may already have replaced the cached value: restoring here would + // then roll the cache back behind storage. Only undo this write while it is still + // the newest one for the key. + if (this.stateWriteGenerations.get(key) === generation) { + this.stateCache[key] = previousCachedValue + } throw error } } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index f9fa9cdb15..79cb0471d2 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -120,6 +120,27 @@ describe("ContextProxy", () => { expect(result).toBe("deepseek") }) + it("should not let an older rejected write clobber a newer cached value", async () => { + let rejectFirst: (error: Error) => void = () => {} + const firstWriteResult = new Promise((_, reject) => { + rejectFirst = reject + }) + + mockGlobalState.update.mockReset() + mockGlobalState.update.mockReturnValueOnce(firstWriteResult) + mockGlobalState.update.mockResolvedValueOnce(undefined) + + const firstWrite = proxy.updateGlobalState("apiProvider", "anthropic") + await proxy.updateGlobalState("apiProvider", "openai") + + // The older write rejects after a newer value already reached the cache: rolling + // the cache back to the pre-first-write value would diverge from storage. + rejectFirst(new Error("disk full")) + await expect(firstWrite).rejects.toThrow("disk full") + + expect(proxy.getGlobalState("apiProvider")).toBe("openai") + }) + it("should bypass cache for pass-through state keys", async () => { // Setup mock return value mockGlobalState.get.mockReturnValue("pass-through-value") From 576f45203f9806b13c7d83787724d32e26e33b94 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:21:43 +0800 Subject: [PATCH 13/51] chore: tighten the new review-fix comments --- src/core/config/ContextProxy.ts | 6 +-- src/core/webview/ClineProvider.ts | 79 ++++++++----------------------- 2 files changed, 21 insertions(+), 64 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1e7815e8dd..a284c8444b 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -377,10 +377,8 @@ export class ContextProxy { try { return await this.originalContext.globalState.update(key, value) } catch (error) { - // A rejected write must not leave the cache ahead of storage, but a *newer* write - // for the same key may already have replaced the cached value: restoring here would - // then roll the cache back behind storage. Only undo this write while it is still - // the newest one for the key. + // A newer write for the same key may already own the cache: only undo this write + // while it is still the newest one. if (this.stateWriteGenerations.get(key) === generation) { this.stateCache[key] = previousCachedValue } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5b4a9aed35..169daed1e0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -935,21 +935,13 @@ 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. - */ + /** Load non-secret persisted selections; in-flight buffer edits are reapplied on top. */ 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. + // A newer id registered while this load is in flight must win. const loadedForViewId = this.viewStateId - // While the stable id has not been registered yet, the key is this instance's - // session-local name. Temporary names restart from 0 on every host start, so an - // entry under the same name can belong to a different session. Load only entries - // this instance authored. + // Before registration the key is the session-local name, which can be a stale entry + // from another session: load only entries this instance authored. if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { return } @@ -957,15 +949,12 @@ 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. + // The mutation paths update viewLocalState in place; a shallow snapshot makes + // in-flight edits observable below. const preLoadBuffer = { ...this.viewLocalState } if (persisted?.mode) { - // A persisted mode may reference a custom mode that was deleted after it was - // pinned: restore it only when the slug still resolves, so a stale slug cannot - // shadow the shared mode from getState(). + // A stale custom-mode slug must not shadow the shared mode from getState(). const customModes = await this.customModesManager.getCustomModes() if (getModeBySlug(persisted.mode, customModes)) { loadedState.mode = persisted.mode as Mode @@ -994,13 +983,8 @@ export class ClineProvider return } - // Reapply the buffer fields that changed while the load was in flight, - // tracking the change instead of testing against undefined: a field cleared - // during the load window must stay cleared (the loaded value must not - // resurrect it), and a field written to a new value must win over it. - // Untouched fields keep the persisted values authoritative, and the - // pre-load buffer is never merged wholesale so stale temporary-id state - // cannot override the stable persisted state. + // Diff each field so in-flight edits win over the loaded values (including clears) + // while untouched fields keep the persisted values authoritative. const postLoadBuffer = this.viewLocalState const mergedState: Partial = { ...loadedState } @@ -1037,11 +1021,7 @@ export class ClineProvider } } - /** - * 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. - */ + /** Save one view-local value; the durable subset is re-keyed to the stable id. */ public async saveViewState( key: K, value: PersistedViewStateValues[K] | undefined, @@ -2611,16 +2591,11 @@ export class ClineProvider // `allSettled` so a caller that rolls back after a failure never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - // 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(). + // Route through setValue so the buffer tracks the activated profile. 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(). + // setProviderSettings bypasses the buffer path: refresh this view's overlay. this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }), ]) const failedActivation = activationResults.find( @@ -2631,9 +2606,7 @@ export class ClineProvider if (signal.aborted) return id - // Other live views may have buffered this profile's settings earlier; - // refresh them so their getState() cannot report the updated profile's - // name with stale settings. + // Refresh other views pinned to this profile so their overlays cannot go stale. // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). @@ -2644,16 +2617,11 @@ export class ClineProvider } 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. + // A non-activating save still changes the profile's settings: refresh the views + // that buffered a copy, or their getState() keeps the pre-save values. 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. + // Only rebuild a task that actually runs under the saved profile. if (this.getCurrentTask()?.taskApiConfigName === name) { this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } @@ -3950,11 +3918,7 @@ export class ClineProvider await this.contextProxy.setValues(values) } - /** - * 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. - */ + /** Persist the durable subset first, then update the in-memory buffer. */ private async _saveViewLocalStateFromMutation( values: Partial & Partial, ): Promise { @@ -3962,10 +3926,7 @@ export class ClineProvider this._updateViewLocalStateFromMutation(values) } - /** - * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) - * into the registered viewStates map for this view. - */ + /** Write the durable subset of a mutation into this view's viewStates entry. */ private async _persistViewLocalStateFromMutation( values: Partial & Partial, ): Promise { @@ -3984,9 +3945,7 @@ export class ClineProvider } } - /** - * Clear view-local state cache so that getState() falls back to ContextProxy defaults. - */ + /** Clear the buffer so getState() falls back to the shared values. */ private _clearViewLocalState(): void { this.viewLocalState = {} } From 14a8d4008313c5c7482f4cabffc50fb2727f2073 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:22:00 +0800 Subject: [PATCH 14/51] chore: tighten the shared review-fix comments --- src/core/config/ContextProxy.ts | 6 +-- src/core/webview/ClineProvider.ts | 79 ++++++++----------------------- 2 files changed, 21 insertions(+), 64 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 1e7815e8dd..a284c8444b 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -377,10 +377,8 @@ export class ContextProxy { try { return await this.originalContext.globalState.update(key, value) } catch (error) { - // A rejected write must not leave the cache ahead of storage, but a *newer* write - // for the same key may already have replaced the cached value: restoring here would - // then roll the cache back behind storage. Only undo this write while it is still - // the newest one for the key. + // A newer write for the same key may already own the cache: only undo this write + // while it is still the newest one. if (this.stateWriteGenerations.get(key) === generation) { this.stateCache[key] = previousCachedValue } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6d98cc638b..2f534c7afa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -935,21 +935,13 @@ 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. - */ + /** Load non-secret persisted selections; in-flight buffer edits are reapplied on top. */ 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. + // A newer id registered while this load is in flight must win. const loadedForViewId = this.viewStateId - // While the stable id has not been registered yet, the key is this instance's - // session-local name. Temporary names restart from 0 on every host start, so an - // entry under the same name can belong to a different session. Load only entries - // this instance authored. + // Before registration the key is the session-local name, which can be a stale entry + // from another session: load only entries this instance authored. if (loadedForViewId === this.viewId && !this.wroteUnderTemporaryViewStateId) { return } @@ -957,15 +949,12 @@ 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. + // The mutation paths update viewLocalState in place; a shallow snapshot makes + // in-flight edits observable below. const preLoadBuffer = { ...this.viewLocalState } if (persisted?.mode) { - // A persisted mode may reference a custom mode that was deleted after it was - // pinned: restore it only when the slug still resolves, so a stale slug cannot - // shadow the shared mode from getState(). + // A stale custom-mode slug must not shadow the shared mode from getState(). const customModes = await this.customModesManager.getCustomModes() if (getModeBySlug(persisted.mode, customModes)) { loadedState.mode = persisted.mode as Mode @@ -994,13 +983,8 @@ export class ClineProvider return } - // Reapply the buffer fields that changed while the load was in flight, - // tracking the change instead of testing against undefined: a field cleared - // during the load window must stay cleared (the loaded value must not - // resurrect it), and a field written to a new value must win over it. - // Untouched fields keep the persisted values authoritative, and the - // pre-load buffer is never merged wholesale so stale temporary-id state - // cannot override the stable persisted state. + // Diff each field so in-flight edits win over the loaded values (including clears) + // while untouched fields keep the persisted values authoritative. const postLoadBuffer = this.viewLocalState const mergedState: Partial = { ...loadedState } @@ -1037,11 +1021,7 @@ export class ClineProvider } } - /** - * 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. - */ + /** Save one view-local value; the durable subset is re-keyed to the stable id. */ public async saveViewState( key: K, value: PersistedViewStateValues[K] | undefined, @@ -2611,16 +2591,11 @@ export class ClineProvider // `allSettled` so a caller that rolls back after a failure never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - // 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(). + // Route through setValue so the buffer tracks the activated profile. 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(). + // setProviderSettings bypasses the buffer path: refresh this view's overlay. this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }), ]) const failedActivation = activationResults.find( @@ -2631,9 +2606,7 @@ export class ClineProvider if (signal.aborted) return id - // Other live views may have buffered this profile's settings earlier; - // refresh them so their getState() cannot report the updated profile's - // name with stale settings. + // Refresh other views pinned to this profile so their overlays cannot go stale. // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). @@ -2644,16 +2617,11 @@ export class ClineProvider } 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. + // A non-activating save still changes the profile's settings: refresh the views + // that buffered a copy, or their getState() keeps the pre-save values. 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. + // Only rebuild a task that actually runs under the saved profile. if (this.getCurrentTask()?.taskApiConfigName === name) { this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } @@ -3963,11 +3931,7 @@ export class ClineProvider await this.contextProxy.setValues(values) } - /** - * 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. - */ + /** Persist the durable subset first, then update the in-memory buffer. */ private async _saveViewLocalStateFromMutation( values: Partial & Partial, ): Promise { @@ -3975,10 +3939,7 @@ export class ClineProvider this._updateViewLocalStateFromMutation(values) } - /** - * Writes the durably persisted subset of a mutation (mode and currentApiConfigName) - * into the registered viewStates map for this view. - */ + /** Write the durable subset of a mutation into this view's viewStates entry. */ private async _persistViewLocalStateFromMutation( values: Partial & Partial, ): Promise { @@ -3997,9 +3958,7 @@ export class ClineProvider } } - /** - * Clear view-local state cache so that getState() falls back to ContextProxy defaults. - */ + /** Clear the buffer so getState() falls back to the shared values. */ private _clearViewLocalState(): void { this.viewLocalState = {} } From b4ca1bc9a3ea7b52476584b96f8c87f431190403 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:30:34 +0800 Subject: [PATCH 15/51] fix(config): advance write generations on reset A write pending across resetAllState() must not restore its pre-reset value when it rejects afterwards; bump the generation of every tracked key during the reset, with a test. --- src/core/config/ContextProxy.ts | 5 +++++ .../config/__tests__/ContextProxy.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index a284c8444b..e6aea60685 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -607,6 +607,11 @@ export class ContextProxy { // Clear in-memory caches this.stateCache = {} this.secretCache = {} + // A write that started before this reset must not restore its pre-reset value when it + // rejects afterwards, so advance every generation the pending writes still hold. + for (const key of this.stateWriteGenerations.keys()) { + this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1) + } await Promise.all([ ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 79cb0471d2..a28ca249ac 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -141,6 +141,25 @@ describe("ContextProxy", () => { expect(proxy.getGlobalState("apiProvider")).toBe("openai") }) + it("should not restore a pre-reset value when a pending write rejects after reset", async () => { + let rejectPending: (error: Error) => void = () => {} + mockGlobalState.update.mockReset() + mockGlobalState.update.mockReturnValueOnce( + new Promise((_, reject) => { + rejectPending = reject + }), + ) + + const pending = proxy.updateGlobalState("apiProvider", "anthropic") + await proxy.resetAllState() + rejectPending(new Error("disk full")) + + await expect(pending).rejects.toThrow("disk full") + // The reset cleared the cache: restoring the pre-reset value would resurrect state + // that storage no longer holds. + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + }) + it("should bypass cache for pass-through state keys", async () => { // Setup mock return value mockGlobalState.get.mockReturnValue("pass-through-value") From b82c90439261ba28e1f7526715d3366c734e0ef1 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:30:34 +0800 Subject: [PATCH 16/51] fix(config): advance write generations on reset A write pending across resetAllState() must not restore its pre-reset value when it rejects afterwards; bump the generation of every tracked key during the reset, with a test. --- src/core/config/ContextProxy.ts | 5 +++++ .../config/__tests__/ContextProxy.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index a284c8444b..e6aea60685 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -607,6 +607,11 @@ export class ContextProxy { // Clear in-memory caches this.stateCache = {} this.secretCache = {} + // A write that started before this reset must not restore its pre-reset value when it + // rejects afterwards, so advance every generation the pending writes still hold. + for (const key of this.stateWriteGenerations.keys()) { + this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1) + } await Promise.all([ ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 79cb0471d2..a28ca249ac 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -141,6 +141,25 @@ describe("ContextProxy", () => { expect(proxy.getGlobalState("apiProvider")).toBe("openai") }) + it("should not restore a pre-reset value when a pending write rejects after reset", async () => { + let rejectPending: (error: Error) => void = () => {} + mockGlobalState.update.mockReset() + mockGlobalState.update.mockReturnValueOnce( + new Promise((_, reject) => { + rejectPending = reject + }), + ) + + const pending = proxy.updateGlobalState("apiProvider", "anthropic") + await proxy.resetAllState() + rejectPending(new Error("disk full")) + + await expect(pending).rejects.toThrow("disk full") + // The reset cleared the cache: restoring the pre-reset value would resurrect state + // that storage no longer holds. + expect(proxy.getGlobalState("apiProvider")).toBeUndefined() + }) + it("should bypass cache for pass-through state keys", async () => { // Setup mock return value mockGlobalState.get.mockReturnValue("pass-through-value") From ebe5a6af21c138d8f42dfcc163edeb9d7cee1c83 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:31:28 +0800 Subject: [PATCH 17/51] chore: align the shared view-identity docs with N1 --- packages/types/src/global-settings.ts | 6 +-- packages/types/src/vscode-extension-host.ts | 5 +-- src/core/config/ProviderSettingsManager.ts | 7 +-- src/core/webview/ClineProvider.ts | 47 ++++----------------- 4 files changed, 13 insertions(+), 52 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index b09bae2635..3da73ee0fe 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -122,10 +122,8 @@ export const viewStateSchema = z.object({ currentApiConfigName: z.string().optional(), updatedAt: z.number().optional(), /** - * Host-minted capability token binding this entry to the webview that wrote it. - * Minted by the extension host (`crypto.randomUUID()`), delivered to that webview and - * never accepted from webview input. An entry is only re-adopted for a webview that - * presents this token, so a view cannot read another (e.g. closed) view's selections. + * Host-minted capability token binding this entry to the webview that wrote it; never + * accepted from webview input and only a matching token re-adopts the entry. */ claimToken: z.string().optional(), }) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c4e7788928..c77dbb14c4 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -681,10 +681,7 @@ export interface WebviewMessage { viewStateId?: string /** * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. - * The webview only echoes what it was given; the host enforces the match in - * `ClineProvider.setViewStateId` when a view registers this id, so a view cannot adopt - * another view's persisted selections. Forwarding these fields from the launch handler - * is wired by the follow-up launch unit. + * Enforced by `ClineProvider.setViewStateId`; launch forwarding ships in the wiring unit. */ viewStateClaim?: string taskId?: string diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 306253578a..07a5fc3d21 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -55,12 +55,7 @@ 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. - */ +/** Already-deleted profile: callers branch on this type instead of matching message text. */ export class ProviderSettingsNotFoundError extends Error { constructor(message: string) { super(message) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 169daed1e0..fdd174e4e4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -377,12 +377,8 @@ export class ClineProvider public readonly viewId: string /** - * Stable identifier for persisted per-view state keys. - * Defaults to viewId until the webview reports its VS Code-persisted id. - * A durable entry under this id is bound to the webview that wrote it by a host-minted - * claim token (see setViewStateId), so the entry - and the profile its pin brings back - * into the state post - can outlive this instance without becoming adoptable by any - * other webview. + * Stable identifier for persisted per-view state keys; binds a durable entry to the + * webview that wrote it through the host-minted claim token. */ private viewStateId: string @@ -605,13 +601,8 @@ export class ClineProvider } /** - * 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. + * Persist the non-secret selections through the serialized write queue, re-keying a + * pre-launch write to the stable id once the webview registers one. */ private async savePersistedViewState(values: Partial): Promise { // Capture the id at change time: a write belongs to the view that was active @@ -699,11 +690,8 @@ export class ClineProvider } /** - * Re-binds a claim-less viewStates entry to this webview with a freshly minted claim. - * Entries written before claims existed cannot be attributed to any webview, so their - * selections are dropped instead of being carried over: keeping them would let the new - * token re-expose them on a later launch, which is exactly the disclosure this binding - * exists to prevent. Runs through the serialized write queue like every other mutation. + * Re-bind a claim-less viewStates entry with a freshly minted claim; its unowned + * selections are dropped so the new token cannot re-expose them. */ private async rebindUnboundViewStateEntry(viewStateId: string): Promise { const claimToken = crypto.randomUUID() @@ -749,18 +737,7 @@ export class ClineProvider ) } - /** - * 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. An entry this instance never wrote is - * left alone, so a stale entry that happens to carry the same session-local name is - * not adopted. - */ + /** Re-key this instance's authored pre-launch entry to the stable id. */ private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { const previousViewStateId = this.viewId @@ -808,14 +785,8 @@ export class ClineProvider } /** - * 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. - * - * `presentedClaim` is the host-minted token this webview was given for that id. It is the - * only proof of ownership: an entry that carries a claim is loaded only when the presented - * token matches it, so a webview cannot adopt another (e.g. disposed, still durable) view's - * id and read that view's pinned profile - and the profile secrets copied into the state - * post - back out of it. The check runs before any re-keying or loading. + * Register the stable view id and load its persisted selections. An entry that carries a + * claim is loaded only when the presented host-minted token matches it. */ public async setViewStateId(viewStateId: string | undefined, presentedClaim?: string): Promise { // The webview generates this id on purpose - it has to survive a webview reload, From 0f341c64b7752be0ef5a3392aa63d6c2c015be7d Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:31:31 +0800 Subject: [PATCH 18/51] chore: align the shared view-identity docs with N1 --- packages/types/src/global-settings.ts | 6 +-- packages/types/src/vscode-extension-host.ts | 5 +-- src/core/config/ProviderSettingsManager.ts | 7 +-- src/core/webview/ClineProvider.ts | 47 ++++----------------- 4 files changed, 13 insertions(+), 52 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index b09bae2635..3da73ee0fe 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -122,10 +122,8 @@ export const viewStateSchema = z.object({ currentApiConfigName: z.string().optional(), updatedAt: z.number().optional(), /** - * Host-minted capability token binding this entry to the webview that wrote it. - * Minted by the extension host (`crypto.randomUUID()`), delivered to that webview and - * never accepted from webview input. An entry is only re-adopted for a webview that - * presents this token, so a view cannot read another (e.g. closed) view's selections. + * Host-minted capability token binding this entry to the webview that wrote it; never + * accepted from webview input and only a matching token re-adopts the entry. */ claimToken: z.string().optional(), }) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c4e7788928..c77dbb14c4 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -681,10 +681,7 @@ export interface WebviewMessage { viewStateId?: string /** * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. - * The webview only echoes what it was given; the host enforces the match in - * `ClineProvider.setViewStateId` when a view registers this id, so a view cannot adopt - * another view's persisted selections. Forwarding these fields from the launch handler - * is wired by the follow-up launch unit. + * Enforced by `ClineProvider.setViewStateId`; launch forwarding ships in the wiring unit. */ viewStateClaim?: string taskId?: string diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 306253578a..07a5fc3d21 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -55,12 +55,7 @@ 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. - */ +/** Already-deleted profile: callers branch on this type instead of matching message text. */ export class ProviderSettingsNotFoundError extends Error { constructor(message: string) { super(message) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2f534c7afa..5027f01751 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -377,12 +377,8 @@ export class ClineProvider public readonly viewId: string /** - * Stable identifier for persisted per-view state keys. - * Defaults to viewId until the webview reports its VS Code-persisted id. - * A durable entry under this id is bound to the webview that wrote it by a host-minted - * claim token (see setViewStateId), so the entry - and the profile its pin brings back - * into the state post - can outlive this instance without becoming adoptable by any - * other webview. + * Stable identifier for persisted per-view state keys; binds a durable entry to the + * webview that wrote it through the host-minted claim token. */ private viewStateId: string @@ -605,13 +601,8 @@ export class ClineProvider } /** - * 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. + * Persist the non-secret selections through the serialized write queue, re-keying a + * pre-launch write to the stable id once the webview registers one. */ private async savePersistedViewState(values: Partial): Promise { // Capture the id at change time: a write belongs to the view that was active @@ -699,11 +690,8 @@ export class ClineProvider } /** - * Re-binds a claim-less viewStates entry to this webview with a freshly minted claim. - * Entries written before claims existed cannot be attributed to any webview, so their - * selections are dropped instead of being carried over: keeping them would let the new - * token re-expose them on a later launch, which is exactly the disclosure this binding - * exists to prevent. Runs through the serialized write queue like every other mutation. + * Re-bind a claim-less viewStates entry with a freshly minted claim; its unowned + * selections are dropped so the new token cannot re-expose them. */ private async rebindUnboundViewStateEntry(viewStateId: string): Promise { const claimToken = crypto.randomUUID() @@ -749,18 +737,7 @@ export class ClineProvider ) } - /** - * 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. An entry this instance never wrote is - * left alone, so a stale entry that happens to carry the same session-local name is - * not adopted. - */ + /** Re-key this instance's authored pre-launch entry to the stable id. */ private async rekeyPersistedViewStateEntry(nextViewStateId: string): Promise { const previousViewStateId = this.viewId @@ -808,14 +785,8 @@ export class ClineProvider } /** - * 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. - * - * `presentedClaim` is the host-minted token this webview was given for that id. It is the - * only proof of ownership: an entry that carries a claim is loaded only when the presented - * token matches it, so a webview cannot adopt another (e.g. disposed, still durable) view's - * id and read that view's pinned profile - and the profile secrets copied into the state - * post - back out of it. The check runs before any re-keying or loading. + * Register the stable view id and load its persisted selections. An entry that carries a + * claim is loaded only when the presented host-minted token matches it. */ public async setViewStateId(viewStateId: string | undefined, presentedClaim?: string): Promise { // The webview generates this id on purpose - it has to survive a webview reload, From f79870c387ad96039f5067ebb6c0ac06b51a5196 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:32:14 +0800 Subject: [PATCH 19/51] chore: tighten the reset-generation comments --- src/core/config/ContextProxy.ts | 6 ++---- src/core/config/__tests__/ContextProxy.spec.ts | 6 ++---- src/core/config/importExport.ts | 4 +--- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index e6aea60685..c93b42628f 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -377,8 +377,7 @@ export class ContextProxy { try { return await this.originalContext.globalState.update(key, value) } catch (error) { - // A newer write for the same key may already own the cache: only undo this write - // while it is still the newest one. + // Only undo this write while it is still the newest for the key. if (this.stateWriteGenerations.get(key) === generation) { this.stateCache[key] = previousCachedValue } @@ -607,8 +606,7 @@ export class ContextProxy { // Clear in-memory caches this.stateCache = {} this.secretCache = {} - // A write that started before this reset must not restore its pre-reset value when it - // rejects afterwards, so advance every generation the pending writes still hold. + // Advance every tracked generation so a pending write cannot restore pre-reset state. for (const key of this.stateWriteGenerations.keys()) { this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1) } diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index a28ca249ac..5231e730d9 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -133,8 +133,7 @@ describe("ContextProxy", () => { const firstWrite = proxy.updateGlobalState("apiProvider", "anthropic") await proxy.updateGlobalState("apiProvider", "openai") - // The older write rejects after a newer value already reached the cache: rolling - // the cache back to the pre-first-write value would diverge from storage. + // The older write rejects after a newer value reached the cache. rejectFirst(new Error("disk full")) await expect(firstWrite).rejects.toThrow("disk full") @@ -155,8 +154,7 @@ describe("ContextProxy", () => { rejectPending(new Error("disk full")) await expect(pending).rejects.toThrow("disk full") - // The reset cleared the cache: restoring the pre-reset value would resurrect state - // that storage no longer holds. + // The reset cleared the cache: the old value must not come back. expect(proxy.getGlobalState("apiProvider")).toBeUndefined() }) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 29c9497552..530e29cbb9 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -99,9 +99,7 @@ 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. + // Per-view selections are machine-local: they must not import across machines. if (key === "viewStates") { continue } From d52cd47a2cf03670aa58881d9f1ce35fd9c3ce9b Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:32:44 +0800 Subject: [PATCH 20/51] chore: tighten the N2 test comments --- src/core/webview/__tests__/ClineProvider.spec.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 0e033397da..a7adf0e440 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -428,9 +428,7 @@ type StalledProfile = { function stallProviderSettingsProfile(provider: ClineProvider) { let resolveProfile: (value: StalledProfile) => void = () => {} const getProfileSpy = vi.fn(() => { - // Only one lookup is resolvable: resolveProfile is bound to the first - // promise's resolver, so a second getProfile would strand its promise. - // Fail loudly instead of hanging. + // Only the first lookup is resolvable; fail loudly on a second. if (getProfileSpy.mock.calls.length > 1) { throw new Error( "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", @@ -440,9 +438,7 @@ function stallProviderSettingsProfile(provider: ClineProvider) { }) // @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. + // Wrapper keeps the reassigned resolver visible to the caller. return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } } @@ -4316,10 +4312,7 @@ describe("ClineProvider", () => { }) 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". + // No per-view entry: getState() falls back to the shared defaults. expect(state.mode).toBe("code") expect(state.currentApiConfigName).toBe("default") From f652d7e1323d38ab6728d5c75594b33c52851958 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:55:49 +0800 Subject: [PATCH 21/51] chore: drop the now-unused buffer-clear helper --- src/core/webview/ClineProvider.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 516c050d81..940a846c43 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3917,9 +3917,6 @@ export class ClineProvider } /** Clear the buffer so getState() falls back to the shared values. */ - private _clearViewLocalState(): void { - this.viewLocalState = {} - } async resetState() { const answer = await vscode.window.showInformationMessage( t("common:confirmation.reset_state"), From 2a91e73802711edd9d33424cfdf1f71621f5cbcd Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:56:02 +0800 Subject: [PATCH 22/51] chore: drop the now-unused buffer-clear helper --- src/core/webview/ClineProvider.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3bd0426d0e..cce71bc0ff 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3930,9 +3930,6 @@ export class ClineProvider } /** Clear the buffer so getState() falls back to the shared values. */ - private _clearViewLocalState(): void { - this.viewLocalState = {} - } async resetState() { const answer = await vscode.window.showInformationMessage( t("common:confirmation.reset_state"), From 7a48eed62d85279d07c8476d0570cb4eed7c95ec Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:56:56 +0800 Subject: [PATCH 23/51] chore: tighten the shared view-state docs --- packages/types/src/global-settings.ts | 5 +---- packages/types/src/vscode-extension-host.ts | 5 +---- src/core/webview/ClineProvider.ts | 20 ++++---------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3da73ee0fe..f9fe1f1139 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -121,10 +121,7 @@ export const viewStateSchema = z.object({ mode: z.string().optional(), currentApiConfigName: z.string().optional(), updatedAt: z.number().optional(), - /** - * Host-minted capability token binding this entry to the webview that wrote it; never - * accepted from webview input and only a matching token re-adopts the entry. - */ + /** Host-minted token binding this entry to the webview that wrote it. */ claimToken: z.string().optional(), }) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c77dbb14c4..3954246e15 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -679,10 +679,7 @@ export interface WebviewMessage { | "themeFixtureProbeResponse" text?: string viewStateId?: string - /** - * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. - * Enforced by `ClineProvider.setViewStateId`; launch forwarding ships in the wiring unit. - */ + /** For `webviewDidLaunch`: the claim delivered for `viewStateId`, enforced by setViewStateId. */ viewStateClaim?: string taskId?: string editedMessageContent?: string diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 940a846c43..559568579e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -376,10 +376,7 @@ export class ClineProvider */ public readonly viewId: string - /** - * Stable identifier for persisted per-view state keys; binds a durable entry to the - * webview that wrote it through the host-minted claim token. - */ + /** Stable id for persisted per-view state keys, bound by the host-minted claim. */ private viewStateId: string /** @@ -600,10 +597,7 @@ export class ClineProvider return Object.assign(Object.create(null), viewStates) } - /** - * Persist the non-secret selections through the serialized write queue, re-keying a - * pre-launch write to the stable id once the webview registers one. - */ + /** Persist the non-secret selections through the serialized write queue. */ 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. @@ -689,10 +683,7 @@ export class ClineProvider await write } - /** - * Re-bind a claim-less viewStates entry with a freshly minted claim; its unowned - * selections are dropped so the new token cannot re-expose them. - */ + /** Re-bind a claim-less entry with a fresh claim; unowned selections are dropped. */ private async rebindUnboundViewStateEntry(viewStateId: string): Promise { const claimToken = crypto.randomUUID() let rebound = false @@ -784,10 +775,7 @@ export class ClineProvider await write } - /** - * Register the stable view id and load its persisted selections. An entry that carries a - * claim is loaded only when the presented host-minted token matches it. - */ + /** Register the stable view id and load its persisted selections; a claim must match. */ public async setViewStateId(viewStateId: string | undefined, presentedClaim?: string): Promise { // The webview generates this id on purpose - it has to survive a webview reload, // which recreates the view on both sides - so the host cannot mint it instead. From 3f2a32cfe323124e8a07dbfced1cf8d3e8d05b01 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:56:59 +0800 Subject: [PATCH 24/51] chore: tighten the shared view-state docs --- packages/types/src/global-settings.ts | 5 +---- packages/types/src/vscode-extension-host.ts | 5 +---- src/core/webview/ClineProvider.ts | 20 ++++---------------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 3da73ee0fe..f9fe1f1139 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -121,10 +121,7 @@ export const viewStateSchema = z.object({ mode: z.string().optional(), currentApiConfigName: z.string().optional(), updatedAt: z.number().optional(), - /** - * Host-minted capability token binding this entry to the webview that wrote it; never - * accepted from webview input and only a matching token re-adopts the entry. - */ + /** Host-minted token binding this entry to the webview that wrote it. */ claimToken: z.string().optional(), }) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c77dbb14c4..3954246e15 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -679,10 +679,7 @@ export interface WebviewMessage { | "themeFixtureProbeResponse" text?: string viewStateId?: string - /** - * For `webviewDidLaunch`: the host-minted claim previously delivered for `viewStateId`. - * Enforced by `ClineProvider.setViewStateId`; launch forwarding ships in the wiring unit. - */ + /** For `webviewDidLaunch`: the claim delivered for `viewStateId`, enforced by setViewStateId. */ viewStateClaim?: string taskId?: string editedMessageContent?: string diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index cce71bc0ff..e4fc066188 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -376,10 +376,7 @@ export class ClineProvider */ public readonly viewId: string - /** - * Stable identifier for persisted per-view state keys; binds a durable entry to the - * webview that wrote it through the host-minted claim token. - */ + /** Stable id for persisted per-view state keys, bound by the host-minted claim. */ private viewStateId: string /** @@ -600,10 +597,7 @@ export class ClineProvider return Object.assign(Object.create(null), viewStates) } - /** - * Persist the non-secret selections through the serialized write queue, re-keying a - * pre-launch write to the stable id once the webview registers one. - */ + /** Persist the non-secret selections through the serialized write queue. */ 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. @@ -689,10 +683,7 @@ export class ClineProvider await write } - /** - * Re-bind a claim-less viewStates entry with a freshly minted claim; its unowned - * selections are dropped so the new token cannot re-expose them. - */ + /** Re-bind a claim-less entry with a fresh claim; unowned selections are dropped. */ private async rebindUnboundViewStateEntry(viewStateId: string): Promise { const claimToken = crypto.randomUUID() let rebound = false @@ -784,10 +775,7 @@ export class ClineProvider await write } - /** - * Register the stable view id and load its persisted selections. An entry that carries a - * claim is loaded only when the presented host-minted token matches it. - */ + /** Register the stable view id and load its persisted selections; a claim must match. */ public async setViewStateId(viewStateId: string | undefined, presentedClaim?: string): Promise { // The webview generates this id on purpose - it has to survive a webview reload, // which recreates the view on both sides - so the host cannot mint it instead. From 00c0f8d86a1f387bbf05b4f6d6abaf7397b1298c Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:57:38 +0800 Subject: [PATCH 25/51] chore: tighten the N2 comments --- src/core/config/__tests__/ContextProxy.spec.ts | 2 -- src/core/config/__tests__/importExport.spec.ts | 3 +-- src/core/webview/ClineProvider.ts | 3 --- src/core/webview/__tests__/ClineProvider.spec.ts | 6 +----- 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 5231e730d9..12f8c67174 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -133,7 +133,6 @@ describe("ContextProxy", () => { const firstWrite = proxy.updateGlobalState("apiProvider", "anthropic") await proxy.updateGlobalState("apiProvider", "openai") - // The older write rejects after a newer value reached the cache. rejectFirst(new Error("disk full")) await expect(firstWrite).rejects.toThrow("disk full") @@ -154,7 +153,6 @@ describe("ContextProxy", () => { rejectPending(new Error("disk full")) await expect(pending).rejects.toThrow("disk full") - // The reset cleared the cache: the old value must not come back. expect(proxy.getGlobalState("apiProvider")).toBeUndefined() }) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index b8dff9c0ed..238362d5c2 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -370,8 +370,7 @@ describe("importExport", () => { }) 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. + // Importing must not apply another machine's view pins. expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) expect(result).not.toHaveProperty("globalSettings.viewStates") }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 559568579e..d571390b26 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -452,7 +452,6 @@ 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 @@ -2565,8 +2564,6 @@ export class ClineProvider if (signal.aborted) return id - // Refresh other views pinned to this profile so their overlays cannot go stale. - // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a7adf0e440..2e1d2a0b8e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -428,7 +428,6 @@ type StalledProfile = { function stallProviderSettingsProfile(provider: ClineProvider) { let resolveProfile: (value: StalledProfile) => void = () => {} const getProfileSpy = vi.fn(() => { - // Only the first lookup is resolvable; fail loudly on a second. if (getProfileSpy.mock.calls.length > 1) { throw new Error( "stallProviderSettingsProfile: getProfile called more than once; only one lookup is resolvable", @@ -438,7 +437,6 @@ function stallProviderSettingsProfile(provider: ClineProvider) { }) // @ts-ignore - Reassign the readonly providerSettingsManager for the test; the double only backs getProfile. provider.providerSettingsManager = { getProfile: getProfileSpy } - // Wrapper keeps the reassigned resolver visible to the caller. return { getProfile: getProfileSpy, resolveProfile: (value: StalledProfile) => resolveProfile(value) } } @@ -3891,7 +3889,6 @@ describe("ClineProvider", () => { new ContextProxy(mockContext), new WebviewFocusTracker(), ) - // A string in storage is corrupt: the fresh-read guard must not spread it. await mockContext.globalState.update("viewStates", "corrupted-storage-value") expect(provider["getPersistedViewStates"]({ fresh: true })).toEqual({}) await provider.dispose() @@ -3951,8 +3948,7 @@ describe("ClineProvider", () => { new ContextProxy(mockContext), new WebviewFocusTracker(), ) - // Seed through the write path so this instance is the author of the entry under - // its temporary id; an entry authored by another session must not be adopted. + // Seed through the write path so this instance authors 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({ From 5a6693ba5873bff67863fad1c931e2c60cc05273 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:57:57 +0800 Subject: [PATCH 26/51] chore: tighten the N2 comments further --- src/core/config/__tests__/ContextProxy.spec.ts | 3 --- src/core/webview/ClineProvider.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 12f8c67174..975081e92f 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -132,7 +132,6 @@ describe("ContextProxy", () => { const firstWrite = proxy.updateGlobalState("apiProvider", "anthropic") await proxy.updateGlobalState("apiProvider", "openai") - rejectFirst(new Error("disk full")) await expect(firstWrite).rejects.toThrow("disk full") @@ -147,11 +146,9 @@ describe("ContextProxy", () => { rejectPending = reject }), ) - const pending = proxy.updateGlobalState("apiProvider", "anthropic") await proxy.resetAllState() rejectPending(new Error("disk full")) - await expect(pending).rejects.toThrow("disk full") expect(proxy.getGlobalState("apiProvider")).toBeUndefined() }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index d571390b26..562b93c8ec 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -895,7 +895,6 @@ export class ClineProvider /** Load non-secret persisted selections; in-flight buffer edits are reapplied on top. */ private async loadViewState(): Promise { - // A newer id registered while this load is in flight must win. const loadedForViewId = this.viewStateId // Before registration the key is the session-local name, which can be a stale entry From 589531a15fe562690c0272c0d70467e3af4ffc94 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:55:07 +0800 Subject: [PATCH 27/51] fix(webview): address the N3 review findings - reset runs after the durable view-state queue drains and bumps a reset generation that rejects pre-reset queued writes - a failed activation group restores the profile list, shared selection, mode binding and provider settings before rethrowing - the view overlay is refreshed only after every activation write landed - the shared-value rollback is guarded by the per-key write generation, not the value alone --- src/core/config/ContextProxy.ts | 5 ++ src/core/webview/ClineProvider.ts | 88 +++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index e6aea60685..b34ec926f4 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -386,6 +386,11 @@ export class ContextProxy { } } + /** Current per-key write generation, used by callers whose rollback must not clobber a newer write. */ + public getStateWriteGeneration(key: K): number { + return this.stateWriteGenerations.get(key) ?? 0 + } + private getAllGlobalState(): GlobalState { return Object.fromEntries(GLOBAL_STATE_KEYS.map((key) => [key, this.getGlobalState(key)])) } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e4fc066188..9bad89d3d3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -239,6 +239,8 @@ 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() + // A durable view-state write that started before a reset must not persist after the clear. + private static viewStatesResetGeneration = 0 private webviewDisposables: vscode.Disposable[] = [] private pendingThemeFixtureProbes = new Map< string, @@ -599,12 +601,19 @@ export class ClineProvider /** Persist the non-secret selections through the serialized write queue. */ private async savePersistedViewState(values: Partial): Promise { + const resetGeneration = ClineProvider.viewStatesResetGeneration // 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 // Set by the queued write when it had to mint a claim, delivered after it lands. let mintedClaimToken: string | undefined const write = ClineProvider.persistedViewStateWriteQueue.then(async () => { + // The reset cleared viewStates while this write was queued: storing the pre-reset + // value now would resurrect it on the next reload. + if (ClineProvider.viewStatesResetGeneration !== resetGeneration) { + return + } + const states = this.getPersistedViewStates({ fresh: true }) // The first write to an unowned temporary id claims the slot instead of merging // into it: temporary names restart from 0 on every host start, so an entry under @@ -2548,23 +2557,69 @@ export class ClineProvider // We should probably switch to that and verify that it works. // I left the original implementation in just to be safe. // `allSettled` so a caller that rolls back after a failure never races a write still in flight. + // Capture every store the activation group rewrites: one member can reject after + // others landed, and restoring only the shared name would leave the old name next to + // the new settings and a mode binding pointing at the new profile. + const previousList = this.contextProxy.getValue("listApiConfigMeta") + const previousName = this.contextProxy.getValue("currentApiConfigName") + const previousProviderSettings = this.contextProxy.getProviderSettings() + // Test doubles and older managers may not implement the read: absence means "no previous + // binding to restore", not a failed activation. + const previousModeConfigId = this.providerSettingsManager.getModeConfigId + ? await this.providerSettingsManager.getModeConfigId(mode) + : undefined + const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), // Route through setValue so the buffer tracks the activated profile. this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), - // setProviderSettings bypasses the buffer path: refresh this view's overlay. - this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", ) - if (failedActivation) throw failedActivation.reason + if (failedActivation) { + const restoreFailures: string[] = [] + const restore = async (label: string, action: () => Promise) => { + try { + await action() + } catch (restoreError) { + restoreFailures.push( + `${label}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ) + } + } + await restore("profile list", () => this.contextProxy.setValue("listApiConfigMeta", previousList)) + await restore("shared selection", () => + this.contextProxy.setValue("currentApiConfigName", previousName ?? name), + ) + await restore("mode binding", () => + this.providerSettingsManager.setModeConfig(mode, previousModeConfigId as string), + ) + await restore("shared provider settings", () => + this.contextProxy.setProviderSettings(previousProviderSettings), + ) + + if (restoreFailures.length > 0) { + const original = failedActivation.reason + const prefix = original instanceof Error ? original.message : String(original) + throw new Error(`${prefix}; activation rollback also failed: ${restoreFailures.join("; ")}`, { + cause: original instanceof Error ? original : undefined, + }) + } + + throw failedActivation.reason + } if (signal.aborted) return id + // setProviderSettings bypasses the buffer path: refresh this view's overlay only after + // every activation write landed, so a failed group cannot leave the overlay ahead of + // the restored shared state. + await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + // Refresh other views pinned to this profile so their overlays cannot go stale. // Change the provider for the current task. @@ -3866,15 +3921,19 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { const previousSharedValues = { [key]: this.contextProxy.getValue(key) } as RooCodeSettings + // This write produces the next generation for the key: only restore while it is still newest. + const writeGeneration = this.contextProxy.getStateWriteGeneration(key as never) + 1 await this.contextProxy.setValue(key, value) try { await this._saveViewLocalStateFromMutation({ [key]: value }) } catch (error) { // The per-view record did not land: restore the prior shared value so the two // persistence layers cannot diverge, then surface the original failure. - await this._restoreSharedValuesAfterFailedViewWrite(previousSharedValues, { - [key]: value, - } as RooCodeSettings) + await this._restoreSharedValuesAfterFailedViewWrite( + previousSharedValues, + { [key]: value } as RooCodeSettings, + { [key]: writeGeneration } as Record, + ) throw error } } @@ -3941,6 +4000,11 @@ export class ClineProvider } } + // A view-state write queued before the reset would otherwise read the cleared map and store + // its pre-reset entry after resetAllState(): drain the queue first, then bump the + // generation so a write that started before this point is rejected inside the queue. + await ClineProvider.persistedViewStateWriteQueue.catch(() => undefined) + ClineProvider.viewStatesResetGeneration += 1 await this.contextProxy.resetAllState() await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() @@ -5350,14 +5414,20 @@ export class ClineProvider private async _restoreSharedValuesAfterFailedViewWrite( previous: RooCodeSettings, attempted: RooCodeSettings, + generations: Record = {}, ): Promise { // Roll back only the keys this attempt wrote and that still hold the value it wrote. - // A concurrent provider may have written a newer value in the meantime; restoring the - // captured previous value would then clobber it for every view that reads shared state. + // A concurrent provider may have written a newer value in the meantime - even the same + // value - so the per-key write generation must also still match; restoring otherwise + // would clobber that newer write for every view that reads shared state. const rollback: RooCodeSettings = {} as RooCodeSettings for (const key of Object.keys(attempted) as (keyof RooCodeSettings)[]) { - if (this.contextProxy.getValue(key) === attempted[key]) { + const generation = generations[key] + if ( + this.contextProxy.getValue(key) === attempted[key] && + (generation === undefined || this.contextProxy.getStateWriteGeneration(key as never) === generation) + ) { ;(rollback as Record)[key] = previous[key] } } From 2b8270a912a37ff806ca07ce62de497e224c7a1d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:52:31 +0800 Subject: [PATCH 28/51] fix(webview): address the N3 reset and activation rollback findings - savePersistedViewState reports whether it stored, and the buffer half is skipped when a reset rejected the write - add a regression test for the reset-generation guard behind a held write queue - activation rollback restores this view own pinned name, restores the previous shared name as-is, and only runs while this activation still owns the shared selection generation --- src/core/webview/ClineProvider.ts | 38 ++++++++++++++++--- .../webview/__tests__/ClineProvider.spec.ts | 38 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9bad89d3d3..1a12769059 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -600,7 +600,8 @@ export class ClineProvider } /** Persist the non-secret selections through the serialized write queue. */ - private async savePersistedViewState(values: Partial): Promise { + private async savePersistedViewState(values: Partial): Promise { + let landed = true const resetGeneration = ClineProvider.viewStatesResetGeneration // 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. @@ -611,6 +612,8 @@ export class ClineProvider // The reset cleared viewStates while this write was queued: storing the pre-reset // value now would resurrect it on the next reload. if (ClineProvider.viewStatesResetGeneration !== resetGeneration) { + // The caller must not apply the in-memory half of a mutation storage rejected. + landed = false return } @@ -675,6 +678,8 @@ export class ClineProvider if (mintedClaimToken) { this.postViewStateClaimToWebview(viewStateId, mintedClaimToken) } + + return landed } /** @@ -2562,6 +2567,9 @@ export class ClineProvider // the new settings and a mode binding pointing at the new profile. const previousList = this.contextProxy.getValue("listApiConfigMeta") const previousName = this.contextProxy.getValue("currentApiConfigName") + const previousViewName = this.viewLocalState.currentApiConfigName + // This activation owns the shared selection until a newer write bumps the generation. + const expectedNameGeneration = this.contextProxy.getStateWriteGeneration("currentApiConfigName") + 1 const previousProviderSettings = this.contextProxy.getProviderSettings() // Test doubles and older managers may not implement the read: absence means "no previous // binding to restore", not a failed activation. @@ -2581,6 +2589,16 @@ export class ClineProvider ) if (failedActivation) { + // The mutation queue is per view, but ContextProxy is shared: another view may + // have activated a profile between the group and this rollback. Restoring then + // would overwrite that newer activation with our captured values. + if (this.contextProxy.getStateWriteGeneration("currentApiConfigName") !== expectedNameGeneration) { + this.log( + "[persistProviderProfile] Skipping the activation rollback: another view wrote the shared selection", + ) + throw failedActivation.reason + } + const restoreFailures: string[] = [] const restore = async (label: string, action: () => Promise) => { try { @@ -2593,7 +2611,10 @@ export class ClineProvider } await restore("profile list", () => this.contextProxy.setValue("listApiConfigMeta", previousList)) await restore("shared selection", () => - this.contextProxy.setValue("currentApiConfigName", previousName ?? name), + this.contextProxy.setValue("currentApiConfigName", previousName), + ) + await restore("view-local selection", () => + this._saveViewLocalStateFromMutation({ currentApiConfigName: previousViewName }), ) await restore("mode binding", () => this.providerSettingsManager.setModeConfig(mode, previousModeConfigId as string), @@ -3953,14 +3974,19 @@ export class ClineProvider private async _saveViewLocalStateFromMutation( values: Partial & Partial, ): Promise { - await this._persistViewLocalStateFromMutation(values) + const landed = await this._persistViewLocalStateFromMutation(values) + if (!landed) { + // A reset that beat this write cleared the buffer; applying the value now would + // leave the view reporting a selection a reload drops. + return + } this._updateViewLocalStateFromMutation(values) } /** Write the durable subset of a mutation into this view's viewStates entry. */ private async _persistViewLocalStateFromMutation( values: Partial & Partial, - ): Promise { + ): Promise { const persistedValues: Partial = {} if ("mode" in values) { @@ -3972,8 +3998,10 @@ export class ClineProvider } if ("mode" in persistedValues || "currentApiConfigName" in persistedValues) { - await this.savePersistedViewState(persistedValues) + return this.savePersistedViewState(persistedValues) } + + return true } /** Clear the buffer so getState() falls back to the shared values. */ diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a6dde04808..b6281278d4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3668,6 +3668,44 @@ describe("ClineProvider", () => { }) }) + describe("view state persistence across a reset", () => { + it("drops a queued view-state write when a reset clears viewStates, including the buffer half", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + await provider["setViewStateId"]("stable-sidebar-view") + + // Hold the shared write queue so the save below is still queued when the reset + // generation advances, exactly like resetState() racing a save from the webview. + let releaseQueue!: () => void + const heldQueue = new Promise((resolve) => { + releaseQueue = resolve + }) + ClineProvider["persistedViewStateWriteQueue"] = heldQueue + + const queuedSave = provider.saveViewState("mode", "ask") + // resetState() clears the durable map after bumping the generation (resetAllState + // then wipes the cache); the generation guard must reject the queued write. + ClineProvider["viewStatesResetGeneration"] += 1 + await provider.contextProxy.setValue("viewStates", undefined) + + releaseQueue() + await queuedSave + + // The write started before the reset: storage stays cleared and the buffer does + // not keep a mode that a reload would drop. + expect(mockContext.globalState.get("viewStates")).toBeUndefined() + expect(provider["viewLocalState"].mode).toBeUndefined() + + await provider.dispose() + }) + }) + describe("view state claim binding", () => { it("refuses a viewStateId bound by a concurrent registration through setViewStateId", async () => { const provider = new ClineProvider( From 18b687cfcf565e53a2f9b0b7f9ae6bd7d927e59a Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:32:35 +0800 Subject: [PATCH 29/51] fix(webview): address the N3 rollback ownership and reset buffer findings - mode binding and provider settings are restored even when another view owns the shared selection - the shared-selection restore rechecks the write generation immediately before writing - resetState clears this view buffer after the shared reset --- src/core/webview/ClineProvider.ts | 42 ++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1a12769059..541ef5451e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2590,14 +2590,11 @@ export class ClineProvider if (failedActivation) { // The mutation queue is per view, but ContextProxy is shared: another view may - // have activated a profile between the group and this rollback. Restoring then - // would overwrite that newer activation with our captured values. - if (this.contextProxy.getStateWriteGeneration("currentApiConfigName") !== expectedNameGeneration) { - this.log( - "[persistProviderProfile] Skipping the activation rollback: another view wrote the shared selection", - ) - throw failedActivation.reason - } + // have activated a profile between the group and this rollback. Only the stores + // that view now owns (profile list, shared selection) may be skipped - the mode + // binding and provider settings this activation wrote must still be undone. + const ownsSharedSelection = () => + this.contextProxy.getStateWriteGeneration("currentApiConfigName") === expectedNameGeneration const restoreFailures: string[] = [] const restore = async (label: string, action: () => Promise) => { @@ -2609,10 +2606,15 @@ export class ClineProvider ) } } - await restore("profile list", () => this.contextProxy.setValue("listApiConfigMeta", previousList)) - await restore("shared selection", () => - this.contextProxy.setValue("currentApiConfigName", previousName), - ) + await restore("profile list", async () => { + if (!ownsSharedSelection()) { + this.log( + "[persistProviderProfile] Skipping the profile-list restore: another view wrote the shared selection", + ) + return + } + await this.contextProxy.setValue("listApiConfigMeta", previousList) + }) await restore("view-local selection", () => this._saveViewLocalStateFromMutation({ currentApiConfigName: previousViewName }), ) @@ -2622,6 +2624,17 @@ export class ClineProvider await restore("shared provider settings", () => this.contextProxy.setProviderSettings(previousProviderSettings), ) + // Recheck immediately before the write: the restores above await storage, so + // another view may have written the shared selection since the group failed. + await restore("shared selection", async () => { + if (!ownsSharedSelection()) { + this.log( + "[persistProviderProfile] Skipping the shared-selection restore: another view wrote it", + ) + return + } + await this.contextProxy.setValue("currentApiConfigName", previousName) + }) if (restoreFailures.length > 0) { const original = failedActivation.reason @@ -4034,6 +4047,11 @@ export class ClineProvider await ClineProvider.persistedViewStateWriteQueue.catch(() => undefined) ClineProvider.viewStatesResetGeneration += 1 await this.contextProxy.resetAllState() + + // The reset cleared the shared store: drop this view's buffered selections too, or + // getValues()/getConfiguration() keep serving pre-reset values. + this.viewLocalState = {} + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() From 38d9fee7348e710a9712714260cb6ad4b832c72a Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:22:21 +0800 Subject: [PATCH 30/51] test(webview): type the Task mock options so the suppression count stays exact --- src/core/webview/__tests__/ClineProvider.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9703e6d545..6321ec445d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -276,7 +276,7 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { }) vi.mock("../../task/Task", () => ({ - Task: vi.fn().mockImplementation(function (options: any) { + Task: vi.fn().mockImplementation(function (options: TaskOptions) { return { api: undefined, abortTask: vi.fn(), From f577f1556ebb798360a4a02bc4e6e7291273058f Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:02:07 +0800 Subject: [PATCH 31/51] fix(types): restore the view-state message members in packages/types --- packages/types/src/vscode-extension-host.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 6075bbee58..b17b7f1751 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -112,7 +112,14 @@ export interface ExtensionMessage { | "rooHistoryImportProgress" | "themeFixtureProbeRequest" | "diagnosticsRequest" + | "viewStateClaim" text?: string + /** + * For `viewStateClaim`: the durable `viewStates` key the delivered claim governs. + */ + viewStateId?: string + /** For `viewStateClaim`: the host-minted token the webview stores and echoes on launch. */ + viewStateClaim?: string /** For fileContent: { path, content, error? } */ fileContent?: { path: string; content: string | null; error?: string } /** For originalContent: the pre-edit file content of the requested tool message (null when unavailable) */ From cfce4b948bde4f72b65c0826a58f6a6327f36012 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:55:41 +0800 Subject: [PATCH 32/51] fix(webview): keep per-view view-state writes and reads coherent across views Review follow-up for #2235 (N3). - prunePersistedViewStates protects the key the calling writer just stored. A backwards clock step leaves the retained window stamped later than Date.now(), and the entry a save, rebind, or re-key just wrote sorted below the limit and was dropped while the write still reported success. - getValues() overlays this view's buffered mode, profile name, and profile settings as one unit - flattened with the same key set as ContextProxy.getProviderSettings() and excluding secret keys - so a consumer never reads another view's selection or this view's name carrying another profile's settings. A buffered name whose profile failed to resolve stays paired with the shared profile rather than half-overlaid. - The persistProviderProfile rollback gains a per-store ownership signal instead of gating everything on the shared-name generation: the mode binding is undone only while it still points at this activation's profile id, and the shared provider settings only while they still hold the write generation this activation produced. Tests: ClineProvider.spec.ts 254 passed (7 new). Each guard was proven by mutating the production line and observing the concrete assertion failure. --- src/core/webview/ClineProvider.ts | 143 ++++++++-- .../webview/__tests__/ClineProvider.spec.ts | 259 ++++++++++++++++++ 2 files changed, 381 insertions(+), 21 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e5fd38e760..5e26a8e565 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -75,6 +75,7 @@ import { modelIdKeys, providerIdentifiers, PROVIDER_SETTINGS_KEYS, + isSecretStateKey, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { TaskRegistry } from "../task/TaskRegistry" @@ -718,7 +719,7 @@ export class ClineProvider wroteEntry = true } - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states, viewStateId)) // 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. @@ -768,7 +769,7 @@ export class ClineProvider return } states[viewStateId] = { claimToken, updatedAt: Date.now() } - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states, viewStateId)) rebound = true }) @@ -790,13 +791,33 @@ export class ClineProvider /** * 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. + * + * `protectedKey` is the entry the calling writer just stored. Retention ranks entries by + * `updatedAt`, and a wall clock that moved backwards - a corrected system clock, a VM + * restored to an earlier time - leaves the whole retained window stamped later than + * `Date.now()`, so the entry written right now ranks below the limit and would be dropped + * while the write still reports success. The protected entry takes the oldest retained slot + * instead; it sorts below every retained entry by construction, so the ordering of the + * others is untouched. */ - 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), - ) + private prunePersistedViewStates( + states: Record, + protectedKey?: string, + ): Record { + const entries = Object.entries(states) + const retained = entries + .sort(([, a], [, b]) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, ClineProvider.MAX_PERSISTED_VIEW_STATES) + + if (protectedKey !== undefined && !retained.some(([key]) => key === protectedKey)) { + const protectedEntry = entries.find(([key]) => key === protectedKey) + + if (protectedEntry) { + retained[retained.length - 1] = protectedEntry + } + } + + return Object.fromEntries(retained) } /** Re-key this instance's authored pre-launch entry to the stable id. */ @@ -835,7 +856,7 @@ export class ClineProvider } } - await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states)) + await this.contextProxy.setValue("viewStates", this.prunePersistedViewStates(states, nextViewStateId)) // 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. @@ -2793,6 +2814,10 @@ export class ClineProvider // This activation owns the shared selection until a newer write bumps the generation. const expectedNameGeneration = this.contextProxy.getStateWriteGeneration("currentApiConfigName") + 1 const previousProviderSettings = this.contextProxy.getProviderSettings() + // Ownership signal for the provider-settings store, independent of the shared name: + // this activation's `setProviderSettings` writes `apiProvider` exactly once, so those + // settings are still ours while that key holds the generation the write produced. + const expectedApiProviderGeneration = this.contextProxy.getStateWriteGeneration("apiProvider") + 1 // Test doubles and older managers may not implement the read: absence means "no previous // binding to restore", not a failed activation. const previousModeConfigId = this.providerSettingsManager.getModeConfigId @@ -2810,10 +2835,16 @@ export class ClineProvider ) if (failedActivation) { - // The mutation queue is per view, but ContextProxy is shared: another view may - // have activated a profile between the group and this rollback. Only the stores - // that view now owns (profile list, shared selection) may be skipped - the mode - // binding and provider settings this activation wrote must still be undone. + // The mutation queue is per view, but ContextProxy is shared: another view may have + // activated a profile between the group and this rollback. Every store is undone + // only while this activation still owns it, and each store has its own signal - + // gating all of them on the shared-name generation would skip undoing this + // activation's mode binding and settings whenever another view changed only the + // shared name, which leaves the failed activation's values in place. + // * profile list + shared selection: the generation of the shared name, + // * mode binding: the binding still points at this activation's profile id, + // * shared provider settings: the generation of `apiProvider`, which this group + // writes exactly once. const ownsSharedSelection = () => this.contextProxy.getStateWriteGeneration("currentApiConfigName") === expectedNameGeneration @@ -2839,12 +2870,33 @@ export class ClineProvider await restore("view-local selection", () => this._saveViewLocalStateFromMutation({ currentApiConfigName: previousViewName }), ) - await restore("mode binding", () => - this.providerSettingsManager.setModeConfig(mode, previousModeConfigId as string), - ) - await restore("shared provider settings", () => - this.contextProxy.setProviderSettings(previousProviderSettings), - ) + await restore("mode binding", async () => { + // A newer activation from another view re-pointed this mode: its binding wins. + // When this activation's own binding write is what failed, the binding never + // moved and there is nothing to undo. + const currentModeConfigId = this.providerSettingsManager.getModeConfigId + ? await this.providerSettingsManager.getModeConfigId(mode) + : previousModeConfigId + + if (currentModeConfigId !== id) { + this.log( + "[persistProviderProfile] Skipping the mode-binding restore: the mode no longer points at this activation's profile", + ) + return + } + + await this.providerSettingsManager.setModeConfig(mode, previousModeConfigId as string) + }) + await restore("shared provider settings", async () => { + if (this.contextProxy.getStateWriteGeneration("apiProvider") !== expectedApiProviderGeneration) { + this.log( + "[persistProviderProfile] Skipping the shared-settings restore: another view wrote the provider settings", + ) + return + } + + await this.contextProxy.setProviderSettings(previousProviderSettings) + }) // Recheck immediately before the write: the restores above await storage, so // another view may have written the shared selection since the group failed. await restore("shared selection", async () => { @@ -4218,8 +4270,57 @@ export class ClineProvider return this.contextProxy.getValue(key) } - public getValues() { - return this.contextProxy.getValues() + /** + * This view's own values over the shared store. + * + * The buffered profile name and the settings that were loaded for it are overlaid as one + * unit. Overlaying the name on its own would hand a consumer this view's profile name + * carrying whichever profile the shared store currently holds. `loadViewState` records the + * name before it resolves the profile and keeps it when the lookup fails, so a buffered name + * with no settings behind it is overlaid only when the shared selection is that same profile - + * the case where the shared settings genuinely belong to it. Otherwise this view reads the + * shared name and settings pair until its profile resolves; it never gets one half of a pair. + */ + public getValues(): RooCodeSettings { + return { ...this.contextProxy.getValues(), ...this._viewLocalValuesOverlay() } + } + + /** The view buffer flattened onto the shared key set, ready to overlay `getValues()`. */ + private _viewLocalValuesOverlay(): RooCodeSettings { + const { mode, currentApiConfigName, apiConfiguration } = this.viewLocalState + const overlay: RooCodeSettings = {} + + if (mode !== undefined) { + overlay.mode = mode + } + + if (currentApiConfigName === undefined) { + return overlay + } + + if (apiConfiguration === undefined) { + if (currentApiConfigName === this.contextProxy.getValue("currentApiConfigName")) { + overlay.currentApiConfigName = currentApiConfigName + } + + return overlay + } + + overlay.currentApiConfigName = currentApiConfigName + + // Flattened with the same key set as `ContextProxy.getProviderSettings()`, and with the + // same exclusion: secrets live in the shared secret state, not in a per-view overlay. + // Every other provider key - including the ones this profile does not carry - comes from + // this view's profile, so the name above never travels with another profile's settings. + for (const key of PROVIDER_SETTINGS_KEYS) { + if (isSecretStateKey(key)) { + continue + } + + ;(overlay as Record)[key] = apiConfiguration[key] + } + + return overlay } public async setValues(values: RooCodeSettings) { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6321ec445d..97fa1b3c72 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4426,6 +4426,265 @@ describe("ClineProvider", () => { }) }) + describe("persisted view state pruning protection", () => { + // A wall clock that stepped backwards (a corrected system clock, a VM restored to an + // earlier time) leaves the whole retained window stamped later than `Date.now()`, so an + // entry written now ranks below the retention limit. + const futureStampedStates = (count: number) => + Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `future-view-${index}`, + { mode: "architect", updatedAt: Date.now() + 60_000 + index }, + ]), + ) + const storedViewStates = () => + mockContext.globalState.get>("viewStates") + + it("should keep the entry a save just wrote when its timestamp sorts below the retention limit", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider["setViewStateId"]("clock-skew-view") + await provider.contextProxy.setValue("viewStates", futureStampedStates(51)) + + const landed = await provider["savePersistedViewState"]({ mode: "architect", updatedAt: 1 }) + const states = storedViewStates() + + expect(landed).toBe(true) + expect(Object.keys(states ?? {})).toHaveLength(50) + expect(states?.["clock-skew-view"]).toMatchObject({ mode: "architect" }) + + await provider.dispose() + }) + + it("should keep the claim a rebind just minted when its timestamp sorts below the retention limit", async () => { + await mockContext.globalState.update("viewStates", { + ...futureStampedStates(51), + "legacy-view": { mode: "architect", updatedAt: 1 }, + }) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + + const claimToken = await provider["rebindUnboundViewStateEntry"]("legacy-view") + const states = storedViewStates() + + expect(claimToken).toEqual(expect.any(String)) + expect(Object.keys(states ?? {})).toHaveLength(50) + expect(states?.["legacy-view"]).toMatchObject({ claimToken }) + + await provider.dispose() + }) + + it("should keep the entry a re-key just moved when its timestamp sorts below the retention limit", async () => { + await mockContext.globalState.update("viewStates", futureStampedStates(51)) + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Authored under this instance's temporary id and stamped with the current time. + await provider.saveViewState("mode", "architect") + const temporaryViewStateId = provider["viewStateId"] + + await provider["rekeyPersistedViewStateEntry"]("clock-skew-stable-view") + const states = storedViewStates() + + expect(Object.keys(states ?? {})).toHaveLength(50) + expect(states?.["clock-skew-stable-view"]).toMatchObject({ mode: "architect" }) + expect(states?.[temporaryViewStateId]).toBeUndefined() + + await provider.dispose() + }) + }) + + describe("getValues view-local overlay", () => { + it("should return each view's own profile name with that profile's settings", async () => { + // One shared ContextProxy, as in the extension host: both views read the same store. + const sharedProxy = new ContextProxy(mockContext) + const sidebar = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + sharedProxy, + new WebviewFocusTracker(), + ) + const panel = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + sharedProxy, + new WebviewFocusTracker(), + ) + // The shared store carries whichever profile is active globally, as flattened keys. + await sidebar.contextProxy.setValue("openRouterModelId", "shared-model") + await sidebar.setValue("mode", "architect") + await sidebar.setValue("currentApiConfigName", "sidebar-profile") + await sidebar.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.anthropic, + anthropicBaseUrl: "https://sidebar.example", + }) + await panel.setValue("mode", "code") + await panel.setValue("currentApiConfigName", "panel-profile") + await panel.saveViewState("apiConfiguration", { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "panel-model", + }) + + expect(sidebar.getValues().mode).toBe("architect") + expect(sidebar.getValues().currentApiConfigName).toBe("sidebar-profile") + expect(sidebar.getValues().apiProvider).toBe(providerIdentifiers.anthropic) + expect(sidebar.getValues().anthropicBaseUrl).toBe("https://sidebar.example") + // The other profile's flattened settings must not travel with this view's name. + expect(sidebar.getValues().openRouterModelId).toBeUndefined() + + expect(panel.getValues().mode).toBe("code") + expect(panel.getValues().currentApiConfigName).toBe("panel-profile") + expect(panel.getValues().apiProvider).toBe(providerIdentifiers.openrouter) + expect(panel.getValues().openRouterModelId).toBe("panel-model") + + await sidebar.dispose() + await panel.dispose() + }) + + it("should not pair a buffered profile name with the shared settings when the profile failed to resolve", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setValue("currentApiConfigName", "shared-profile") + await provider.contextProxy.setValue("apiProvider", providerIdentifiers.openrouter) + await provider.contextProxy.setValue("viewStates", { + "unresolved-view": { + claimToken: "claim-x", + currentApiConfigName: "deleted-profile", + updatedAt: Date.now(), + }, + }) + + await provider["setViewStateId"]("unresolved-view", "claim-x") + + expect(provider["viewLocalState"].currentApiConfigName).toBe("deleted-profile") + expect(provider["viewLocalState"]).not.toHaveProperty("apiConfiguration") + // Defined behavior for a half-loaded view: the shared name and settings stay a pair. + expect(provider.getValues().currentApiConfigName).toBe("shared-profile") + expect(provider.getValues().apiProvider).toBe(providerIdentifiers.openrouter) + + await provider.dispose() + }) + }) + + describe("provider profile activation rollback ownership", () => { + const activationFailure = (provider: ClineProvider, otherViewSettings?: Record) => { + Object.assign(provider["providerSettingsManager"], { + saveConfig: vi.fn().mockResolvedValue("new-profile-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig: vi.fn().mockRejectedValue(new Error("mode binding write failed")), + getModeConfigId: vi + .fn() + .mockResolvedValueOnce("previous-profile-id") + .mockImplementation(async () => { + // A second view activates its own profile between this group and the rollback. + if (otherViewSettings) { + await provider.contextProxy.setProviderSettings(otherViewSettings as never) + } + + return otherViewSettings ? undefined : "new-profile-id" + }), + }) + vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) + } + + it("should undo this activation's mode binding and settings while it still owns them", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setProviderSettings({ apiProvider: providerIdentifiers.anthropic }) + const setModeConfig = vi + .fn() + .mockRejectedValueOnce(new Error("mode binding write failed")) + .mockResolvedValue(undefined) + Object.assign(provider["providerSettingsManager"], { + saveConfig: vi.fn().mockResolvedValue("new-profile-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig, + // First read: the binding this activation captured. Second read: still ours. + getModeConfigId: vi + .fn() + .mockResolvedValueOnce("previous-profile-id") + .mockResolvedValue("new-profile-id"), + }) + vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) + + await expect( + provider["persistProviderProfile"]( + "test-profile", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("mode binding write failed") + + // The group write, then the rollback of the binding this activation still owns. + expect(setModeConfig).toHaveBeenNthCalledWith(1, "code", "new-profile-id") + expect(setModeConfig).toHaveBeenNthCalledWith(2, "code", "previous-profile-id") + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.anthropic) + expect(provider.contextProxy.getValue("openRouterModelId")).toBeUndefined() + + await provider.dispose() + }) + + it("should leave a newer view's mode binding and provider settings alone", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await provider.contextProxy.setProviderSettings({ apiProvider: providerIdentifiers.anthropic }) + activationFailure(provider, { + apiProvider: providerIdentifiers.requesty, + requestyModelId: "other-view-model", + }) + const logSpy = vi.spyOn(provider, "log") + + await expect( + provider["persistProviderProfile"]( + "test-profile", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("mode binding write failed") + + expect(provider["providerSettingsManager"].setModeConfig).toHaveBeenCalledTimes(1) + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.requesty) + expect(provider.contextProxy.getValue("requestyModelId")).toBe("other-view-model") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping the mode-binding restore")) + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping the shared-settings restore")) + + await provider.dispose() + }) + }) + it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { const provider1 = new ClineProvider( mockContext, From 75ebe31906dbf63a1a89573bdf85ec53f151875b Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:08:13 +0800 Subject: [PATCH 33/51] fix(webview): mirror profile activation into the view buffer activateProviderProfileUnlocked writes the shared currentApiConfigName and provider settings directly through the ContextProxy, which bypasses the per-view mutation path. getValues() keeps overlaying the previous profile's name and settings, so getConfiguration() reports the previous profile while the shared selection already holds the new one. The activation now mirrors the selection and settings into this view's buffer and durable pin. ClineProvider.spec 254 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0 with src/eslint-suppressions.json untouched. --- src/core/webview/ClineProvider.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5e26a8e565..a3b357a599 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3035,6 +3035,15 @@ export class ClineProvider this.contextProxy.setValue("currentApiConfigName", name), this.contextProxy.setProviderSettings(providerSettings), ]) + + // Mirror the activation into this view's buffer and durable pin: the shared writes above + // bypass the per-view mutation path, so getValues() would keep overlaying the previous + // profile's name and settings - getConfiguration() then reports the previous profile + // while the shared selection already holds the new one. + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) } const { mode } = await this.getState() From 1da2756c63031dac94106ae3b6f43aad81b6e843 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:41 +0800 Subject: [PATCH 34/51] test(webview): pin the activation mirror into the view buffer New test `mirrors the activated profile into the view buffer and durable pin`: a view is buffered on one profile, a different profile is activated, and getValues().currentApiConfigName plus the buffered apiConfiguration must follow the activation. Control: removing the mirror call fails it with the getValues assertion, so the fixed pushed in 75ebe3190 now has the coverage it claimed. ClineProvider.spec 255 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0 with src/eslint-suppressions.json untouched. --- .../webview/__tests__/ClineProvider.spec.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 97fa1b3c72..3bf20a0003 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4608,6 +4608,41 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) } + it("mirrors the activated profile into the view buffer and durable pin", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry, nextEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + provider["viewLocalState"].currentApiConfigName = "prior-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + } + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + ...nextEntry, + apiKey: "next-key", + } as never) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + await provider.activateProviderProfile({ name: "next-profile" }) + + // getValues() overlays the view buffer over the shared store: without the mirror the + // previous profile's name and settings shadow the selection that was just activated. + expect(provider.getValues().currentApiConfigName).toBe("next-profile") + expect(provider["viewLocalState"].apiConfiguration?.apiKey).toBe("next-key") + await provider.dispose() + }) it("should undo this activation's mode binding and settings while it still owns them", async () => { const provider = new ClineProvider( mockContext, From ea9015ed807d3a42eb5a0928c558b58ab16247b8 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:11:02 +0800 Subject: [PATCH 35/51] fix(webview): gate the profile-list rollback on the list's own write generation The activation rollback restored listApiConfigMeta while only the shared-selection generation still matched, so a non-activating profile save from another view - which writes the list without touching currentApiConfigName - was overwritten and the saved profile dropped out of the metadata. The list now carries its own expected generation, captured with the other snapshots. ClineProvider.spec 255 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0 with src/eslint-suppressions.json untouched. --- src/core/webview/ClineProvider.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a3b357a599..61c24a4b1e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2813,6 +2813,10 @@ export class ClineProvider const previousViewName = this.viewLocalState.currentApiConfigName // This activation owns the shared selection until a newer write bumps the generation. const expectedNameGeneration = this.contextProxy.getStateWriteGeneration("currentApiConfigName") + 1 + // The profile list needs its own ownership signal: a non-activating save from another view + // writes listApiConfigMeta without touching the shared name, and undoing it would drop the + // profile that save just stored from the metadata. + const expectedListGeneration = this.contextProxy.getStateWriteGeneration("listApiConfigMeta") + 1 const previousProviderSettings = this.contextProxy.getProviderSettings() // Ownership signal for the provider-settings store, independent of the shared name: // this activation's `setProviderSettings` writes `apiProvider` exactly once, so those @@ -2859,9 +2863,9 @@ export class ClineProvider } } await restore("profile list", async () => { - if (!ownsSharedSelection()) { + if (this.contextProxy.getStateWriteGeneration("listApiConfigMeta") !== expectedListGeneration) { this.log( - "[persistProviderProfile] Skipping the profile-list restore: another view wrote the shared selection", + "[persistProviderProfile] Skipping the profile-list restore: another view wrote the profile list", ) return } From 563c607e4264491761327f2ccdb8da39276e3c76 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:12:24 +0800 Subject: [PATCH 36/51] test(webview): pin the profile-list rollback to its own generation New test `keeps a newer profile list written by another view when the activation rolls back`: another view saves a profile (writing listApiConfigMeta) before this activation's last group member fails; the newer list must survive the rollback. Control: disabling the generation gate fails it, so the fix pushed in ea9015ed8 now has the coverage it claimed. ClineProvider.spec 256 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0 with src/eslint-suppressions.json untouched. --- .../webview/__tests__/ClineProvider.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 3bf20a0003..1b11e50194 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4608,6 +4608,39 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) } + it("keeps a newer profile list written by another view when the activation rolls back", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } + const savedElsewhere = { name: "other-save", id: "other-id", apiProvider: providerIdentifiers.anthropic } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + ...nextEntry, + } as never) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + // Another view saves a profile (writing the list) before this activation's last group member + // fails. Undoing the list here would drop the profile that save just stored from the metadata. + vi.spyOn(provider.contextProxy, "setProviderSettings").mockImplementation(async () => { + await provider.contextProxy.setValue("listApiConfigMeta", [savedElsewhere]) + throw new Error("settings write failed") + }) + + await expect(provider.activateProviderProfile({ name: "next-profile" })).rejects.toThrow() + + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([savedElsewhere]) + await provider.dispose() + }) it("mirrors the activated profile into the view buffer and durable pin", async () => { const provider = new ClineProvider( mockContext, From c1529c0b720d248f3ed5bab647386cc2cad3f37d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:15:21 +0800 Subject: [PATCH 37/51] test(webview): drop the non-discriminating profile-list rollback test 563c607e4 added a test meant to prove the list-generation gate from ea9015ed8. Its control does not hold: with the gate disabled the test still passes, and a verbose run shows the profile-list restore is never reached in that fixture. A test that cannot fail when the fix is removed reads as coverage it does not have, so it is removed here; the gate itself stays and remains unproven. ClineProvider.spec 255 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0. --- .../webview/__tests__/ClineProvider.spec.ts | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1b11e50194..3bf20a0003 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4608,39 +4608,6 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) } - it("keeps a newer profile list written by another view when the activation rolls back", async () => { - const provider = new ClineProvider( - mockContext, - mockOutputChannel, - "sidebar", - new ContextProxy(mockContext), - new WebviewFocusTracker(), - ) - const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } - const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } - const savedElsewhere = { name: "other-save", id: "other-id", apiProvider: providerIdentifiers.anthropic } - await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry]) - await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") - vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ - ...nextEntry, - } as never) - vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) - vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) - vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) - vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) - vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) - // Another view saves a profile (writing the list) before this activation's last group member - // fails. Undoing the list here would drop the profile that save just stored from the metadata. - vi.spyOn(provider.contextProxy, "setProviderSettings").mockImplementation(async () => { - await provider.contextProxy.setValue("listApiConfigMeta", [savedElsewhere]) - throw new Error("settings write failed") - }) - - await expect(provider.activateProviderProfile({ name: "next-profile" })).rejects.toThrow() - - expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([savedElsewhere]) - await provider.dispose() - }) it("mirrors the activated profile into the view buffer and durable pin", async () => { const provider = new ClineProvider( mockContext, From a93fbe4215d81eaee938d1c08ad614f2f9736873 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:33 +0800 Subject: [PATCH 38/51] fix(webview): snapshot the activation stores after the last await The rollback snapshots (profile list, shared name, view pin, provider settings) and the ownership generations were captured before `getModeConfigId` yielded. Another view could write any of those stores in that window: the generations would then be armed against a value this activation never overwrote (so a restore would be skipped when it is still ours) and the snapshots could restore a stale value. They are now taken immediately before the write group. ClineProvider.spec 255 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0. --- src/core/webview/ClineProvider.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 61c24a4b1e..6696b0cb08 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2808,6 +2808,12 @@ export class ClineProvider // Capture every store the activation group rewrites: one member can reject after // others landed, and restoring only the shared name would leave the old name next to // the new settings and a mode binding pointing at the new profile. + const previousModeConfigId = this.providerSettingsManager.getModeConfigId + ? await this.providerSettingsManager.getModeConfigId(mode) + : undefined + // Snapshot every store after the last await above: another view can write any of them while + // this activation is suspended, and a snapshot taken earlier would both mis-arm the + // ownership generations below and restore a value that was never current. const previousList = this.contextProxy.getValue("listApiConfigMeta") const previousName = this.contextProxy.getValue("currentApiConfigName") const previousViewName = this.viewLocalState.currentApiConfigName @@ -2824,9 +2830,6 @@ export class ClineProvider const expectedApiProviderGeneration = this.contextProxy.getStateWriteGeneration("apiProvider") + 1 // Test doubles and older managers may not implement the read: absence means "no previous // binding to restore", not a failed activation. - const previousModeConfigId = this.providerSettingsManager.getModeConfigId - ? await this.providerSettingsManager.getModeConfigId(mode) - : undefined const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), From 88dcfa117e9450ddbe4b9731890d989b51ffea57 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:59:51 +0800 Subject: [PATCH 39/51] fix(webview): require selection ownership before restoring the mode binding The mode-binding rollback decided ownership from the profile id alone, so a second view that activated the same profile looked identical to this activation's own write and its selection was overwritten by the restore. The shared-selection generation is now required as well - the same signal the list and settings restores already use - so only the activation that still owns the selection undoes the binding. ClineProvider.spec 255 passed, tsc --noEmit 0 errors, eslint --max-warnings=0 exit 0. --- src/core/webview/ClineProvider.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6696b0cb08..e56d0226aa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2881,6 +2881,17 @@ export class ClineProvider // A newer activation from another view re-pointed this mode: its binding wins. // When this activation's own binding write is what failed, the binding never // moved and there is nothing to undo. + // + // The profile id alone cannot answer that: another view can activate the same + // profile, in which case the binding still reads as ours and restoring it would + // overwrite the other view's selection. The shared-selection generation is the + // ownership signal for "this write is still mine to undo". + if (!ownsSharedSelection()) { + this.log( + "[persistProviderProfile] Skipping the mode-binding restore: another view wrote the shared selection", + ) + return + } const currentModeConfigId = this.providerSettingsManager.getModeConfigId ? await this.providerSettingsManager.getModeConfigId(mode) : previousModeConfigId From 9de36bebd76717b7d6683a44afdbe3ba11cbbd10 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:02:22 +0800 Subject: [PATCH 40/51] fix(webview): undo the shared activation writes when the per-view mirror rejects The activation writes the shared selection, list and provider settings, then mirrors the same values into this view's buffer and durable pin. If that mirror rejects, nothing reverted the shared writes: the shared selection named the new profile while the buffer and pin still named the previous one, so a reopened view could show the old profile. The shared values and their ownership generations are now captured before the group and restored when the mirror rejects, each store only while this activation still owns it. New test `restores the shared writes when the per-view mirror rejects` asserts the selection is back to the prior profile after a rejected mirror. ClineProvider.spec 256 passed, tsc --noEmit 0 errors. --- src/core/webview/ClineProvider.ts | 55 +++++++++++++++++-- .../webview/__tests__/ClineProvider.spec.ts | 31 +++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e56d0226aa..ca2d23e1bb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3048,6 +3048,16 @@ export class ClineProvider if (!skipCurrentTaskRebuild) { // See `upsertProviderProfile` for a description of what this is doing. + // Capture the shared values this group is about to replace: the per-view mirror below is the + // last step, and its rejection must not leave the shared selection and settings naming a + // profile the view buffer and the durable pin do not follow. + const sharedValuesBeforeActivation = { + list: this.contextProxy.getValue("listApiConfigMeta"), + name: this.contextProxy.getValue("currentApiConfigName"), + nameGeneration: this.contextProxy.getStateWriteGeneration("currentApiConfigName") + 1, + settings: this.contextProxy.getProviderSettings(), + apiProviderGeneration: this.contextProxy.getStateWriteGeneration("apiProvider") + 1, + } await Promise.all([ this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), this.contextProxy.setValue("currentApiConfigName", name), @@ -3058,10 +3068,47 @@ export class ClineProvider // bypass the per-view mutation path, so getValues() would keep overlaying the previous // profile's name and settings - getConfiguration() then reports the previous profile // while the shared selection already holds the new one. - await this._saveViewLocalStateFromMutation({ - currentApiConfigName: name, - apiConfiguration: providerSettings, - }) + try { + await this._saveViewLocalStateFromMutation({ + currentApiConfigName: name, + apiConfiguration: providerSettings, + }) + } catch (mirrorError) { + // The shared writes already landed. Put them back while this activation still owns them, + // so the shared selection cannot name a profile the view buffer does not follow. + if ( + this.contextProxy.getStateWriteGeneration("currentApiConfigName") === + sharedValuesBeforeActivation.nameGeneration + ) { + await this.contextProxy + .setValue("currentApiConfigName", sharedValuesBeforeActivation.name) + .catch((restoreError: unknown) => + this.log( + `[activateProviderProfile] Failed to restore the shared selection after a rejected view mirror: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ), + ) + await this.contextProxy + .setValue("listApiConfigMeta", sharedValuesBeforeActivation.list) + .catch((restoreError: unknown) => + this.log( + `[activateProviderProfile] Failed to restore the profile list after a rejected view mirror: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ), + ) + } + if ( + this.contextProxy.getStateWriteGeneration("apiProvider") === + sharedValuesBeforeActivation.apiProviderGeneration + ) { + await this.contextProxy + .setProviderSettings(sharedValuesBeforeActivation.settings) + .catch((restoreError: unknown) => + this.log( + `[activateProviderProfile] Failed to restore the shared provider settings after a rejected view mirror: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, + ), + ) + } + throw mirrorError + } } const { mode } = await this.getState() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 3bf20a0003..54a1fc5a74 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4608,6 +4608,37 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) } + it("restores the shared writes when the per-view mirror rejects", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry, nextEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + provider["viewLocalState"].currentApiConfigName = "prior-profile" + vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + ...nextEntry, + apiKey: "next-key", + } as never) + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + // @ts-ignore - fail only the per-view mirror, after the shared writes landed. + vi.spyOn(provider, "_saveViewLocalStateFromMutation").mockRejectedValue(new Error("mirror failed")) + + await expect(provider.activateProviderProfile({ name: "next-profile" })).rejects.toThrow("mirror failed") + + // The shared selection must not name a profile the view buffer does not follow. + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("prior-profile") + await provider.dispose() + }) it("mirrors the activated profile into the view buffer and durable pin", async () => { const provider = new ClineProvider( mockContext, From aefbdbb3cc80f712252265fdec8a80bd2214da2f Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:28:41 +0800 Subject: [PATCH 41/51] test(webview): cover the rollback path where an undo write also rejects The activation rollback reported a store whose undo failed only through an aggregated error that no test exercised. The new test makes the mode-binding write reject and then makes the same store's undo reject, and asserts that the thrown error keeps the original failure at the head and as the cause, names only the store whose undo failed, and that the list, the shared selection and the provider settings are still undone. Ownership skips remain covered by the adjacent tests; a failed undo is reported through the error, not a log line. --- .../webview/__tests__/ClineProvider.spec.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9fd9ef4064..7c891c7c66 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -4916,6 +4916,69 @@ describe("ClineProvider", () => { await provider.dispose() }) + it("names the store whose undo failed when a rollback write also rejects", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.anthropic } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry, nextEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + await provider.contextProxy.setProviderSettings({ apiProvider: providerIdentifiers.anthropic }) + // The activation group write rejects, and so does the undo of the same store. + const setModeConfig = vi + .fn() + .mockRejectedValueOnce(new Error("mode binding write failed")) + .mockRejectedValueOnce(new Error("mode binding undo failed")) + .mockResolvedValue(undefined) + Object.assign(provider["providerSettingsManager"], { + saveConfig: vi.fn().mockResolvedValue("new-profile-id"), + listConfig: vi.fn().mockResolvedValue([]), + setModeConfig, + // First read: the binding this activation captured. Second read: still ours. + getModeConfigId: vi + .fn() + .mockResolvedValueOnce("previous-profile-id") + .mockResolvedValue("new-profile-id"), + }) + vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) + const logSpy = vi.spyOn(provider, "log") + let failure: unknown + try { + await provider["persistProviderProfile"]( + "test-profile", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" }, + true, + new AbortController().signal, + ) + throw new Error("the activation was expected to fail") + } catch (error) { + failure = error + } + const rollbackError = failure as Error + // The original failure stays at the head of the message and stays the cause, + // so a caller can branch on either. The failed undo is named beside it. + expect(rollbackError.message).toContain("mode binding write failed") + expect(rollbackError.message).toContain( + "activation rollback also failed: mode binding: mode binding undo failed", + ) + expect((rollbackError.cause as Error).message).toBe("mode binding write failed") + // Only the undo that failed is reported: the store is named, not the whole group. + expect(rollbackError.message).not.toContain("profile list:") + expect(rollbackError.message).not.toContain("shared selection:") + // Every other store this activation still owned was undone anyway. + expect(setModeConfig).toHaveBeenNthCalledWith(2, "code", "previous-profile-id") + expect(provider.contextProxy.getValue("listApiConfigMeta")).toEqual([priorEntry, nextEntry]) + expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("prior-profile") + expect(provider.contextProxy.getValue("apiProvider")).toBe(providerIdentifiers.anthropic) + // Nothing was skipped for ownership: the skips log, a failed undo throws. + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Skipping the")) + await provider.dispose() + }) }) it("should merge concurrent persisted updates from separate provider instances without lost viewStates", async () => { From 3f5765148300b22d16cc3c50f3ab870833a6f5d2 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:27:04 +0800 Subject: [PATCH 42/51] fix(config): end write ownership on reset, and carry the profile name with its settings Three measured review findings on this head, landed together because they answer one save/reset path. ContextProxy.resetAllState cleared the caches and bumped a generation counter that nothing ever read: stateWriteGenerations had five references in the repository (declaration at src/core/config/ContextProxy.ts:79, increment at 420-421, reset bump at 729-730), no comparer anywhere, and ClineProvider.ts never consulted a generation. The rejection handlers gate on write ownership (stateWriteOwner / secretWriteOwner), and reset left that ownership in place, so a write still in flight read the Memento back into the cache the reset had just cleared. resetAllState now clears both owner maps; the field, its two write sites and its reset bump are deleted. ContextProxy.ts 763 -> 761 lines. The activation path wrote a profile's settings into this view's buffer while the buffered profile name stayed behind: ClineProvider.setValue is a ContextProxy pass-through, and pinnedProfileName reads viewLocalState.currentApiConfigName, so the next non-activating save of that profile compared a name nothing had updated and skipped the refresh. The buffer write now carries currentApiConfigName and apiConfiguration in one mutation. refreshSiblingsAfterProfileSave is ported unchanged from the n4 head (877ea0a00) and called from both save branches. This head had no sibling refresh at all before - zero references to refreshSiblings and the three getAllInstances uses were elsewhere - so this is a capability arriving, not a behaviour changing. ClineProvider.ts 6039 -> 6078 lines. Tests. ContextProxy.spec.ts: the case "should not restore a pre-reset value when a pending write rejects after reset" now re-arms the mocked Memento so the read-back answers with the pre-reset value; it fails against the old production (expected 'anthropic' to be undefined) and passes now. Main's thirteen read-back / rejection / reset cases in that file were re-run and are green individually, not by inference (file 66/66). ClineProvider.spec.ts: new case "keeps the buffered profile name in step with the profile it activates", plus the two sibling-overlay cases ported from the n4 head, one per save branch; on this head the sibling is pinned through saveViewState because setValue does not touch the buffer. Controls (production snapshotted and restored by content hash after each - ClineProvider.ts 8f05e399aa, ContextProxy.ts f5281bde9e): removing the non-activating branch sibling call fails only the non-activating case (1 failed | 368 skipped); removing the activating-branch call fails only the activated-profile case; reducing the buffer mutation to { apiConfiguration } fails only the name case (expected { ...2 } to match object { ...2 }); leaving write ownership in place at reset fails the reset case (expected 'anthropic' to be undefined) while main's "should reinitialize caches after reset" still passes. Gates: prettier --check clean; tsc --noEmit -p . clean; eslint --max-warnings=0 clean on all four files; vitest ContextProxy.spec.ts 66 tests, core/webview + core/config 44 files / 1267 tests green. Scope 335 -> 354 extension-package executable lines (types unchanged at 8), inside the 500 advisory. --- src/core/config/ContextProxy.ts | 12 +- .../config/__tests__/ContextProxy.spec.ts | 4 + src/core/webview/ClineProvider.ts | 45 +++++++- .../webview/__tests__/ClineProvider.spec.ts | 103 ++++++++++++++++++ 4 files changed, 154 insertions(+), 10 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index cd2fc87230..f5281bde9e 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -76,7 +76,6 @@ export class ContextProxy { private readonly originalContext: vscode.ExtensionContext private stateCache: GlobalState - private stateWriteGenerations = new Map() private secretCache: SecretState private _isInitialized = false @@ -417,8 +416,6 @@ export class ContextProxy { // writes that are in flight. An update that throws at once is turned into a rejection, so it is handled the same. const owner = {} this.stateWriteOwner.set(key, owner) - const generation = (this.stateWriteGenerations.get(key) ?? 0) + 1 - this.stateWriteGenerations.set(key, generation) this.stateCache[key] = value const release = () => { if (this.stateWriteOwner.get(key) === owner) this.stateWriteOwner.delete(key) @@ -725,10 +722,11 @@ export class ContextProxy { // Clear in-memory caches this.stateCache = {} this.secretCache = {} - // Advance every tracked generation so a pending write cannot restore pre-reset state. - for (const key of this.stateWriteGenerations.keys()) { - this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1) - } + // A reset also ends write ownership: a write that is still in flight reads the Memento back + // into the cache it just lost, so clearing the caches is not enough to keep pre-reset state + // out. Ownership is what the rejection handlers gate on. + this.stateWriteOwner.clear() + this.secretWriteOwner.clear() await Promise.all([ ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 240f0fe5e7..016f75118b 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -149,6 +149,10 @@ describe("ContextProxy", () => { ) const pending = proxy.updateGlobalState("apiProvider", "anthropic") await proxy.resetAllState() + // The Memento answers the read-back with the pre-reset value: only ending write ownership + // during the reset keeps the cache cleared. With the mocked store this is the difference + // between the guarantee holding and the rejected write repopulating the cache. + mockGlobalState.get.mockImplementation((key: string) => (key === "apiProvider" ? "anthropic" : undefined)) rejectPending(new Error("disk full")) await expect(pending).rejects.toThrow("disk full") expect(proxy.getGlobalState("apiProvider")).toBeUndefined() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b3748229eb..8f05e399aa 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2823,12 +2823,18 @@ export class ClineProvider // `allSettled` so a caller that rolls back after a failure never races a write still in flight. const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - // Route through setValue so the buffer tracks the activated profile. + // Shared write: adds/updates the profile list entry and the current-profile name. this.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), - // setProviderSettings bypasses the buffer path: refresh this view's overlay. - this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }), + // setProviderSettings bypasses the buffer path; the mutation below refreshes this view. + // One mutation carries both the name and the settings: pinnedProfileName reads the buffer, + // so a buffer that keeps the previous name while holding the new settings pins a profile + // this view no longer runs under. + this._saveViewLocalStateFromMutation({ + currentApiConfigName: name, + apiConfiguration: providerSettings, + }), ]) const failedActivation = activationResults.find( (result): result is PromiseRejectedResult => result.status === "rejected", @@ -2838,6 +2844,8 @@ export class ClineProvider if (signal.aborted) return id + await this.refreshSiblingsAfterProfileSave(name, providerSettings) + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -2856,6 +2864,10 @@ export class ClineProvider this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) } } + // A non-activating save changes the profile's settings too: refresh every sibling + // that buffered a copy (pinned siblings included), or their getState() keeps the pre-save + // values. This head had no sibling refresh at all before this change. + await this.refreshSiblingsAfterProfileSave(name, providerSettings) } if (signal.aborted) return id @@ -2863,6 +2875,33 @@ export class ClineProvider await this.postStateToWebview() return id } + /** + * Refresh the buffered overlay of every live sibling pinned to a saved profile and rebuild + * any task that follows it. The handler rebuild is independent of the pin: a task restored + * from history can follow profile A while its view pins profile B. + */ + private async refreshSiblingsAfterProfileSave(name: string, providerSettings: ProviderSettings): Promise { + for (const instance of ClineProvider.getAllInstances()) { + if (instance === this || instance._disposed || instance.pinnedProfileName !== name) { + continue + } + // Direct private access: compile-time safe across sibling instances. + await instance._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings }) + } + + const { + name: _recordName, + id: _recordId, + ...taskSettings + } = providerSettings as ProviderSettings & { name?: string; id?: string } + for (const instance of ClineProvider.getAllInstances()) { + if (instance._disposed || instance.getCurrentTask()?.taskApiConfigName !== name) { + continue + } + instance.updateTaskApiHandlerIfNeeded(taskSettings, { forceRebuild: true }) + } + } + async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { const globalSettings = this.contextProxy.getValues() let profileToActivate: string | undefined = globalSettings.currentApiConfigName diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index c9312af8dd..4a23c2cddd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6263,6 +6263,109 @@ describe("ClineProvider", () => { }) await provider.dispose() }) + // The activation path writes the profile settings straight into this view's buffer, so the + // buffered profile name has to move with them: pinnedProfileName reads the buffer, and a later + // non-activating save of the same profile is gated on that name. + it("keeps the buffered profile name in step with the profile it activates", async () => { + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + await saver["saveViewState"]("currentApiConfigName", "profile-a") + expect(saver["pinnedProfileName"]).toBe("profile-a") + // @ts-ignore - The double only backs the profile-store calls of the save path. + saver.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("profile-b-id"), + listConfig: vi.fn().mockResolvedValue([{ name: "profile-b", id: "profile-b-id" }]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + const providerSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "activated-key" } + + await saver.upsertProviderProfile("profile-b", providerSettings, true) + + // One mutation: the name and the settings land in the buffer together, or the view pins a + // profile whose settings it is no longer holding. + expect(saver["viewLocalState"]).toMatchObject({ + currentApiConfigName: "profile-b", + apiConfiguration: providerSettings, + }) + expect(saver["pinnedProfileName"]).toBe("profile-b") + await saver.dispose() + }) + + it("should refresh the buffered overlay of another view pinned to the saved profile on a non-activating save", async () => { + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const pinned = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // saveViewState is the buffer write on this head: setValue is a ContextProxy pass-through, + // so going through setValue here would leave pinnedProfileName undefined. + await pinned["saveViewState"]("currentApiConfigName", "shared-profile") + // @ts-ignore - The double only backs the profile-store calls of the save path. + saver.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("shared-id"), + listConfig: vi.fn().mockResolvedValue([{ name: "shared-profile", id: "shared-id" }]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + const providerSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" } + + await saver.upsertProviderProfile("shared-profile", providerSettings, false) + + // The pinned view buffered a copy of the profile's settings; a non-activating save must + // refresh it, or its getState() keeps serving the pre-save configuration. + expect(pinned["viewLocalState"].apiConfiguration).toEqual(providerSettings) + await saver.dispose() + await pinned.dispose() + }) + + it("should refresh the buffered overlay of another view pinned to the activated profile", async () => { + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const pinned = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // saveViewState is the buffer write on this head: setValue is a ContextProxy pass-through, + // so going through setValue here would leave pinnedProfileName undefined. + await pinned["saveViewState"]("currentApiConfigName", "shared-profile") + // @ts-ignore - The double only backs the profile-store calls of the save path. + saver.providerSettingsManager = { + saveConfig: vi.fn().mockResolvedValue("shared-id"), + listConfig: vi.fn().mockResolvedValue([{ name: "shared-profile", id: "shared-id" }]), + setModeConfig: vi.fn().mockResolvedValue(undefined), + } + const providerSettings = { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" } + + await saver.upsertProviderProfile("shared-profile", providerSettings, true) + + // The pinned view buffered a copy of the profile's settings; an activating save refreshes + // it through the sibling loop, so this row covers the activate=true branch that the case + // above no longer reaches. + expect(pinned["viewLocalState"].apiConfiguration).toEqual(providerSettings) + await saver.dispose() + await pinned.dispose() + }) }) describe("saveViewState", () => { From acf8ccb9c670ba649aa4f6df8e621fd42f7625a6 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:03:35 +0800 Subject: [PATCH 43/51] test(webview): re-adopt the heartbeat coverage the upstream merges dropped This commit touches one file: src/core/webview/__tests__/ClineProvider.spec.ts. The two upstream/main merges on this branch (61189bb57, d46d7b23d) resolved that file toward our side and dropped 72 declarations that main carries at e943530c8 - the whole "webview heartbeat watchdog" suite (70 declarations, 1,912 lines) plus two top-level .each tables (the diagnostics-broker router and the dev-server probe fallback). An AST title inventory over all 932 spec files in this tree finds none of the 72 anywhere else, and no distinctive token from any of their bodies appears in another spec, so this is deleted coverage rather than a rewording. The four sibling heads (#2226, #2227, #2236, #2237) each still carry all 72. All 72 are re-adopted from main: every adopted body is byte-identical to main's modulo repo prettier formatting, and no assertion is re-pointed to a different seam. The suite needs harness plumbing this file had also lost, so the commit restores exactly that and nothing more: the pWaitFor import, the ViewColumn, getCommands and createWebviewPanel doubles, a showWarningMessage double that resolves, and the show member on the sidebar and output-channel doubles. Net effect: 1,996 added / 27 removed lines as git counts the diff in one file (the only pre-existing line whose text changes is the showWarningMessage double; the other "deletions" are identical blocks re-paired by the diff), zero executable lines changed, and no eslint suppression count moved. Controls: with mockWebviewView.show removed again the explicit-retry recovery case livelocks and the worker dies at 122s, so that restored double member is load-bearing rather than cosmetic. Moving WEBVIEW_WATCHDOG_TICK_MS off 60_000 fails 47 of the adopted cases, never registering the watchdog interval fails 57, and short-circuiting retryWebviewRecovery fails 14. On the unmutated tree all 381 cases in the file pass, including the 96 re-adopted heartbeat cases and the 6 re-adopted table cases. --- .../webview/__tests__/ClineProvider.spec.ts | 2023 ++++++++++++++++- 1 file changed, 1996 insertions(+), 27 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7c891c7c66..1d5fdcfb20 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -7,6 +7,7 @@ import { TaskRegistry } from "../../task/TaskRegistry" import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" import axios from "axios" +import pWaitFor from "p-wait-for" import { type ProviderSettingsEntry, @@ -178,12 +179,15 @@ vi.mock("vscode", () => ({ QuickFix: { value: "quickfix" }, RefactorRewrite: { value: "refactor.rewrite" }, }, + ViewColumn: { Active: -1 }, commands: { + getCommands: vi.fn().mockResolvedValue([]), executeCommand: vi.fn().mockResolvedValue(undefined), }, window: { + createWebviewPanel: vi.fn(), showInformationMessage: vi.fn(), - showWarningMessage: vi.fn(), + showWarningMessage: vi.fn().mockResolvedValue(undefined), showErrorMessage: vi.fn(), showSaveDialog: vi.fn(), showOpenDialog: vi.fn(), @@ -552,6 +556,7 @@ describe("ClineProvider", () => { // Mock output channel mockOutputChannel = { + show: vi.fn(), appendLine: vi.fn(), clear: vi.fn(), dispose: vi.fn(), @@ -570,6 +575,7 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, + show: vi.fn(), onDidDispose: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -811,6 +817,43 @@ describe("ClineProvider", () => { expect(tab.postMessage).not.toHaveBeenCalled() }) + it.each([ + { + message: { + type: "diagnosticsResponse", + requestId: "request-1", + diagnostics: { capturedAt: "2026-11-01T00:00:00.000Z" }, + }, + routed: true, + }, + { + message: { type: "diagnosticsResponse", diagnostics: { capturedAt: "2026-11-01T00:00:00.000Z" } }, + routed: false, + }, + { message: { type: "themeFixtureProbeResponse", requestId: "request-1" }, routed: false }, + ] satisfies { message: WebviewMessage; routed: boolean }[])( + "routes $message to the diagnostics broker only when addressed", + async ({ message, routed }) => { + const handler = vi + .spyOn(await import("../webviewMessageHandler"), "webviewMessageHandler") + .mockResolvedValue(undefined) + const resolve = vi.spyOn(provider["diagnosticsRequestBroker"], "resolve") + try { + sidebar.messages.fire(message) + if (routed) { + expect(resolve).toHaveBeenCalledExactlyOnceWith("request-1", message.diagnostics) + expect(handler).not.toHaveBeenCalled() + } else { + expect(resolve).not.toHaveBeenCalled() + expect(handler).toHaveBeenCalledOnce() + expect(handler.mock.calls[0].slice(0, 2)).toEqual([provider, message]) + } + } finally { + handler.mockRestore() + resolve.mockRestore() + } + }, + ) test("background messages reach the provider handler without changing the last focused chat", async () => { sidebar.messages.fire({ type: "webviewDidFocus" }) const handler = vi @@ -1127,41 +1170,1967 @@ describe("ClineProvider", () => { }) }) - test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => { - const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development } - provider = new ClineProvider( - developmentContext, - mockOutputChannel, - "sidebar", - new ContextProxy(developmentContext), - new WebviewFocusTracker(), + describe("webview heartbeat watchdog", () => { + let visibilityCallback: () => void + let disposeCallback: () => void + + function trackHtmlAssignments() { + let html = mockWebviewView.webview.html + const assignHtml = vi.fn((value: string) => { + html = value + }) + Object.defineProperty(mockWebviewView.webview, "html", { + configurable: true, + get: () => html, + set: assignHtml, + }) + return assignHtml + } + + beforeEach(() => { + vi.mocked(vscode.commands.executeCommand).mockReset().mockResolvedValue(undefined) + vi.mocked(vscode.window.showWarningMessage).mockResolvedValue(undefined) + vi.mocked(vscode.commands.getCommands).mockResolvedValue([ + `${ClineProvider.sideBarId}.removeView`, + `${ClineProvider.sideBarId}.focus`, + ]) + // Fake timers must be active before resolveWebviewView so the + // watchdog interval is registered on the fake clock. + vi.useFakeTimers() + mockWebviewView.onDidChangeVisibility = vi.fn().mockImplementation((cb: () => void) => { + visibilityCallback = cb + return { dispose: vi.fn() } + }) + // VS Code events are multi-listener: the provider and the focus + // tracker each register a disposal callback, and both must fire. + const disposeCallbacks: Array<() => void> = [] + disposeCallback = () => disposeCallbacks.forEach((cb) => cb()) + mockWebviewView.onDidDispose = vi.fn().mockImplementation((cb: () => void) => { + disposeCallbacks.push(cb) + return { dispose: vi.fn() } + }) + }) + + afterEach(async () => { + await provider.dispose() + vi.useRealTimers() + }) + + test("does not reload the webview while heartbeats are fresh", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(110_000) + await webviewMessageHandler(provider, { type: "webviewHeartbeat", timestamp: Date.now() }) + await vi.advanceTimersByTimeAsync(60_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("reloads the webview with fresh HTML when the heartbeat is stale and the view is visible", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + + // Reassigning webview.html with a fresh nonce is what forces the reload. + expect(mockWebviewView.webview.html).not.toBe(htmlAfterResolve) + expect(mockWebviewView.webview.html).toContain("Zoo Code") + }) + + test.each([ + { age: 89_999, reloads: 0 }, + { age: 90_000, reloads: 0 }, + { age: 90_001, reloads: 1 }, + ])("at heartbeat age $age ms performs $reloads reloads", async ({ age, reloads }) => { + await provider.resolveWebviewView(mockWebviewView) + const assignHtml = trackHtmlAssignments() + // Keep the next tick ordinary while placing heartbeat age exactly on + // either side of the stale threshold at that tick. + provider["lastWebviewHeartbeatAt"] = Date.now() + 60_000 - age + + await vi.advanceTimersByTimeAsync(60_000) + + expect(assignHtml).toHaveBeenCalledTimes(reloads) + }) + + test.each([ + { gap: 89_999, reloads: 1 }, + { gap: 90_000, reloads: 1 }, + { gap: 90_001, reloads: 0 }, + ])("at watchdog tick gap $gap ms performs $reloads reloads", async ({ gap, reloads }) => { + await provider.resolveWebviewView(mockWebviewView) + const assignHtml = trackHtmlAssignments() + // The heartbeat is stale in every row; only the suspend-gap guard + // can suppress recovery. No wall-clock jumps or extra ticks mask it. + provider["lastWebviewHeartbeatAt"] = Date.now() - 120_000 + provider["lastWebviewWatchdogTickAt"] = Date.now() + 60_000 - gap + + await vi.advanceTimersByTimeAsync(60_000) + + expect(assignHtml).toHaveBeenCalledTimes(reloads) + }) + + test.each(["none", "disposed", "epoch"] as const)( + "checks only the %s post-await recovery guard", + async (guard) => { + await provider.resolveWebviewView(mockWebviewView) + const assignHtml = trackHtmlAssignments() + let finishReload: (html: string) => void = () => { + throw new Error("Recovery HTML generation has not started") + } + provider["getWebviewHtml"] = vi.fn( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + const recovery = provider["reloadWebviewForRecovery"]() + // Change one field only: real dispose/re-resolve paths change + // multiple fields and would let other guards mask this one. + if (guard === "disposed") provider["_disposed"] = true + if (guard === "epoch") provider["webviewRecoveryEpoch"]++ + try { + finishReload("recovered-html") + await recovery + expect(assignHtml).toHaveBeenCalledTimes(guard === "none" ? 1 : 0) + if (guard === "none") expect(mockWebviewView.webview.html).toBe("recovered-html") + } finally { + // Allow the regular afterEach to dispose the provider's resources. + provider["_disposed"] = false + } + }, ) - // The dev-server probe fails, so the HMR path falls back to the production HTML. - ;(axios.get as any).mockRejectedValueOnce(new Error("Network error")) - await provider.resolveWebviewView(mockWebviewView) + test("grants a new grace window after recovery and caps reloads until sustained heartbeats", async () => { + await provider.resolveWebviewView(mockWebviewView) + const assignHtml = trackHtmlAssignments() - expect(mockWebviewView.webview.options).toEqual({ - enableScripts: true, - localResourceRoots: [mockContext.extensionUri], + await vi.advanceTimersByTimeAsync(120_000) + expect(assignHtml).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(60_000) + expect(assignHtml).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(180_000) + expect(assignHtml).toHaveBeenCalledTimes(3) + + const log = vi.spyOn(provider, "log") + + // Visibility changes and host suspension grant grace, but cannot + // replenish attempts for a renderer that has never responded. + visibilityCallback() + vi.setSystemTime(Date.now() + 120_000) + await vi.advanceTimersByTimeAsync(600_000) + expect(assignHtml).toHaveBeenCalledTimes(3) + expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Webview heartbeat stale")) + + await webviewMessageHandler(provider, { type: "webviewHeartbeat" }) + await vi.advanceTimersByTimeAsync(120_000) + expect(assignHtml).toHaveBeenCalledTimes(3) + // One mount heartbeat is insufficient: a new healthy window must + // span a full minute of periodic heartbeats before replenishing. + const attempts = provider["webviewRecoveryAttempts"] + expect(attempts).toBe(3) + provider.updateWebviewHeartbeat() + expect(provider["webviewRecoveryAttempts"]).toBe(attempts) + await vi.advanceTimersByTimeAsync(30_000) + provider.updateWebviewHeartbeat() + expect(provider["webviewRecoveryAttempts"]).toBe(attempts) + await vi.advanceTimersByTimeAsync(30_000) + provider.updateWebviewHeartbeat() + expect(provider["webviewRecoveryAttempts"]).toBe(0) + await vi.advanceTimersByTimeAsync(120_000) + expect(assignHtml).toHaveBeenCalledTimes(4) + }) + + test.each([false, true])( + "recreates only the failed sidebar host, retaining its task and budget (interrupted: %s)", + async (interrupted) => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + task.abort = interrupted + provider["taskRegistry"].push(task) + const evict = vi.spyOn(provider, "evictCurrentTask") + const remove = vi.spyOn(provider, "removeClineFromStack") + const replacement = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) await provider.resolveWebviewView(replacement) + }) + + await vi.advanceTimersByTimeAsync(120_000) + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith(`${ClineProvider.sideBarId}.removeView`) + await vi.advanceTimersByTimeAsync(10_000) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith(`${ClineProvider.sideBarId}.removeView`) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith(`${ClineProvider.sideBarId}.focus`, { + preserveFocus: true, + }) + expect(provider["view"]).toBe(replacement) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(provider.getCurrentTask()).toBe(task) + expect(evict).not.toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + }, + ) + + test.each(["HTML generation", "state hydration"] as const)( + "preserves normal sidebar startup rejection for failed %s outside recovery", + async (stage) => { + const error = new Error("initial view failed") + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("initial-html") + if (stage === "HTML generation") vi.mocked(provider["getWebviewHtml"]).mockRejectedValueOnce(error) + else vi.spyOn(provider, "getState").mockRejectedValueOnce(error) + + await expect(provider.resolveWebviewView(mockWebviewView)).rejects.toBe(error) + + expect(provider["webviewWatchdogInterval"]).toBeNull() + expect(provider["webviewRecoveryAttempts"]).toBe(0) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }, + ) + + describe("sidebar replacement timing", () => { + beforeEach(async () => { + const actual = await vi.importActual("p-wait-for") + vi.mocked(pWaitFor).mockImplementation(actual.default) + }) + + afterEach(() => { + vi.mocked(pWaitFor).mockReset().mockResolvedValue(undefined) + }) + + function removeSidebarOnCommand() { + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + }) + } + + test.each([false, true])( + "explicit retry renews a timed-out sidebar incident after ambiguous reopen: %s", + async (reopened) => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + let choose: (value: string) => void = () => { + throw new Error("No warning") + } + vi.mocked(vscode.window.showWarningMessage).mockImplementation( + vi.fn().mockReturnValue( + new Promise((resolve) => { + choose = resolve + }), + ), + ) + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(140_000) + expect(provider["pendingSidebarRecovery"]).toBe(true) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + if (reopened) { + await provider.resolveWebviewView({ + ...mockWebviewView, + webview: { ...mockWebviewView.webview, html: "" }, + }) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(provider.getCurrentTask()).toBe(task) + } + const healthy = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) await provider.resolveWebviewView(healthy) + }) + + choose("Retry Interface Recovery") + await vi.advanceTimersByTimeAsync(50) + + expect(provider["view"]).toBe(healthy) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(provider["webviewRecoveryRequestGeneration"]).toBe(1) + expect(provider["webviewRecoveryFailureReported"]).toBe(false) + expect(provider["webviewRecoveryStopLogged"]).toBe(false) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider.getCurrentTask()).toBe(task) + expect(remove).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.reloadWindow") + }, + ) + + test.each(["focus failure", "replacement timeout"] as const)( + "explicit retry reports %s without an automatic retry loop", + async (failure) => { + await provider.resolveWebviewView(mockWebviewView) + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(140_000) + if (failure === "focus failure") + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("focus unavailable")) + const retry = provider.retryWebviewRecovery() + await vi.advanceTimersByTimeAsync(10_000) + await retry + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(provider["pendingSidebarRecovery"]).toBe(failure === "replacement timeout") + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(120_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(2) + }, + ) + + test("warns once after ten seconds without a replacement and releases recovery ownership", async () => { + await provider.resolveWebviewView(mockWebviewView) + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(130_000) + expect(provider["webviewHostRecoveryInProgress"]).toBe(true) + await vi.advanceTimersByTimeAsync(9_999) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(120_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + test("settles the replacement wait when the provider is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(130_000) + expect(provider["webviewHostRecoveryInProgress"]).toBe(true) + await provider.dispose() + await vi.advanceTimersByTimeAsync(50) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }) + + test.each([5_000, 11_000])( + "retains an interrupted task and budget when replacement starts after %s ms", + async (delay) => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(130_000 + delay) + const attempts = delay < 10_000 ? 1 : 3 + expect(provider["webviewRecoveryAttempts"]).toBe(attempts) + expect(provider["webviewHostRecoveryInProgress"]).toBe(delay < 10_000) + + const replacement = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + await provider.resolveWebviewView(replacement) + await vi.advanceTimersByTimeAsync(50) + + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["pendingSidebarRecovery"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(attempts) + expect(provider["webviewRecoveryFailureReported"]).toBe(delay >= 10_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(delay < 10_000 ? 0 : 1) + expect(provider.getCurrentTask()).toBe(task) + expect(remove).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + }, + ) + + test.each(["HTML generation", "state hydration"] as const)( + "recovers an interrupted sidebar task after failed replacement %s", + async (stage) => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html") + const failed = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + const healthy = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + const replacements = [failed, healthy] + let resolution: Promise | undefined + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) { + const next = replacements.shift() + if (!next) throw new Error("Unexpected third replacement") + if (next === failed) { + if (stage === "HTML generation") + vi.mocked(provider["getWebviewHtml"]).mockRejectedValueOnce( + new Error("HTML failed"), + ) + else vi.spyOn(provider, "getState").mockRejectedValueOnce(new Error("hydration failed")) + } + // VS Code resolves the view independently of its focus command. + resolution = provider.resolveWebviewView(next) + } + }) + + await vi.advanceTimersByTimeAsync(130_050) + await expect(resolution).resolves.toBeUndefined() + + expect(provider["view"]).toBe(failed) + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect(provider.getCurrentTask()).toBe(task) + expect(remove).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(130_000) + await expect(resolution).resolves.toBeUndefined() + + expect(provider["view"]).toBe(healthy) + expect(healthy.webview.html).toBe("recovered-html") + expect(provider["webviewRecoveryAttempts"]).toBe(2) + expect(replacements).toHaveLength(0) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect(provider.getCurrentTask()).toBe(task) + expect(remove).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + }, + ) + + test("caps repeated failed sidebar replacements without evicting the task", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + provider["taskRegistry"].push(task) + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html") + const resolutions: Promise[] = [] + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) { + vi.spyOn(provider, "getState").mockRejectedValueOnce(new Error("hydration failed")) + const replacement = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + resolutions.push(provider.resolveWebviewView(replacement)) + } + }) + + await vi.advanceTimersByTimeAsync(720_000) + await Promise.all(resolutions) + + expect(resolutions).toHaveLength(3) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect(provider.getCurrentTask()).toBe(task) + expect(task.abortTask).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + }) + + test("keeps retries available when a published sidebar rejects after the replacement wait", async () => { + await provider.resolveWebviewView(mockWebviewView) + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(130_000) + let rejectHtml: (error: Error) => void = () => { + throw new Error("HTML generation has not started") + } + provider["getWebviewHtml"] = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectHtml = reject + }), + ) + const replacement = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + const resolution = provider.resolveWebviewView(replacement) + await vi.advanceTimersByTimeAsync(10_000) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + + rejectHtml(new Error("late HTML failure")) + await expect(resolution).resolves.toBeUndefined() + + expect(provider["view"]).toBe(replacement) + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + test("retains recovery intent and remaining attempts while replacement HTML generation outlasts the wait", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + removeSidebarOnCommand() + await vi.advanceTimersByTimeAsync(130_000) + let finishHtml: (html: string) => void = () => { + throw new Error("HTML generation has not started") + } + provider["getWebviewHtml"] = vi.fn( + () => + new Promise((resolve) => { + finishHtml = resolve + }), + ) + const replacement = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + const resolving = provider.resolveWebviewView(replacement) + await vi.advanceTimersByTimeAsync(10_000) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + finishHtml("replacement-html") + await resolving + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(provider.getCurrentTask()).toBe(task) + expect(remove).not.toHaveBeenCalled() + }) }) - expect(mockWebviewView.webview.html).toContain("") + test("recreates the sidebar again after a second incident", async () => { + await provider.resolveWebviewView(mockWebviewView) + const replacements = [0, 1].map(() => ({ + ...mockWebviewView, + webview: { ...mockWebviewView.webview, html: "" }, + })) + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) { + const next = replacements.shift() + if (!next) throw new Error("Unexpected third replacement") + await provider.resolveWebviewView(next) + } + }) + await vi.advanceTimersByTimeAsync(130_000) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + provider.updateWebviewHeartbeat() + await vi.advanceTimersByTimeAsync(30_000) + provider.updateWebviewHeartbeat() + await vi.advanceTimersByTimeAsync(30_000) + provider.updateWebviewHeartbeat() + expect(provider["webviewRecoveryAttempts"]).toBe(0) + await vi.advanceTimersByTimeAsync(130_000) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(replacements).toHaveLength(0) + expect( + vi + .mocked(vscode.commands.executeCommand) + .mock.calls.filter(([command]) => command === `${ClineProvider.sideBarId}.removeView`), + ).toHaveLength(2) + }) - // Verify Content Security Policy contains the necessary PostHog domains - expect(mockWebviewView.webview.html).toContain( - "connect-src vscode-webview://test-csp-source https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com", + test.each(["heartbeat", "hidden", "disposed", "suspended"] as const)( + "does not recreate the host after %s during the confirmation window", + async (event) => { + await provider.resolveWebviewView(mockWebviewView) + await vi.advanceTimersByTimeAsync(120_000) + if (event === "heartbeat") provider.updateWebviewHeartbeat() + if (event === "hidden") + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + if (event === "disposed") await provider.dispose() + if (event === "suspended") vi.setSystemTime(Date.now() + 120_000) + + await vi.advanceTimersByTimeAsync(10_000) + + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith(`${ClineProvider.sideBarId}.removeView`) + }, ) - // Extract the script-src directive section and verify required security elements - const html = mockWebviewView.webview.html - const scriptSrcMatch = html.match(/script-src[^;]*;/) - expect(scriptSrcMatch).not.toBeNull() - expect(scriptSrcMatch![0]).toContain("'nonce-") - // Verify wasm-unsafe-eval is present for Shiki syntax highlighting - expect(scriptSrcMatch![0]).toContain("'wasm-unsafe-eval'") - }) + test.each(["heartbeat", "hidden", "disposed", "replacement"] as const)( + "cancels host recreation after %s during command discovery", + async (event) => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockImplementation(async () => { + if (event === "heartbeat") provider.updateWebviewHeartbeat() + if (event === "hidden") + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + if (event === "disposed") await provider.dispose() + if (event === "replacement") provider["view"] = { ...mockWebviewView } + // Even an unavailable-command result must be ignored after cancellation. + return [] + }) + + await vi.advanceTimersByTimeAsync(130_000) + + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith(`${ClineProvider.sideBarId}.removeView`) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }, + ) + + test("ignores a late command-discovery error after a heartbeat returns", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockImplementation(async () => { + provider.updateWebviewHeartbeat() + throw new Error("discovery failed") + }) + + await vi.advanceTimersByTimeAsync(130_000) + + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }) + + test.each(["unavailable commands", "failed removal"] as const)( + "stops and notifies once when scoped recovery has %s", + async (failure) => { + await provider.resolveWebviewView(mockWebviewView) + if (failure === "unavailable commands") vi.mocked(vscode.commands.getCommands).mockResolvedValue([]) + if (failure === "failed removal") + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("removal failed")) + const assignHtml = trackHtmlAssignments() + + await vi.advanceTimersByTimeAsync(600_000) + + expect(assignHtml).toHaveBeenCalledTimes(1) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["pendingSidebarRecovery"]).toBe(false) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.reloadWindow") + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("Webview recovery stopped:"), + ) + }, + ) + + test("clears a failed focus incident before a manual sidebar open", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) throw new Error("focus failed") + }) + + await vi.advanceTimersByTimeAsync(130_000) + + expect(provider["view"]).toBeUndefined() + expect(provider["pendingSidebarRecovery"]).toBe(false) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect(remove).not.toHaveBeenCalled() + + const manualView = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + await provider.resolveWebviewView(manualView) + + expect(provider["view"]).toBe(manualView) + expect(provider["webviewRecoveryAttempts"]).toBe(0) + expect(provider["webviewRecoveryFailureReported"]).toBe(false) + expect(remove).toHaveBeenCalledTimes(1) + expect(provider.getCurrentTask()).toBeUndefined() + }) + + test.each([false, true])( + "logs one terminal stop and notification despite repeated ticks (prior retry warning: %s)", + async (priorWarning) => { + await provider.resolveWebviewView(mockWebviewView) + const html = mockWebviewView.webview.html + const log = vi.spyOn(provider, "log") + if (priorWarning) { + provider["reportWebviewRecoveryFailure"]("Initialization failed; retry pending", false) + expect(provider["webviewRecoveryAttempts"]).toBe(0) + expect(log).toHaveBeenCalledWith( + "[Zoo Code] Webview recovery will retry: Initialization failed; retry pending", + ) + } + provider["reportWebviewRecoveryFailure"]("Recovery is unavailable") + expect(log).toHaveBeenCalledWith("[Zoo Code] Webview recovery stopped: Recovery is unavailable") + await vi.advanceTimersByTimeAsync(300_000) + + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(mockWebviewView.webview.html).toBe(html) + expect(log.mock.calls.filter(([line]) => line.includes("Webview recovery stopped:"))).toEqual([ + ["[Zoo Code] Webview recovery stopped: Recovery is unavailable"], + ]) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }, + ) + + test.each(["heartbeat", "manual reopen"] as const)( + "logs a new terminal stop after %s begins a new incident", + async (reset) => { + await provider.resolveWebviewView(mockWebviewView) + const log = vi.spyOn(provider, "log") + provider["reportWebviewRecoveryFailure"]("First incident") + if (reset === "heartbeat") provider.updateWebviewHeartbeat() + else { + disposeCallback() + await provider.resolveWebviewView({ + ...mockWebviewView, + webview: { ...mockWebviewView.webview, html: "" }, + }) + } + expect(provider["webviewRecoveryStopLogged"]).toBe(false) + + provider["reportWebviewRecoveryFailure"]("Second incident") + await vi.advanceTimersByTimeAsync(300_000) + + expect(log.mock.calls.filter(([line]) => line.includes("Webview recovery stopped:"))).toEqual([ + ["[Zoo Code] Webview recovery stopped: First incident"], + ["[Zoo Code] Webview recovery stopped: Second incident"], + ]) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(2) + }, + ) + + test.each(["Retry Interface Recovery", "Reload Window", "Show Logs"] as const)( + "an old notification action cannot restart a renewed generation: %s", + async (choice) => { + await provider.resolveWebviewView(mockWebviewView) + let choose: (value: string) => void = () => { + throw new Error("No warning") + } + vi.mocked(vscode.window.showWarningMessage).mockImplementation( + vi.fn().mockReturnValue( + new Promise((resolve) => { + choose = resolve + }), + ), + ) + provider["reportWebviewRecoveryFailure"]("Old incident") + const healthy = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) await provider.resolveWebviewView(healthy) + }) + await provider.retryWebviewRecovery() + choose(choice) + await vi.advanceTimersByTimeAsync(0) + expect(provider["webviewRecoveryRequestGeneration"]).toBe(1) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.reloadWindow") + expect(mockOutputChannel.show).toHaveBeenCalledTimes(choice === "Show Logs" ? 1 : 0) + }, + ) + + test.each(["disposed", "stale notification"] as const)("does not renew recovery after %s", async (event) => { + await provider.resolveWebviewView(mockWebviewView) + if (event === "disposed") await provider.dispose() + else provider["webviewRecoveryRequestGeneration"] = 1 + vi.mocked(vscode.commands.executeCommand).mockClear() + await provider.retryWebviewRecovery(0) + expect(provider["webviewRecoveryRequestGeneration"]).toBe(event === "disposed" ? 0 : 1) + expect(vscode.commands.executeCommand).not.toHaveBeenCalled() + }) + + test("an old focus rejection cannot release or stop a newer explicit retry", async () => { + await provider.resolveWebviewView(mockWebviewView) + let rejectOldFocus: (error: Error) => void = () => { + throw new Error("Old focus has not started") + } + let finishNewFocus: () => void = () => { + throw new Error("New focus has not started") + } + let focusCount = 0 + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) { + focusCount++ + if (focusCount === 1) + await new Promise((_resolve, reject) => { + rejectOldFocus = reject + }) + else + await new Promise((resolve) => { + finishNewFocus = resolve + }) + } + }) + const old = provider["recreateWebviewHostForRecovery"](mockWebviewView) + await vi.advanceTimersByTimeAsync(0) + const retry = provider.retryWebviewRecovery() + await vi.advanceTimersByTimeAsync(0) + rejectOldFocus(new Error("obsolete focus rejection")) + await old + expect(provider["webviewHostRecoveryInProgress"]).toBe(true) + expect(provider["pendingSidebarRecovery"]).toBe(true) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + await provider.resolveWebviewView({ ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } }) + finishNewFocus() + await retry + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + }) + + test.each(["resolve", "reject"] as const)( + "ignores old initialization that completes on the same view after explicit retry: %s", + async (completion) => { + let finishHtml: (html: string) => void = () => { + throw new Error("HTML has not started") + } + let rejectHtml: (error: Error) => void = () => { + throw new Error("HTML has not started") + } + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve, reject) => { + finishHtml = resolve + rejectHtml = reject + }), + ) + .mockResolvedValue("fresh-html") + const old = provider.resolveWebviewView(mockWebviewView) + let finishDiscovery: (commands: string[]) => void = () => { + throw new Error("Discovery has not started") + } + vi.mocked(vscode.commands.getCommands).mockImplementationOnce( + () => + new Promise((resolve) => { + finishDiscovery = resolve + }), + ) + const retry = provider.retryWebviewRecovery() + await vi.advanceTimersByTimeAsync(0) + if (completion === "resolve") finishHtml("obsolete-html") + else rejectHtml(new Error("obsolete initialization failure")) + await old + expect(mockWebviewView.webview.html).toBe("") + expect(mockWebviewView.webview.onDidReceiveMessage).not.toHaveBeenCalled() + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + const healthy = { ...mockWebviewView, webview: { ...mockWebviewView.webview, html: "" } } + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) await provider.resolveWebviewView(healthy) + }) + finishDiscovery([`${ClineProvider.sideBarId}.removeView`, `${ClineProvider.sideBarId}.focus`]) + await retry + expect(provider["view"]).toBe(healthy) + expect(healthy.webview.html).toBe("fresh-html") + }, + ) + + test("old hydration cannot change terminal settings after explicit retry renews the same view", async () => { + await provider.resolveWebviewView(mockWebviewView) + const state = await provider.getState() + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("fresh-html") + let finishHydration: (value: typeof state) => void = () => { + throw new Error("Hydration has not started") + } + vi.spyOn(provider, "getState").mockImplementationOnce( + () => + new Promise((resolve) => { + finishHydration = resolve + }), + ) + const old = provider.resolveWebviewView(mockWebviewView) + await vi.advanceTimersByTimeAsync(0) + let finishDiscovery: (commands: string[]) => void = () => { + throw new Error("Discovery has not started") + } + vi.mocked(vscode.commands.getCommands).mockImplementationOnce( + () => + new Promise((resolve) => { + finishDiscovery = resolve + }), + ) + const retry = provider.retryWebviewRecovery() + await vi.advanceTimersByTimeAsync(0) + const terminal = vi.spyOn(Terminal, "setTerminalProfile") + finishHydration({ ...state, terminalProfile: "obsolete-profile" }) + await old + expect(terminal).not.toHaveBeenCalled() + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command) => { + if (command === `${ClineProvider.sideBarId}.removeView`) disposeCallback() + if (command === `${ClineProvider.sideBarId}.focus`) + await provider.resolveWebviewView({ + ...mockWebviewView, + webview: { ...mockWebviewView.webview, html: "" }, + }) + }) + finishDiscovery([`${ClineProvider.sideBarId}.removeView`, `${ClineProvider.sideBarId}.focus`]) + await retry + expect(terminal).not.toHaveBeenCalledWith("obsolete-profile") + }) + + test.each(["Reload Window", "Show Logs"] as const)( + "runs only the selected recovery action: %s", + async (action) => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockResolvedValue([]) + vi.mocked(vscode.window.showWarningMessage).mockImplementation(vi.fn().mockResolvedValue(action)) + + await vi.advanceTimersByTimeAsync(130_000) + + if (action === "Reload Window") + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.reloadWindow") + if (action === "Show Logs") { + expect(mockOutputChannel.show).toHaveBeenCalledTimes(1) + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.reloadWindow") + } + }, + ) + + test("ignores an old Reload Window choice after a heartbeat returns", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockResolvedValue([]) + let choose: (value: string) => void = () => { + throw new Error("Notification not shown") + } + const choice = new Promise((resolve) => { + choose = resolve + }) + vi.mocked(vscode.window.showWarningMessage).mockImplementation(vi.fn().mockReturnValue(choice)) + await vi.advanceTimersByTimeAsync(130_000) + + provider.updateWebviewHeartbeat() + choose("Reload Window") + await vi.advanceTimersByTimeAsync(0) + + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.reloadWindow") + }) + + test("offers a fresh actionable warning when one heartbeat is followed by another stall", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockResolvedValue([]) + let chooseOld: (value: string) => void = () => { + throw new Error("Notification not shown") + } + const oldChoice = new Promise((resolve) => { + chooseOld = resolve + }) + vi.mocked(vscode.window.showWarningMessage).mockImplementationOnce(vi.fn().mockReturnValue(oldChoice)) + await vi.advanceTimersByTimeAsync(130_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + + provider.updateWebviewHeartbeat() + expect(provider["webviewRecoveryAttempts"]).toBe(3) + chooseOld("Reload Window") + await vi.advanceTimersByTimeAsync(0) + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.reloadWindow") + + vi.mocked(vscode.window.showWarningMessage).mockImplementation(vi.fn().mockResolvedValue("Reload Window")) + await vi.advanceTimersByTimeAsync(170_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(2) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.reloadWindow") + await vi.advanceTimersByTimeAsync(240_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(2) + }) + + test("keeps the Reload Window action available after visibility grace resets", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockResolvedValue([]) + let choose: (value: string) => void = () => { + throw new Error("Notification not shown") + } + const choice = new Promise((resolve) => { + choose = resolve + }) + vi.mocked(vscode.window.showWarningMessage).mockImplementation(vi.fn().mockReturnValue(choice)) + await vi.advanceTimersByTimeAsync(130_000) + + visibilityCallback() + choose("Reload Window") + await vi.advanceTimersByTimeAsync(0) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.reloadWindow") + }) + + test("waits for the final HTML attempt to settle before reporting the limit", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider["webviewRecoveryAttempts"] = 2 + let rejectHtml: (error: Error) => void = () => { + throw new Error("HTML not requested") + } + const html = new Promise((_resolve, reject) => { + rejectHtml = reject + }) + provider["getWebviewHtml"] = vi.fn<(webview: vscode.Webview) => Promise>().mockReturnValue(html) + await vi.advanceTimersByTimeAsync(240_000) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + + rejectHtml(new Error("cannot generate")) + await vi.advanceTimersByTimeAsync(60_000) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + test("bounds failed HTML generation and reports the exhausted budget once", async () => { + await provider.resolveWebviewView(mockWebviewView) + const generate = vi.spyOn(provider, "getState").mockRejectedValue(new Error("cannot generate")) + const assignHtml = trackHtmlAssignments() + + await vi.advanceTimersByTimeAsync(600_000) + + expect(generate).toHaveBeenCalledTimes(3) + expect(assignHtml).not.toHaveBeenCalled() + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + test("does not replenish attempts in a mount-heartbeat-crash loop", async () => { + await provider.resolveWebviewView(mockWebviewView) + const assignHtml = trackHtmlAssignments() + for (let attempt = 0; attempt < 3; attempt++) { + await vi.advanceTimersByTimeAsync(120_000) + provider.updateWebviewHeartbeat() + } + await vi.advanceTimersByTimeAsync(240_000) + + expect(assignHtml).toHaveBeenCalledTimes(3) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + test("gives a user-opened replacement a fresh budget after unavailable host recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.mocked(vscode.commands.getCommands).mockResolvedValue([]) + const generateHtml = vi.spyOn(provider, "getState") + + await vi.advanceTimersByTimeAsync(130_000) + expect(generateHtml).toHaveBeenCalledTimes(1) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + + disposeCallback() + const replacementView = { + ...mockWebviewView, + webview: { ...mockWebviewView.webview, html: "" }, + } + await provider.resolveWebviewView(replacementView) + const htmlAfterResolve = replacementView.webview.html + generateHtml.mockClear() + + await vi.advanceTimersByTimeAsync(60_000) + expect(replacementView.webview.html).toBe(htmlAfterResolve) + await vi.advanceTimersByTimeAsync(60_000) + + expect(generateHtml).toHaveBeenCalledTimes(1) + expect(replacementView.webview.html).not.toBe(htmlAfterResolve) + expect(replacementView.webview.html).toContain("Zoo Code") + }) + + test("does not reload while the view is hidden even when the heartbeat is stale", async () => { + await provider.resolveWebviewView(mockWebviewView) + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewView.webview.html + const log = vi.spyOn(provider, "log") + + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + expect(log).not.toHaveBeenCalledWith(expect.stringContaining("Webview heartbeat stale")) + }) + + test("resets the grace window when the view becomes visible", async () => { + await provider.resolveWebviewView(mockWebviewView) + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + Object.defineProperty(mockWebviewView, "visible", { value: true, configurable: true }) + visibilityCallback() + const htmlAfterBecomingVisible = mockWebviewView.webview.html + + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewView.webview.html).toBe(htmlAfterBecomingVisible) + + // Watchdog ticks every 60s; 120s after the flip the heartbeat is stale again. + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewView.webview.html).not.toBe(htmlAfterBecomingVisible) + }) + + test("restarts the grace window when a tick gap shows the host slept", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // One ordinary tick, then a wall-clock jump past the stale + // threshold before the next tick. The gap means the host slept + // while both timers were paused, not that the renderer died. + await vi.advanceTimersByTimeAsync(60_000) + vi.setSystemTime(Date.now() + 120_000) + await vi.advanceTimersByTimeAsync(60_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // The grace window restarted at the post-sleep tick, so the + // watchdog still reloads once the heartbeat genuinely goes stale. + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewView.webview.html).not.toBe(htmlAfterResolve) + }) + + test("stops watching after the provider is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + await provider.dispose() + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("stops watching and clears the view when the sidebar webview is disposed", async () => { + mockWebviewView.webview.onDidReceiveMessage = vi.fn(() => ({ dispose: vi.fn() })) + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + const epochBefore = provider["webviewRecoveryEpoch"] + const trackedDisposables = [...provider["webviewDisposables"]] + expect(trackedDisposables).not.toHaveLength(0) + const disposeSpies = trackedDisposables.map((disposable) => vi.spyOn(disposable, "dispose")) + + // Precondition: the watchdog interval was actually scheduled, so the + // null check below proves disposal stopped it rather than it never + // having started. + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + + // The provider outlives a disposed sidebar view; VS Code re-resolves + // a fresh view later. Disposal must stop the watchdog and drop the + // stale view reference so no recovery reload targets the dead view. + disposeCallback() + expect(provider["webviewWatchdogInterval"]).toBeNull() + expect(provider["view"]).toBeUndefined() + expect(provider["webviewDisposables"]).toHaveLength(0) + for (const disposeSpy of disposeSpies) { + expect(disposeSpy).toHaveBeenCalled() + } + expect(provider["webviewRecoveryEpoch"]).toBe(epochBefore + 1) + + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not stack watchdog intervals when resolveWebviewView runs again", async () => { + await provider.resolveWebviewView(mockWebviewView) + const timersAfterFirstResolve = vi.getTimerCount() + await provider.resolveWebviewView(mockWebviewView) + expect(vi.getTimerCount()).toBe(timersAfterFirstResolve) + + // getHtmlContent regenerates the HTML via one getState() call per reload, + // so getState invocations after this point count watchdog reloads. + const getStateSpy = vi.spyOn(provider, "getState") + + await vi.advanceTimersByTimeAsync(120_000) + + expect(getStateSpy).toHaveBeenCalledTimes(1) + }) + + test("logs when regenerating the reload HTML fails", async () => { + await provider.resolveWebviewView(mockWebviewView) + vi.spyOn(provider, "getState").mockRejectedValue(new Error("regen boom")) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() + + await vi.advanceTimersByTimeAsync(120_000) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[Zoo Code] Failed to reload webview: regen boom"), + ) + }) + + test("logs and keeps the webview html when the recovery reload rejects", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + // The watchdog reload no longer goes through a VS Code command; stub + // the recovery HTML regeneration itself to reject. + provider["getWebviewHtml"] = vi.fn().mockRejectedValue(new Error("reload boom")) + ;(mockOutputChannel.appendLine as ReturnType).mockClear() + + await vi.advanceTimersByTimeAsync(120_000) + + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("[Zoo Code] Failed to reload webview: reload boom"), + ) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not reassign html when the provider is disposed mid-recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + await provider.dispose() + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not reassign html when the sidebar view is disposed mid-recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Defer the recovery reload's own HTML generation so the test proves + // the disposal lands while recovery is in flight, not before it starts. + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + // The stale heartbeat started a recovery reload, and its HTML + // generation is still pending. + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + disposeCallback() + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not reassign html when the watched view is replaced mid-recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Defer the recovery reload's own HTML generation so the test proves + // the replacement lands while recovery is in flight, not before it + // starts. + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + // The stale heartbeat started a recovery reload, and its HTML + // generation is still pending. + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // VS Code re-resolves a fresh view (e.g. sidebar re-opened) while the + // recovery reload for the old view is still awaiting its HTML. + // @ts-ignore - accessing private property for testing + provider.view = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + } + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("skips the recovery reload when a heartbeat arrives while regenerating HTML", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // The renderer process reported in again while the recovery HTML was + // still being generated, so the webview is alive and must not reload. + provider.updateWebviewHeartbeat() + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("skips the recovery reload when two heartbeats land in the same millisecond during recovery", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // Pin the clock to the timestamp captured before recovery: both + // heartbeats stamp the same millisecond, so a timestamp comparison + // would see no change, but the heartbeat revision moved, so the + // reload must still be skipped. + const heartbeatAtCapture = provider["lastWebviewHeartbeatAt"] + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(heartbeatAtCapture) + provider.updateWebviewHeartbeat() + expect(provider["lastWebviewHeartbeatAt"]).toBe(heartbeatAtCapture) + provider.updateWebviewHeartbeat() + expect(provider["lastWebviewHeartbeatAt"]).toBe(heartbeatAtCapture) + nowSpy.mockRestore() + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("skips the recovery reload when the view hides while regenerating HTML", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // The view hid while the recovery HTML was still being generated; a + // hidden webview throttles heartbeats, so the stale heartbeat no + // longer proves a dead renderer. + Object.defineProperty(mockWebviewView, "visible", { value: false, configurable: true }) + + finishReload("recovered") + await vi.advanceTimersByTimeAsync(0) + + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + }) + + test("does not start a second recovery while one is in flight and recovers after a failure", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // The first recovery blocks on a controlled pending generation; the + // retry (second call) gets its own deferred generation. + let failFirst: (error: Error) => void = () => {} + let finishRetry: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failFirst = reject + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // The watchdog ticks again while the first recovery still awaits + // its HTML; no second generation may start for the same view. + await vi.advanceTimersByTimeAsync(60_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + + // The pending generation rejects; the finally block must clear the + // in-flight state so a later watchdog tick can start a fresh + // recovery instead of being blocked forever. + failFirst(new Error("reload boom")) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(60_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + + // The retry's generation resolves with fresh HTML and must land in + // the webview. + finishRetry("recovered-retry") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toContain("recovered-retry") + }) + + test("lets the replacement view's recovery start while a stale one is pending", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + + // Each recovery generation gets its own deferred finish callback. + const generations: Array<{ finish: (html: string) => void }> = [] + provider["getWebviewHtml"] = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + generations.push({ finish: resolve }) + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // The sidebar view is disposed (bumping webviewRecoveryEpoch) and a + // replacement view takes over while recovery A's HTML generation is + // still pending, making A stale. + disposeCallback() + // @ts-ignore - accessing private property for testing + provider.view = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + } + const replacementEpoch = provider["webviewRecoveryEpoch"] + + // Recovery B for the new view must start even though A is still + // pending: the in-flight guard is scoped to the epoch. + void provider["reloadWebviewForRecovery"]() + await vi.advanceTimersByTimeAsync(0) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + + // A's completion is stale: it must not reassign the old view's html, + // and its finally must NOT release the ownership B took. + generations[0].finish("stale-A") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + expect(provider["webviewRecoveryInFlightEpoch"]).toBe(replacementEpoch) + + // B completes and lands its HTML on the new view, then releases + // ownership so future recoveries can start. + generations[1].finish("recovered-B") + await vi.advanceTimersByTimeAsync(0) + // @ts-ignore - accessing private property for testing + expect(provider.view.webview.html).toContain("recovered-B") + expect(provider["webviewRecoveryInFlightEpoch"]).toBeUndefined() + }) + + test("advances the recovery epoch when resolveWebviewView replaces the view and lets the new recovery start", async () => { + await provider.resolveWebviewView(mockWebviewView) + const htmlAfterResolve = mockWebviewView.webview.html + const epochBeforeReplacement = provider["webviewRecoveryEpoch"] + + // Recovery generations get their own deferred finish callbacks; the + // replacement view's initial HTML generation resolves immediately so + // resolveWebviewView can complete. + const generations: Array<{ finish: (html: string) => void }> = [] + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + generations.push({ finish: resolve }) + }), + ) + .mockImplementationOnce(() => Promise.resolve("view2-initial")) + .mockImplementation( + () => + new Promise((resolve) => { + generations.push({ finish: resolve }) + }), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // VS Code re-resolves a DIFFERENT view without a disposal in between + // (the gap path): the epoch must advance so the stale recovery can + // neither block nor reassign the replacement view's recovery. + const mockWebviewView2 = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn(), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + await provider.resolveWebviewView(mockWebviewView2) + expect(provider["webviewRecoveryEpoch"]).toBe(epochBeforeReplacement + 1) + + // Recovery B for the replacement view starts immediately, unblocked + // by A's still-pending generation. + void provider["reloadWebviewForRecovery"]() + await vi.advanceTimersByTimeAsync(0) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(3) + + // A's completion is stale: the old view's html is untouched and A's + // finally does not release the ownership B took. + generations[0].finish("stale-A") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toBe(htmlAfterResolve) + expect(provider["webviewRecoveryInFlightEpoch"]).toBe(provider["webviewRecoveryEpoch"]) + + // B completes and lands its HTML on the replacement view, then + // releases ownership. + generations[1].finish("recovered-B") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView2.webview.html).toContain("recovered-B") + expect(provider["webviewRecoveryInFlightEpoch"]).toBeUndefined() + }) + + test("does not advance the recovery epoch when the same view is re-resolved and keeps the pending recovery intact", async () => { + await provider.resolveWebviewView(mockWebviewView) + const epochAfterFirstResolve = provider["webviewRecoveryEpoch"] + + // Recovery A blocks on a controlled pending generation; the same + // view's re-resolve initial HTML resolves immediately. + let finishReload: (html: string) => void = () => {} + provider["getWebviewHtml"] = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishReload = resolve + }), + ) + .mockImplementation(() => + Promise.resolve("same-view-reresolve"), + ) + + await vi.advanceTimersByTimeAsync(120_000) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(1) + + // Re-resolving the SAME view must not bump the epoch nor disrupt the + // pending recovery that belongs to it. + await provider.resolveWebviewView(mockWebviewView) + expect(provider["webviewRecoveryEpoch"]).toBe(epochAfterFirstResolve) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + expect(provider["webviewRecoveryInFlightEpoch"]).toBe(epochAfterFirstResolve) + + // A still owns the view across the re-resolve: another recovery + // attempt is blocked until A settles. + void provider["reloadWebviewForRecovery"]() + await vi.advanceTimersByTimeAsync(0) + expect(provider["getWebviewHtml"]).toHaveBeenCalledTimes(2) + + // A settles cleanly: the re-resolve restarted the watchdog, which + // re-stamped the heartbeat, so the revision guard (not the epoch) + // skips the stale assignment; the ownership A held is released. + finishReload("recovered-A") + await vi.advanceTimersByTimeAsync(0) + expect(mockWebviewView.webview.html).toContain("same-view-reresolve") + expect(provider["webviewRecoveryInFlightEpoch"]).toBeUndefined() + }) + + test("reloads only its own webview when multiple providers are active", async () => { + // Structural stand-in for the VS Code webview API surface this scenario + // exercises, same as the mockContext cast below. + const mockWebviewViewB = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn(), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + const providerB = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + try { + await provider.resolveWebviewView(mockWebviewView) + await providerB.resolveWebviewView(mockWebviewViewB) + + const htmlBeforeA = mockWebviewView.webview.html + const htmlBeforeB = mockWebviewViewB.webview.html + + // The pre-PR reload path fired the global + // workbench.action.webview.reloadWebviewAction command, which + // resets every webview. The html assertions below cannot catch a + // regression back to that command because the vscode mock + // swallows executeCommand without touching any view, so also + // assert the global command was never invoked. + const executeCommandMock = vscode.commands.executeCommand as ReturnType + + await vi.advanceTimersByTimeAsync(119_000) + // B's view is still alive; refresh its heartbeat so a stale one + // would reload it too, leaving only A's watchdog to fire. + await webviewMessageHandler(providerB, { type: "webviewHeartbeat", timestamp: Date.now() }) + await vi.advanceTimersByTimeAsync(1_000) + + expect(mockWebviewView.webview.html).not.toBe(htmlBeforeA) + expect(mockWebviewViewB.webview.html).toBe(htmlBeforeB) + expect(executeCommandMock).not.toHaveBeenCalledWith("workbench.action.webview.reloadWebviewAction") + } finally { + await providerB.dispose() + } + }) + + describe("tab panel (WebviewPanel shape)", () => { + let viewStateCallback: () => void + // Structural stand-in for the VS Code webview API surface this + // scenario exercises, same as the mockWebviewViewB cast below. + let mockWebviewPanel: vscode.WebviewPanel + + beforeEach(() => { + // WebviewPanel-shaped stand-in: same webview surface, but the + // visibility listener is onDidChangeViewState instead of + // onDidChangeVisibility, matching resolveWebviewView's tab branch. + mockWebviewPanel = { + webview: { + postMessage: vi.fn(), + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidChangeViewState: vi.fn().mockImplementation((cb: () => void) => { + viewStateCallback = cb + return { dispose: vi.fn() } + }), + dispose: vi.fn(), + reveal: vi.fn(), + } as unknown as vscode.WebviewPanel + }) + + test("disposes the current tab provider and its watchdog registration", async () => { + const onDispose: Array<() => unknown> = [] + const registration = { dispose: vi.fn() } + vi.mocked(mockWebviewPanel.onDidDispose).mockImplementation((listener, _thisArg, disposables) => { + onDispose.push(listener) + const disposable = onDispose.length === 1 ? registration : { dispose: vi.fn() } + disposables?.push(disposable) + return disposable + }) + await provider.resolveWebviewView(mockWebviewPanel) + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + const dispose = vi.spyOn(provider, "dispose") + const html = mockWebviewPanel.webview.html + + await onDispose[0]() + + expect(dispose).toHaveBeenCalledTimes(1) + expect(provider["_disposed"]).toBe(true) + expect(provider["webviewWatchdogInterval"]).toBeNull() + expect(registration.dispose).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(600_000) + expect(mockWebviewPanel.webview.html).toBe(html) + }) + + test.each([false, true])( + "replaces the tab host without disposing its provider or task (interrupted: %s)", + async (interrupted) => { + const callbacks: Array<() => unknown> = [] + vi.mocked(mockWebviewPanel.onDidDispose).mockImplementation((listener) => { + callbacks.push(listener) + return { dispose: vi.fn() } + }) + vi.mocked(mockWebviewPanel.dispose).mockImplementation(() => { + callbacks.forEach((callback) => callback()) + }) + mockWebviewPanel.title = "Zoo Code task" + await provider.resolveWebviewView(mockWebviewPanel) + const task = new Task(defaultTaskOptions) + task.abort = interrupted + provider["taskRegistry"].push(task) + const evict = vi.spyOn(provider, "evictCurrentTask") + const remove = vi.spyOn(provider, "removeClineFromStack") + const replacement = { + ...mockWebviewPanel, + webview: { ...mockWebviewPanel.webview, html: "" }, + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + } + vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(replacement) + + await vi.advanceTimersByTimeAsync(130_000) + + expect(mockWebviewPanel.dispose).toHaveBeenCalledTimes(1) + expect(provider["view"]).toBe(replacement) + expect(provider["_disposed"]).toBe(false) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(provider.getCurrentTask()).toBe(task) + expect(evict).not.toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( + `${ClineProvider.sideBarId}.removeView`, + ) + }, + ) + + test("explicit retry replaces an exhausted tab without evicting its interrupted task", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + provider["reportWebviewRecoveryFailure"]("Exhausted incident") + const healthy = replacementPanel() + vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(healthy) + await provider.retryWebviewRecovery() + expect(provider["view"]).toBe(healthy) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(provider.getCurrentTask()).toBe(task) + expect(remove).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + }) + + test("keeps the old tab and its task when replacement resolution fails before publication", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + const task = new Task(defaultTaskOptions) + provider["taskRegistry"].push(task) + const replacement = { ...mockWebviewPanel, dispose: vi.fn() } + vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(replacement) + vi.spyOn(provider, "resolveWebviewView").mockRejectedValue(new Error("resolver unavailable")) + + await provider["recreateWebviewHostForRecovery"](mockWebviewPanel) + + expect(mockWebviewPanel.dispose).not.toHaveBeenCalled() + expect(replacement.dispose).toHaveBeenCalledTimes(1) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["view"]).toBe(mockWebviewPanel) + expect(provider.getCurrentTask()).toBe(task) + expect(task.dispose).not.toHaveBeenCalled() + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + }) + + function replacementPanel() { + return { + ...mockWebviewPanel, + webview: { + ...mockWebviewPanel.webview, + html: "", + onDidReceiveMessage: vi.fn(() => ({ dispose: vi.fn() })), + }, + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + dispose: vi.fn(), + } + } + + test.each(["HTML generation", "state hydration"] as const)( + "retries a published tab after failed %s without disposing its interrupted task", + async (stage) => { + await provider.resolveWebviewView(mockWebviewPanel) + const task = new Task(defaultTaskOptions) + task.abort = true + provider["taskRegistry"].push(task) + const remove = vi.spyOn(provider, "removeClineFromStack") + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html") + await provider["reloadWebviewForRecovery"]() + const failed = replacementPanel() + const healthy = replacementPanel() + vi.mocked(vscode.window.createWebviewPanel).mockReturnValueOnce(failed).mockReturnValue(healthy) + if (stage === "HTML generation") { + vi.mocked(provider["getWebviewHtml"]).mockRejectedValueOnce(new Error("HTML failed")) + } else { + vi.spyOn(provider, "getState").mockRejectedValueOnce(new Error("hydration failed")) + } + await vi.advanceTimersByTimeAsync(10_000) + + expect(provider["view"]).toBe(failed) + expect(failed.webview.onDidReceiveMessage).not.toHaveBeenCalled() + expect(failed.dispose).not.toHaveBeenCalled() + expect(provider["webviewWatchdogInterval"]).not.toBeNull() + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(provider["webviewRecoveryAttempts"]).toBe(1) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining("interface is not responding"), + "Retry Interface Recovery", + "Reload Window", + "Show Logs", + ) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining("Failed to initialize replacement tab"), + ) + + await vi.advanceTimersByTimeAsync(130_000) + + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(2) + expect(provider["view"]).toBe(healthy) + // The provider and focus tracker each install one message listener. + expect(healthy.webview.onDidReceiveMessage).toHaveBeenCalledTimes(2) + expect(failed.dispose).toHaveBeenCalledTimes(1) + expect(provider["webviewRecoveryAttempts"]).toBe(2) + expect(provider.getCurrentTask()).toBe(task) + expect(provider["_disposed"]).toBe(false) + expect(remove).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + }, + ) + + test.each(["disposed", "replaced"] as const)( + "does not restart a stale replacement watchdog after the provider is %s", + async (event) => { + await provider.resolveWebviewView(mockWebviewPanel) + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html") + await provider["reloadWebviewForRecovery"]() + let rejectHtml: (error: Error) => void = () => { + throw new Error("Replacement HTML generation has not started") + } + vi.mocked(provider["getWebviewHtml"]).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectHtml = reject + }), + ) + const failed = replacementPanel() + vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(failed) + await vi.advanceTimersByTimeAsync(10_000) + expect(provider["view"]).toBe(failed) + if (event === "disposed") await provider.dispose() + else await provider.resolveWebviewView(replacementPanel()) + const watchdog = provider["webviewWatchdogInterval"] + rejectHtml(new Error("stale HTML failure")) + await vi.advanceTimersByTimeAsync(0) + expect(provider["webviewWatchdogInterval"]).toBe(watchdog) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }, + ) + + test("bounds repeated published-tab initialization failures and keeps the task available", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + const task = new Task(defaultTaskOptions) + provider["taskRegistry"].push(task) + provider["getWebviewHtml"] = vi.fn().mockResolvedValue("recovered-html") + vi.mocked(vscode.window.createWebviewPanel).mockImplementation(() => { + const replacement = replacementPanel() + // Fail after publication, before any message listener is installed. + vi.spyOn(provider, "getState").mockRejectedValueOnce(new Error("hydration failed")) + return replacement + }) + + await vi.advanceTimersByTimeAsync(720_000) + + expect(vscode.window.createWebviewPanel).toHaveBeenCalledTimes(3) + expect(provider["webviewRecoveryAttempts"]).toBe(3) + expect(provider["webviewHostRecoveryInProgress"]).toBe(false) + expect(vscode.window.showWarningMessage).toHaveBeenCalledTimes(1) + expect( + vi + .mocked(mockOutputChannel.appendLine) + .mock.calls.filter(([line]) => line.includes("Webview recovery stopped:")), + ).toHaveLength(1) + expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( + expect.stringContaining( + "Webview recovery stopped: Recovery attempt limit reached without sustained heartbeats", + ), + ) + expect(provider["_disposed"]).toBe(false) + expect(provider.getCurrentTask()).toBe(task) + expect(task.dispose).not.toHaveBeenCalled() + expect(task.abortTask).not.toHaveBeenCalled() + }) + + test("reloads the tab webview when the heartbeat is stale and the tab is visible", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + const htmlAfterResolve = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + + expect(mockWebviewPanel.webview.html).not.toBe(htmlAfterResolve) + expect(mockWebviewPanel.webview.html).toContain("Zoo Code") + }) + + test("does not reload a hidden tab even when the heartbeat is stale", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + Object.defineProperty(mockWebviewPanel, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(180_000) + + expect(mockWebviewPanel.webview.html).toBe(htmlAfterResolve) + }) + + test("resets the grace window when the tab becomes visible", async () => { + await provider.resolveWebviewView(mockWebviewPanel) + Object.defineProperty(mockWebviewPanel, "visible", { value: false, configurable: true }) + const htmlAfterResolve = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(120_000) + expect(mockWebviewPanel.webview.html).toBe(htmlAfterResolve) + + Object.defineProperty(mockWebviewPanel, "visible", { value: true, configurable: true }) + viewStateCallback() + const htmlAfterBecomingVisible = mockWebviewPanel.webview.html + + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewPanel.webview.html).toBe(htmlAfterBecomingVisible) + + // Watchdog ticks every 60s; 120s after the flip the heartbeat is stale again. + await vi.advanceTimersByTimeAsync(60_000) + expect(mockWebviewPanel.webview.html).not.toBe(htmlAfterBecomingVisible) + }) + }) + }) + test("resolveWebviewView sets up webview correctly in development mode even if local server is not running", async () => { + const developmentContext = { ...mockContext, extensionMode: vscode.ExtensionMode.Development } + provider = new ClineProvider( + developmentContext, + mockOutputChannel, + "sidebar", + new ContextProxy(developmentContext), + new WebviewFocusTracker(), + ) + // The dev-server probe fails, so the HMR path falls back to the production HTML. + ;(axios.get as any).mockRejectedValueOnce(new Error("Network error")) + + await provider.resolveWebviewView(mockWebviewView) + + expect(mockWebviewView.webview.options).toEqual({ + enableScripts: true, + localResourceRoots: [mockContext.extensionUri], + }) + + expect(mockWebviewView.webview.html).toContain("") + + // Verify Content Security Policy contains the necessary PostHog domains + expect(mockWebviewView.webview.html).toContain( + "connect-src vscode-webview://test-csp-source https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com", + ) + + // Extract the script-src directive section and verify required security elements + const html = mockWebviewView.webview.html + const scriptSrcMatch = html.match(/script-src[^;]*;/) + expect(scriptSrcMatch).not.toBeNull() + expect(scriptSrcMatch![0]).toContain("'nonce-") + // Verify wasm-unsafe-eval is present for Shiki syntax highlighting + expect(scriptSrcMatch![0]).toContain("'wasm-unsafe-eval'") + }) + + test.each([ + ["ECONNABORTED", "errors.hmr_probe_timeout"], + ["ETIMEDOUT", "errors.hmr_probe_timeout"], + ["ECONNREFUSED", "errors.hmr_not_running"], + ])("reports dev-server probe failure %s accurately and falls back to built HTML", async (code, message) => { + const { AxiosError } = await vi.importActual("axios") + vi.mocked(axios.get).mockRejectedValueOnce(new AxiosError("probe failed", code)) + + const html = await provider["getHMRHtmlContent"](mockWebviewView.webview) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(message) + expect(html).toContain("") + expect(html).not.toContain("/@react-refresh") + }) test("resolveWebviewView builds HMR content against the local dev server when it is reachable", async () => { const originalThemeFixtureProbe = process.env.ROO_CODE_THEME_FIXTURE_PROBE delete process.env.ROO_CODE_THEME_FIXTURE_PROBE From f3ca33bf23f993d87ca9db9a57cb9bbc7b17673d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:22:11 +0800 Subject: [PATCH 44/51] fix(config): settle in-flight secret writes before a reset ends their claims A reset deleted every secret key before a storeSecret that was still in flight had committed, so the write landed afterwards and a pre-reset secret came back from storage even though the cache was empty. resetAllState now waits for the pending settles first, then ends write ownership, then deletes, so the cleared copy is the last write to land. The claim is cleared after the settle on purpose: a write that settles while it still owns its key adopts into the cache, which the very next lines clear, and its storage write is then deleted too. A secret write issued after a reset that waited for a pending write is adopted normally, because it is a post-reset intent that owns its key. Measured before the change: at the instant resetAllState returned, storage still held the pre-reset secret while getSecret() read undefined and the delete pass had already run 37 times - awaiting the settlement after the deletes settles correctly and changes nothing. Both added cases were red against the previous head. Control: replacing the await with 'void inFlight' re-reddens the regression case; the production file was restored by content hash. Addresses row 6z9EGf on #2227. Co-Authored-By: Claude --- src/core/config/ContextProxy.ts | 26 +++++++ .../config/__tests__/ContextProxy.spec.ts | 69 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index f5281bde9e..9d1ae3d3c0 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -81,6 +81,7 @@ export class ContextProxy { constructor(context: vscode.ExtensionContext) { this.originalContext = context + this.stateCache = {} this.secretCache = {} this._isInitialized = false @@ -400,6 +401,12 @@ export class ContextProxy { */ private readonly stateWriteOwner = new Map() private readonly secretWriteOwner = new Map() + /** + * The secret writes that are still in flight, each paired with the claim it took, + * so a reset can wait for them to settle before it deletes the keys. Without the + * pairing a reset cannot tell a settled-but-orphaned write from a live one. + */ + private readonly pendingSecretWrites = new Map }>>() /** How many writes of each secret were started. Unlike the owners, it is not cleared when a write is over. */ private readonly secretWriteCount = new Map() @@ -457,7 +464,19 @@ export class ContextProxy { // cache (it stays empty until the next write, `refreshSecrets` or a restart) rather than being served. // The entry of the key is removed when the write and its read-back are over (if it is still the latest). A store // that throws at once is turned into a rejection, so it is handled the same. + const pending = this.pendingSecretWrites.get(key) ?? new Set<{ settle: Promise }>() + let settleWrite: () => void = () => {} + const entry: { settle: Promise } = { + settle: new Promise((resolve) => { + settleWrite = resolve + }), + } + pending.add(entry) + this.pendingSecretWrites.set(key, pending) const release = () => { + pending.delete(entry) + if (pending.size === 0) this.pendingSecretWrites.delete(key) + settleWrite() if (this.secretWriteOwner.get(key) === owner) this.secretWriteOwner.delete(key) } const stored = (async () => @@ -719,6 +738,13 @@ export class ContextProxy { * @returns A promise that resolves when all reset operations are complete */ public async resetAllState() { + // A secret write that is in flight at this moment has already committed nothing, but it will + // commit as soon as it settles, and the deletes below cannot un-commit a write that lands + // after them. Wait for the pending writes first, then end the claims, then delete, so the + // cleared copy is the last one to land in storage and the cache stays empty afterwards. + const inFlight = [...this.pendingSecretWrites.values()].flatMap((writes) => [...writes].map((w) => w.settle)) + await Promise.allSettled(inFlight) + // Clear in-memory caches this.stateCache = {} this.secretCache = {} diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 016f75118b..c31062c0b5 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -157,6 +157,75 @@ describe("ContextProxy", () => { await expect(pending).rejects.toThrow("disk full") expect(proxy.getGlobalState("apiProvider")).toBeUndefined() }) + it("does not restore a pre-reset secret when a pending storeSecret settles after the reset", async () => { + const durable = new Map() + let settle: () => void = () => {} + mockSecrets.get.mockImplementation(async (key: string) => durable.get(key)) + mockSecrets.delete.mockImplementation(async (key: string) => { + durable.delete(key) + }) + mockSecrets.store.mockImplementationOnce( + (key: string, value: string) => + new Promise((resolve) => { + settle = () => { + durable.set(key, value) + resolve(undefined) + } + }), + ) + + const pending = proxy.storeSecret("apiKey", "pre-reset-secret") + setTimeout(settle, 0) + await proxy.resetAllState() + await pending + expect.soft([ + await mockSecrets.get("apiKey"), + proxy.getSecret("apiKey"), + mockSecrets.delete.mock.calls.length, + ]) + + // The pending store is held open on a double that only puts the value into storage + // when it settles, so the ordering is observable: the reset must wait for the write to + // settle and then delete the keys, so neither the cache nor the durable copy keeps the + // value that was in flight when the reset started. + expect(await mockSecrets.get("apiKey")).toBeUndefined() + expect(proxy.getSecret("apiKey")).toBeUndefined() + }) + + it("adopts a secret write issued after a reset that waited for a pending write", async () => { + const durable = new Map() + let settle: () => void = () => {} + mockSecrets.get.mockImplementation(async (key: string) => durable.get(key)) + mockSecrets.delete.mockImplementation(async (key: string) => { + durable.delete(key) + }) + mockSecrets.store.mockImplementationOnce( + (key: string, value: string) => + new Promise((resolve) => { + settle = () => { + durable.set(key, value) + resolve(undefined) + } + }), + ) + + const pending = proxy.storeSecret("apiKey", "pre-reset-secret") + const reset = proxy.resetAllState() + setTimeout(settle, 0) + await pending + await reset + + // The reset released only its own claims, so the machinery stays usable afterwards: + // a write issued once the reset has settled is adopted into the cache and reaches storage. + // Back the post-reset write with the durable double as well, so the assertion below reads + // storage rather than only the cache. + mockSecrets.store.mockImplementationOnce(async (key: string, value: string) => { + durable.set(key, value) + }) + await proxy.storeSecret("apiKey", "newer-secret") + expect(proxy.getSecret("apiKey")).toBe("newer-secret") + expect(await mockSecrets.get("apiKey")).toBe("newer-secret") + }) it("should bypass cache for pass-through state keys", async () => { // Setup mock return value From f061de5eaad5286e768d545467b3e2a40f4f2435 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:15:27 +0800 Subject: [PATCH 45/51] test(webview): pin the activating save to name the profile it activated Review row PRRT_kwDOSK-bwM6z9wTV asks that an activating provider-profile save mirror both the profile name and its settings into this view, and that a spec case prove getValues() and the durable viewStates entry name the newly activated profile. The merge of f3ca33bf2 already carries that mirroring at ClineProvider.ts:2893-2896; what was missing was the test, so production is untouched in this commit. The case primes a real pin - this view's buffer and its durable entry both name profile A while A settings sit in the buffer - then calls persistProviderProfile with activate=true and asserts the write shape and both halves of the end state. A wrapper around the private mutation entry point records what this view was asked to write, in order, so the case pins one mutation carrying the name together with the settings rather than only the final values. Controls on this tree, each a single-line deletion in ClineProvider.ts and reverted after the run: - deleting currentApiConfigName from the activation mirror reddens this case (2 failed of 385) together with the existing concurrent-viewStates case, so the pin sits on the mirroring the row describes, not on the priming; - deleting the post-activation self refresh reddens this case on its ordering assertion (2 failed of 385); - deleting the group's shared setValue of the name leaves this case green and reddens five rollback-ownership cases instead: a different claim. Gates on the committed tree: prettier --check, eslint --prune-suppressions --max-warnings=0 on the spec (3301 -> 3301, no increase), check-types 11/11, format:check, lint 12/12, and the core lane (194 files, 4257 passed | 9 skipped, zero files with zero collected cases; lane report, not the compile job). Declaration count in ClineProvider.spec.ts: 379, floor 375. --- .../webview/__tests__/ClineProvider.spec.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index acd2d8186b..acb71d7736 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6902,6 +6902,79 @@ describe("ClineProvider", () => { expect(provider["viewLocalState"].apiConfiguration?.apiKey).toBe("next-key") await provider.dispose() }) + it("names the newly activated profile in the view buffer and its durable pin on an activating save", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry, nextEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + await provider["setViewStateId"]("stable-sidebar-view") + // The scenario the review row describes: this view is pinned to profile A in its buffer and + // in the durable record, and it is holding A settings. saveViewState writes both halves, so + // the pin is a real pin and not a primed field. + await provider.saveViewState("currentApiConfigName", "prior-profile") + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("next-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + + // Record every write this view makes to its own buffer and durable pin, in order, by + // wrapping the private mutation entry point. Bracket access reaches the private member + // without an assertion, and the wrapper delegates so behaviour is unchanged. + const viewWrites: (Partial & Partial)[] = [] + const mirrorWrite = provider["_saveViewLocalStateFromMutation"] + provider["_saveViewLocalStateFromMutation"] = async (values) => { + // The buffer is mutated in place, so record a snapshot of the payload rather + // than the object the mutation path hands over. + viewWrites.push({ ...values }) + await mirrorWrite.call(provider, values) + } + + // A save that activates: the shared stores and this view mirror must agree on B. + await provider["persistProviderProfile"]( + "next-profile", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" }, + true, + new AbortController().signal, + ) + + // getValues() overlays this view buffer over the shared store. The reviewer fix is that one + // mutation carries the name together with the settings, so this view is never read in a + // state where a new name sits on the previous profile settings. Pin that, then both halves + // of the end state. + expect(viewWrites.some((values) => "currentApiConfigName" in values && "apiConfiguration" in values)).toBe( + true, + ) + // The ordering this branch keeps on top of their group: the self refresh that follows the + // group still runs last, after every activation write landed. + expect(viewWrites.at(-1)?.apiConfiguration).toEqual({ + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "new-model", + }) + // A mirror that carried only apiConfiguration would leave the buffered name A shadowing the + // profile that was just activated, which is what the webview would then be shown. + expect(provider.getValues().currentApiConfigName).toBe("next-profile") + expect(provider["viewLocalState"].apiConfiguration?.openRouterModelId).toBe("new-model") + // The durable pin has to follow too: a reload restores the entry below, and an entry that + // still names A loses the activation the user just performed. + expect(provider.contextProxy.getValue("viewStates")).toMatchObject({ + "stable-sidebar-view": { currentApiConfigName: "next-profile" }, + }) + await provider.dispose() + }) it("should undo this activation's mode binding and settings while it still owns them", async () => { const provider = new ClineProvider( mockContext, From 515cc020d2a77edfd05e744f4bd6a1d1f43a8d77 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:44:50 +0800 Subject: [PATCH 46/51] fix(config): refuse secret writes that begin inside a reset instead of letting them survive it resetAllState waits for the secret writes that are in flight when it starts, clears both caches, drops both write claims and then deletes the keys. A storeSecret that begins after that in-flight snapshot is invisible to all of it: it puts the value into the secret cache on entry, the reset clears that cache underneath it, and the write's durable commit lands after the delete pass. The success path never touches the cache again, so the value stays out of the cache while it stays in durable storage, and the next initialize - what a restarted provider does - reads it back out and surfaces to the user a secret they had just reset away. Secret writes are therefore refused while a reset is in progress, with a SettingsWriteError naming the key, and the flag is cleared only after the reset has re-initialized so the whole window is covered. Refusing was chosen over serializing the reset: a serialized reset lets the write apply and then erases it, which discards silently a save the user just asked for, whereas a refusal leaves durable storage empty, which is what a reset promises, and hands the caller an error on a path that already reports which keys it could not store. Serialization was available and was rejected for that reason rather than omitted. Case: erases a secret whose write begins after the reset has snapshotted the pending writes. It parks the reset inside its own delete pass, starts the write in that window and reads three things: the durable double behind the secrets, this instance's cache, and the cache after a fresh initialize - the third is what makes it a security finding rather than a cache artefact. Case alone against f3ca33bf2: Test Files 1 failed | 9 passed (10), Tests 1 failed | 315 passed (316), with 'expected late-secret to be undefined' on the durable read. With the gate: 10 test files and 316 tests, 0 failed. Control, the gate condition disabled: 1 failed | 315 passed again, so the case measures the gate. Review row: database id 4478349061 (Major, Security & Privacy). --- src/core/config/ContextProxy.ts | 12 +++++ .../config/__tests__/ContextProxy.spec.ts | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 9d1ae3d3c0..e2a637fcd1 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -407,6 +407,9 @@ export class ContextProxy { * pairing a reset cannot tell a settled-but-orphaned write from a live one. */ private readonly pendingSecretWrites = new Map }>>() + // A reset is in progress: the caches are meaningless mid-reset, and a secret write begun + // inside it would commit after the delete pass and survive the erase. + private resetting = false /** How many writes of each secret were started. Unlike the owners, it is not cleared when a write is over. */ private readonly secretWriteCount = new Map() @@ -453,6 +456,13 @@ export class ContextProxy { } storeSecret(key: SecretStateKey, value?: string) { + if (this.resetting) { + // Refused rather than deferred: a deferred secret write would re-store exactly the + // value the user asked to erase, and a silently dropped one would lose the save. + return Promise.reject( + new SettingsWriteError([], [key], new Error("secret writes are refused while state is being reset")), + ) + } // Update cache. const owner = {} this.secretWriteOwner.set(key, owner) @@ -738,6 +748,7 @@ export class ContextProxy { * @returns A promise that resolves when all reset operations are complete */ public async resetAllState() { + this.resetting = true // A secret write that is in flight at this moment has already committed nothing, but it will // commit as soon as it settles, and the deletes below cannot un-commit a write that lands // after them. Wait for the pending writes first, then end the claims, then delete, so the @@ -761,6 +772,7 @@ export class ContextProxy { ]) await this.initialize() + this.resetting = false } private static _instance: ContextProxy | null = null diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index c31062c0b5..d2c0bf6e52 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -227,6 +227,56 @@ describe("ContextProxy", () => { expect(await mockSecrets.get("apiKey")).toBe("newer-secret") }) + it("erases a secret whose write begins after the reset has snapshotted the pending writes", async () => { + const durable = new Map() + let deleteCalls = 0 + let settleFirstDelete: () => void = () => {} + let markDeletePassStarted: () => void = () => {} + const deletePassStarted = new Promise((resolve) => { + markDeletePassStarted = resolve + }) + mockSecrets.get.mockImplementation(async (key: string) => durable.get(key)) + mockSecrets.store.mockImplementation(async (key: string, value: string) => { + durable.set(key, value) + }) + // Park the reset inside its own delete pass so a write can begin in the window the in-flight + // snapshot cannot cover. Only the first delete is held open; the rest settle normally, so + // releasing the park lets the reset finish. + mockSecrets.delete.mockImplementation((key: string) => { + deleteCalls++ + durable.delete(key) + if (deleteCalls === 1) { + return new Promise((resolve) => { + markDeletePassStarted() + settleFirstDelete = () => resolve(undefined) + }) + } + return Promise.resolve(undefined) + }) + + const reset = proxy.resetAllState() + await deletePassStarted + + // The reset has taken its in-flight snapshot, cleared both caches and dropped both write + // claims, and is standing inside the delete pass. This write is invisible to all of it. + const late = proxy.storeSecret("apiKey", "late-secret") + settleFirstDelete() + await reset + await late.catch(() => undefined) + + // All three reads matter. Durable storage is what a reset is supposed to empty, the cache is + // what this provider instance shows the user, and re-initializing is what a restarted + // provider does - which is how a secret that survived the reset reaches the user after the + // fact, so the middle read alone would understate the finding. + expect(await mockSecrets.get("apiKey")).toBeUndefined() + expect(proxy.getSecret("apiKey")).toBeUndefined() + await proxy.initialize() + expect(proxy.getSecret("apiKey")).toBeUndefined() + // The caller is told: a write the reset cannot honour is refused rather than dropped, so + // the save surfaces as a failure instead of vanishing. + await expect(late).rejects.toThrow() + }) + it("should bypass cache for pass-through state keys", async () => { // Setup mock return value mockGlobalState.get.mockReturnValue("pass-through-value") From 1d4f28ed558a4c354d02b6780c602f8f197027ce Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:06:01 +0800 Subject: [PATCH 47/51] fix(webview): keep activation ownership and buffered settings intact An activating save wrote the shared current-profile name through this.setValue, and that helper mirrors this view's buffer inside the same call. When the view's own durable write rejected, the mirror restored the shared name itself, and that restore advanced the shared-selection generation this activation was tracked against. The rollback then read its own undo as another view's write: the mode-binding and shared-selection restores were both skipped, setModeConfig ran once, and the mode stayed bound to the profile whose activation failed. The name write now goes straight to ContextProxy, so the per-view mirror is not inside the call whose failure is being rolled back. The same rollback restored only the buffered profile name, which left the failed profile's settings pinned to the restored name: getValues() overlays the buffer over the shared store, so the view served the previous name together with the new provider and model. The rollback now captures the buffered settings next to the buffered name and restores both. Both halves measured rather than argued, with a case in the rollback-ownership block each. - "restores this activation's mode binding when this view's durable write rejects": at the parent the expected generation 2 met an observed 3, ownsSharedSelection() was false, both restores logged a skip, and setModeConfig ran once with ("code","next-id"), leaving the mode bound to the failed profile. With the name write moved to ContextProxy the generation pair is unchanged, no restore reports another view's ownership, and setModeConfig runs a second time restoring ("code","prior-id"). - "restores this view's buffered settings when an activating save rolls back": at the parent the buffer kept currentApiConfigName "prior-profile" with apiConfiguration apiProvider "openrouter" and openRouterModelId "new-model", so getValues() paired A's name with B's settings and the skip log named the settings restore. With the capture the buffer and getValues() agree on the restored pair. Controls on this tree: reverting the name-write half reddens the first case only (386 passed, 1 failed of 387); reverting the settings-restore half reddens the second only (386/1); reverting both reddens both (385/2). With both edits the file runs 387 passed, 0 failed. Gates on this tree, in compile-job order: format:check clean; lint 12/12 with the src package uncached and re-run; check-types 11/11; test:code-qa-ci 7 pass 0 fail. The two solheim test groups fail on Windows alone - the SIGTERM teardown cases in scripts/solheim-smoke-workflow.test.mjs and a POSIX path literal in scripts/solheim-smoke/config.test.ts - and both files are byte-identical to upstream main and untouched here. solheim-smoke:check-types, test:agent-evals:unit, agent-evals:check-types, lifecycle:model-check, webview-recovery:check-types, webview-recovery:model-check (15 regressions rejected) and mcp:integration-check (16 advertised-grant combinations) are clean. eslint --max-warnings=0 on both touched files is clean and src/eslint-suppressions.json stays byte-identical to the parent blob (3301 suppressed counts, no increase). The core lane reports 194 files, 4259 passed | 9 skipped | 0 failed with no file collecting zero cases - a lane report, not the compile job. Declaration count in ClineProvider.spec.ts is 381 when .each, .skip and .only headers count with the cases (379 at the parent), against the 375 floor. Review rows answered: PRRT_kwDOSK-bwM6z_5os (ClineProvider.ts:2886) and PRRT_kwDOSK-bwM6z_5pK (ClineProvider.ts:2896). --- src/core/webview/ClineProvider.ts | 16 ++- .../webview/__tests__/ClineProvider.spec.ts | 121 ++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7486ddb8f3..93971e1fad 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2866,6 +2866,10 @@ export class ClineProvider const previousList = this.contextProxy.getValue("listApiConfigMeta") const previousName = this.contextProxy.getValue("currentApiConfigName") const previousViewName = this.viewLocalState.currentApiConfigName + // The buffered settings belong to the profile this view was running under. Restoring only the + // name would leave the failed profile's settings pinned to the restored name, which is the + // mismatched pair getValues() serves from the overlay. + const previousViewApiConfiguration = this.viewLocalState.apiConfiguration // This activation owns the shared selection until a newer write bumps the generation. const expectedNameGeneration = this.contextProxy.getStateWriteGeneration("currentApiConfigName") + 1 // The profile list needs its own ownership signal: a non-activating save from another view @@ -2883,7 +2887,12 @@ export class ClineProvider const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), // Shared write: adds/updates the profile list entry and the current-profile name. - this.setValue("currentApiConfigName", name), + // It goes straight to ContextProxy rather than through this.setValue: the per-view mirror + // inside setValue restores the shared name itself when this view's write rejects, and that + // restore advances the shared-selection generation this activation is tracked against, so the + // rollback below would read its own undo as another view's write and skip restoring this + // activation's mode binding, leaving the mode on the profile whose activation failed. + this.contextProxy.setValue("currentApiConfigName", name), this.providerSettingsManager.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), // setProviderSettings bypasses the buffer path; the mutation below refreshes this view. @@ -2933,7 +2942,10 @@ export class ClineProvider await this.contextProxy.setValue("listApiConfigMeta", previousList) }) await restore("view-local selection", () => - this._saveViewLocalStateFromMutation({ currentApiConfigName: previousViewName }), + this._saveViewLocalStateFromMutation({ + currentApiConfigName: previousViewName, + apiConfiguration: previousViewApiConfiguration, + }), ) await restore("mode binding", async () => { // A newer activation from another view re-pointed this mode: its binding wins. diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index acb71d7736..957af242a0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6975,6 +6975,127 @@ describe("ClineProvider", () => { }) await provider.dispose() }) + // The activation group's shared name write must not carry this view's per-view mirror with it. + it("restores this activation's mode binding when this view's durable write rejects", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry, nextEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + provider["viewLocalState"].currentApiConfigName = "prior-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("next-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) + vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + // The mode starts bound to the profile this activation replaces; by the time the rollback runs + // it points at this activation's profile, which is what the restore must recognise as its own. + vi.spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValueOnce("prior-id") + .mockResolvedValue("next-id") + const setModeConfig = vi + .spyOn(provider.providerSettingsManager, "setModeConfig") + .mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log") + // Only this view's durable write fails; every shared store lands. The seam stays open, so + // the rollback's own durable write fails with the same reason and the save reports both. + const contextProxy = provider.contextProxy + const updateGlobalState = contextProxy.updateGlobalState.bind(contextProxy) + vi.spyOn(contextProxy, "updateGlobalState").mockImplementation(async (key, value) => { + if (key === "viewStates") { + throw new Error("viewStates write failed") + } + return updateGlobalState(key, value) + }) + const setValue = contextProxy.setValue.bind(contextProxy) + vi.spyOn(contextProxy, "setValue").mockImplementation(async (key, value) => { + if (key === "viewStates") { + throw new Error("viewStates write failed") + } + return setValue(key, value) + }) + + await expect( + provider["persistProviderProfile"]( + "next-profile", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("viewStates write failed") + + // The failed per-view write used to restore the shared name through this.setValue, and that + // restore advanced the shared-selection generation this activation was tracked against. The + // mode-binding restore is gated on that generation, so the rollback read its own undo as + // another view's write and left the mode bound to the profile whose activation failed. + expect(setModeConfig).toHaveBeenNthCalledWith(1, "code", "next-id") + expect(setModeConfig).toHaveBeenNthCalledWith(2, "code", "prior-id") + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Skipping the mode-binding restore")) + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Skipping the shared-selection restore")) + expect(provider.getValues().currentApiConfigName).toBe("prior-profile") + await provider.dispose() + }) + + // A rollback must restore the settings the buffer held, not only the name it held. + it("restores this view's buffered settings when an activating save rolls back", async () => { + const provider = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const priorEntry = { name: "prior-profile", id: "prior-id", apiProvider: providerIdentifiers.anthropic } + const nextEntry = { name: "next-profile", id: "next-id", apiProvider: providerIdentifiers.openrouter } + await provider.contextProxy.setValue("listApiConfigMeta", [priorEntry, nextEntry]) + await provider.contextProxy.setValue("currentApiConfigName", "prior-profile") + provider["viewLocalState"].currentApiConfigName = "prior-profile" + provider["viewLocalState"].apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "prior-key", + } + vi.spyOn(provider.providerSettingsManager, "saveConfig").mockResolvedValue("next-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([priorEntry, nextEntry]) + vi.spyOn(provider, "getState").mockResolvedValue({ mode: "code" } as never) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("prior-id") + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + // The mirror mutation lands; the shared provider-settings write is what rejects. + vi.spyOn(provider.contextProxy, "setProviderSettings") + .mockRejectedValueOnce(new Error("provider settings write failed")) + .mockResolvedValue(undefined) + + await expect( + provider["persistProviderProfile"]( + "next-profile", + { apiProvider: providerIdentifiers.openrouter, openRouterModelId: "new-model" }, + true, + new AbortController().signal, + ), + ).rejects.toThrow("provider settings write failed") + + // Restoring only the name paired the restored profile with the settings of the profile whose + // activation failed: getValues() overlays the buffer over the shared store, so the view + // served the previous name together with the new provider and model. + expect(provider["viewLocalState"].currentApiConfigName).toBe("prior-profile") + expect(provider["viewLocalState"].apiConfiguration?.apiKey).toBe("prior-key") + expect(provider.getValues().currentApiConfigName).toBe("prior-profile") + expect(provider.getValues().apiProvider).toBe(providerIdentifiers.anthropic) + expect(provider["viewLocalState"].apiConfiguration?.apiProvider).toBe(providerIdentifiers.anthropic) + await provider.dispose() + }) + it("should undo this activation's mode binding and settings while it still owns them", async () => { const provider = new ClineProvider( mockContext, From 0a77cf89ebbc573ee4655d497a9ed90cac490152 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:36:33 +0800 Subject: [PATCH 48/51] test(webview): cover both halves of the sibling task-handler rebuild selector The rebuild that applies a saved profile's settings to a running task is keyed on the task's own profile name rather than on the view's pin (ClineProvider.ts:2897-2902), and the case at this file's :6365 that the review row pointed at covers the overlay refresh, not that selector. Two cases now cover it: a sibling whose task follows the saved profile, whose handler rebuild is asserted with the settings and with forceRebuild: true and exactly once, and a sibling whose task follows another profile, whose rebuild is asserted not to happen at all. The rebuild itself is stubbed so the case asserts the call and its options rather than the internals of a handler build; the one assertion-free cast is there because updateTaskApiHandlerIfNeeded is private and vi.spyOn needs the object and the key together, which the private-field alternative cannot give it. The profile-store doubles are spies on the public methods (saveConfig, listConfig, setModeConfig) rather than manager replacements, so this commit adds no suppression directive. Controls, run rather than described. Change forceRebuild: true to false at the call site and the matching case is the only failure in core/webview: Test Files 1 failed | 33 passed (34), Tests 1 failed | 955 passed (956), so the option is asserted rather than assumed. Comment the call line out and the same case is the only failure again, so the rebuild call is what makes it pass rather than the surrounding loop. The non-matching case stays green under both, which is its job. With production restored: Test Files 34 passed (34), Tests 956 passed (956); tsc -p . --noEmit exit 0, eslint --max-warnings=0 --no-warn-ignored on the file exit 0, prettier --check exit 0, and src/eslint-suppressions.json untouched. Test-only, and __tests__ is one of the mutation gate's excluded path segments (scripts/stryker-diff.mjs:85), so this adds nothing to the changed executable-line bucket. Review row: ClineProvider.spec.ts:6365 on this PR. --- .../webview/__tests__/ClineProvider.spec.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4a23c2cddd..12f0d925df 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6366,6 +6366,115 @@ describe("ClineProvider", () => { await saver.dispose() await pinned.dispose() }) + + // The task-handler rebuild is keyed on the task's own profile name rather than on the view's + // pin: a task restored from history can follow profile A while its view pins profile B, and only + // the matching side gets its handler rebuilt with forceRebuild (ClineProvider.ts:2897-2902). + // These two cases are the matching and non-matching halves of that branch, which is the coverage + // the review row on this file asked for. + it("rebuilds a sibling task's API handler with a forced rebuild when that task follows the saved profile", async () => { + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const runner = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // The rebuild itself is stubbed so the case asserts the call and its options rather than the + // internals of a handler build. The assertion-free cast is needed because + // updateTaskApiHandlerIfNeeded is private on ClineProvider and vi.spyOn needs the object and + // the key together; the private-field alternative of instance["name"] cannot be spied. + const task = new Task(defaultTaskOptions) + Object.defineProperty(task, "taskApiConfigName", { value: "shared-profile" }) + vi.spyOn(runner, "getCurrentTask").mockReturnValue(task) + vi.spyOn(saver, "getCurrentTask").mockReturnValue(undefined) + const rebuildTarget = runner as unknown as { + updateTaskApiHandlerIfNeeded: ( + settings: Record, + options?: { forceRebuild?: boolean }, + ) => void + } + const rebuild = vi.spyOn(rebuildTarget, "updateTaskApiHandlerIfNeeded").mockImplementation(() => {}) + vi.spyOn(saver.providerSettingsManager, "saveConfig").mockResolvedValue("shared-id") + vi.spyOn(saver.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "shared-profile", id: "shared-id" }, + ]) + vi.spyOn(saver.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + + await saver.upsertProviderProfile( + "shared-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" }, + false, + ) + + // Forced, because the saved settings can change with the provider and model both the same, + // and a rebuild that skipped on equality would leave the running task on the old client. + expect(rebuild).toHaveBeenCalledWith( + expect.objectContaining({ apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" }), + { forceRebuild: true }, + ) + expect(rebuild).toHaveBeenCalledTimes(1) + + await saver.dispose() + await runner.dispose() + vi.restoreAllMocks() + }) + + it("leaves a sibling task alone when that task follows a different profile than the one saved", async () => { + const saver = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + const runner = new ClineProvider( + mockContext, + mockOutputChannel, + "editor", + new ContextProxy(mockContext), + new WebviewFocusTracker(), + ) + // Same shape as the case above with one value changed: the sibling's task follows another + // profile, so its handler must keep the settings it was built with rather than inherit this + // save's settings. Its overlay refresh is a separate question and is covered by the two cases + // above this one; this case is only about the rebuild's own selector. + const task = new Task(defaultTaskOptions) + Object.defineProperty(task, "taskApiConfigName", { value: "other-profile" }) + vi.spyOn(runner, "getCurrentTask").mockReturnValue(task) + vi.spyOn(saver, "getCurrentTask").mockReturnValue(undefined) + const rebuildTarget = runner as unknown as { + updateTaskApiHandlerIfNeeded: ( + settings: Record, + options?: { forceRebuild?: boolean }, + ) => void + } + const rebuild = vi.spyOn(rebuildTarget, "updateTaskApiHandlerIfNeeded").mockImplementation(() => {}) + vi.spyOn(saver.providerSettingsManager, "saveConfig").mockResolvedValue("shared-id") + vi.spyOn(saver.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "shared-profile", id: "shared-id" }, + ]) + vi.spyOn(saver.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + + await saver.upsertProviderProfile( + "shared-profile", + { apiProvider: providerIdentifiers.anthropic, apiKey: "fresh-key" }, + false, + ) + + expect(rebuild).not.toHaveBeenCalled() + + await saver.dispose() + await runner.dispose() + vi.restoreAllMocks() + }) }) describe("saveViewState", () => { From ddda5ce5a5d352806cb36bf8b5608d02f5712ddc Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:36:46 +0800 Subject: [PATCH 49/51] test(webview): pin what each store holds when a registration write fails The existing case already proves the rolled-back id and the later retry. It did not say anything about what the two stores hold in the interval, which is the half that a compensating-write question turns on. Three assertions now pin it: the durable record still keys the entry under the temporary id with its mode intact, the stable key was never created durably, and the payload ContextProxy holds for viewStates is the same map the durable store holds - so a registration that was rolled back cannot be visible through one store and invisible through the other. Control for the new assertions, run rather than described: make the failing write partially durable - apply the value to globalState, then reject - and exactly one test in core/webview fails, at Test Files 1 failed | 33 passed (34), Tests 1 failed | 955 passed (956), and it is this case, so the pins have teeth. The removal control on the behaviour itself: delete the rollback line at ClineProvider.ts:973 and core/webview goes red at Test Files 1 failed | 33 passed (34), Tests 13 failed | 943 passed (956), thirteen cases, which is the rollback being load-bearing rather than this assertion being fussy. With production restored: 34 files, 956 tests, 0 failures; tsc exit 0; eslint --max-warnings=0 exit 0; prettier --check exit 0; suppressions untouched. Test-only, and excluded from the mutation bucket by scripts/stryker-diff.mjs:85. Note for whoever reads this next: this PR has no compensating write on the re-key path at all (rekeyPersistedViewStateEntry at ClineProvider.ts:813-857 issues one write and rolls nothing back), so these pins hold the interval on the shape that exists here rather than claiming the shape of #2226's fix, which is tracked separately on the board. --- src/core/webview/__tests__/ClineProvider.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 12f0d925df..a954e5896a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -5785,6 +5785,15 @@ describe("ClineProvider", () => { // guard early-returning for an id that was never persisted. expect(provider["viewStateId"]).toBe(provider.viewId) + // Pin what the failure left in each store: the durable record still keys the entry under + // the temporary id (the re-key wrote nothing durable), and the payload ContextProxy holds for + // viewStates is the same map the durable store holds, so a rolled-back registration cannot be + // visible through one store and invisible through the other. + const durableAfterFailure = mockContext.globalState.get("viewStates") as Record + expect(durableAfterFailure[provider.viewId]?.mode).toBe("architect") + expect(durableAfterFailure["stable-sidebar-view"]).toBeUndefined() + expect(provider.contextProxy.getValue("viewStates")).toEqual(durableAfterFailure) + // A later retry succeeds once the storage write works again, and the // pre-launch entry lands under the registered id. setValueSpy.mockRestore() From 48e9bcb39a9b16c948dce31eeae68c1be45bb991 Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:47:27 +0800 Subject: [PATCH 50/51] fix(config): clear the reset guard when the reset itself fails resetAllState() set `resetting` as its first statement and cleared it only on the success path, at the line after `initialize()`. The delete pass is a single `Promise.all` over every global-state key and every secret key, so one keychain `secrets.delete()` rejecting rejects the reset itself, and so does a rejecting `initialize()`. There was no third path: with the flag left set, `storeSecret()` refuses every later secret write with `SettingsWriteError` for the life of the instance, and `ClineProvider.resetState()` awaits `resetAllState()` with no catch, so the provider reset aborts on the same error and the user cannot save an API key until the extension host restarts. Nothing in the chain has a deadline, and the read-back deadline (`withinReadBackDeadline`, defined at ContextProxy.ts:52 and used once, at the write-rejection read-back) does not apply here. The body is wrapped rather than the two rejecting sites being patched individually so that a future await added inside the reset cannot reintroduce the leak; the guarantee being claimed is about the flag, not about the two sites that happen to reject today. No statement inside the reset moved, and the rejection still propagates unchanged - the case below asserts the original error rather than a wrapper, because a caller has to be able to tell a failed erase from a refused write. Test: `clears the reset guard when a secret delete rejects so the next save succeeds and reads back` - one delete rejecting, then the next `storeSecret()` asserted to resolve **and** to read back through both the cache and storage, because the defect is the store being unusable rather than an error not appearing. Control, run rather than described: revert this commit's production change and `vitest run core/config` reports `Test Files 1 failed | 9 passed (10)`, `Tests 1 failed | 316 passed (317)`, and the failure is that case. With the change back: `Test Files 10 passed (10)`, `Tests 317 passed (317)`. `tsc -p . --noEmit` exit 0, `eslint --max-warnings=0 --no-warn-ignored` on both files exit 0, `prettier --check` exit 0 (and the HEAD copies of both files pass the same invocation, so the formatting diff is mine), src/eslint-suppressions.json untouched. Mutation posture: this unit's bucket from the merge base is the extension package's 6 lines, and this commit moves it to roughly 40 (34 added / 28 deleted, almost all re-indentation) - well inside the 500-line cap the gate script enforces at scripts/stryker-diff.mjs:11. The spec half of the commit is excluded from the bucket by the `__tests__` segment at scripts/stryker-diff.mjs:85. Review rows answered: #2235 ContextProxy.ts:759 (tail 60BwCc) and the failure-path half of ContextProxy.ts:765 (tail 6z_5oh). The deadline option the second row raises is deliberately not taken here: a deadline whose expiry skips the deletes and rejects changes what a reset promises to its caller, which is a different contract than the one ClineProvider awaits today. It stays written up in the thread for a maintainer to choose. --- src/core/config/ContextProxy.ts | 62 ++++++++++--------- .../config/__tests__/ContextProxy.spec.ts | 29 +++++++++ 2 files changed, 63 insertions(+), 28 deletions(-) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 7be959362d..0d0a13e082 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -757,35 +757,41 @@ export class ContextProxy { */ public async resetAllState() { this.resetting = true - // A secret write that is in flight at this moment has already committed nothing, but it will - // commit as soon as it settles, and the deletes below cannot un-commit a write that lands - // after them. Wait for the pending writes first, then end the claims, then delete, so the - // cleared copy is the last one to land in storage and the cache stays empty afterwards. - const inFlight = [...this.pendingSecretWrites.values()].flatMap((writes) => [...writes].map((w) => w.settle)) - await Promise.allSettled(inFlight) - - // Clear in-memory caches - this.stateCache = {} - this.secretCache = {} - // A write that started before this reset must not restore its pre-reset value when it - // rejects afterwards, so advance every generation the pending writes still hold. - for (const key of this.stateWriteGenerations.keys()) { - this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1) - } - // A reset also ends write ownership: a write that is still in flight reads the Memento back - // into the cache it just lost, so clearing the caches is not enough to keep pre-reset state - // out. Ownership is what the rejection handlers gate on. - this.stateWriteOwner.clear() - this.secretWriteOwner.clear() - - await Promise.all([ - ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), - ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), - ...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)), - ]) + try { + // A secret write that is in flight at this moment has already committed nothing, but it will + // commit as soon as it settles, and the deletes below cannot un-commit a write that lands + // after them. Wait for the pending writes first, then end the claims, then delete, so the + // cleared copy is the last one to land in storage and the cache stays empty afterwards. + const inFlight = [...this.pendingSecretWrites.values()].flatMap((writes) => + [...writes].map((w) => w.settle), + ) + await Promise.allSettled(inFlight) + + // Clear in-memory caches + this.stateCache = {} + this.secretCache = {} + // A write that started before this reset must not restore its pre-reset value when it + // rejects afterwards, so advance every generation the pending writes still hold. + for (const key of this.stateWriteGenerations.keys()) { + this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1) + } + // A reset also ends write ownership: a write that is still in flight reads the Memento back + // into the cache it just lost, so clearing the caches is not enough to keep pre-reset state + // out. Ownership is what the rejection handlers gate on. + this.stateWriteOwner.clear() + this.secretWriteOwner.clear() - await this.initialize() - this.resetting = false + await Promise.all([ + ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), + ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), + ...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)), + ]) + + await this.initialize() + this.resetting = false + } finally { + this.resetting = false + } } private static _instance: ContextProxy | null = null diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 6a225203bd..452e5f75f0 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -195,6 +195,35 @@ describe("ContextProxy", () => { expect(proxy.getSecret("apiKey")).toBeUndefined() }) + // The delete pass is a single Promise.all over every key, so one keychain delete rejecting + // rejects resetAllState itself. Until now the only path on which the flag that guards + // storeSecret is cleared was the success path, so a single failed delete left every later + // secret write refused for the life of the instance. What is asserted below is that the + // store is usable again - the next save resolving and reading back through both the cache + // and storage - not merely that no error appears. + it("clears the reset guard when a secret delete rejects so the next save succeeds and reads back", async () => { + const durable = new Map() + mockSecrets.get.mockImplementation(async (key: string) => durable.get(key)) + mockSecrets.store.mockImplementation(async (key: string, value: string) => { + durable.set(key, value) + }) + mockSecrets.delete.mockImplementation(async (key: string) => { + if (key === "apiKey") { + throw new Error("keychain locked") + } + durable.delete(key) + }) + + // The original error is what propagates, not a wrapper: the caller has to be able to tell a + // failed erase from a refused write. + await expect(proxy.resetAllState()).rejects.toThrow("keychain locked") + + const after = proxy.storeSecret("apiKey", "written-after-failed-reset") + await expect(after).resolves.toBeUndefined() + expect(await mockSecrets.get("apiKey")).toBe("written-after-failed-reset") + expect(proxy.getSecret("apiKey")).toBe("written-after-failed-reset") + }) + it("adopts a secret write issued after a reset that waited for a pending write", async () => { const durable = new Map() let settle: () => void = () => {} From f95d1641ea0959745fc960961d05549bf3c7d34d Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:48:41 +0800 Subject: [PATCH 51/51] test(config): drop the matcherless expect.soft in the pending-secret reset case `expect.soft([...])` with an array argument and no matcher records nothing: a soft assertion is recorded only when a matcher runs on it. The six lines at 184-189 awaited two doubles, read a delete call count, built an array out of them and asserted none of it, so the case was asserting what it already asserts at lines 194-195 and the block above it read like coverage without carrying any. Control, run rather than described - this is the control a deletion of an assertion needs, since the honest claim is that nothing changes: `vitest run core/config` reads `Test Files 10 passed (10)`, `Tests 317 passed (317)` before the removal and `Test Files 10 passed (10)`, `Tests 317 passed (317)` after it, twice each, while the file's own counts move from 175 assertion calls (`expect(` plus `expect.soft(`) to 174 and stay flat at 66 `it()` declarations (a `expect(`-only count stays at 174 before and after, because the deleted call was `expect.soft(` and that pattern does not match it; the `expect.soft(` occurrences go 1 to 0). One assertion less, same outcome set, same case count. `prettier --check` exit 0, `eslint --max-warnings=0 --no-warn-ignored` exit 0, `tsc -p . --noEmit` exit 0, src/eslint-suppressions.json untouched. Test-only: `__tests__` is an excluded path segment at scripts/stryker-diff.mjs:85, so this adds nothing to the mutation bucket. The same dead call was copied onto every branch carrying these cases (vps2/n2-viewstates-durability at its line 181, vps2/n3-buffer-core at 184, and the vps2/n4-values-reset copy). This removes it where the review row lives, and the two forward merges carry it on. The n2 copy is left alone because that head is final for the evening and its merge owner has been asked not to merge that branch twice; the n4 copy will arrive with the merge rather than being patched separately. Review row answered: #2235 ContextProxy.spec.ts:188 (tail 6z_5oV). --- src/core/config/__tests__/ContextProxy.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 452e5f75f0..f9e9fc6a73 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -181,12 +181,6 @@ describe("ContextProxy", () => { setTimeout(settle, 0) await proxy.resetAllState() await pending - expect.soft([ - await mockSecrets.get("apiKey"), - proxy.getSecret("apiKey"), - mockSecrets.delete.mock.calls.length, - ]) - // The pending store is held open on a double that only puts the value into storage // when it settles, so the ordering is observable: the reset must wait for the write to // settle and then delete the keys, so neither the cache nor the durable copy keeps the