From 57e9032339daa61a0ad52ba59c523af9a032783e Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 1 Aug 2026 12:36:15 +0000 Subject: [PATCH 1/5] fix(provider): isolate profile mutations from focused tasks --- src/core/webview/ClineProvider.ts | 86 +++++++++++++-- .../ClineProvider.apiHandlerRebuild.spec.ts | 103 +++++++++++++++++- .../ClineProvider.lockApiConfig.spec.ts | 9 +- .../__tests__/api-configuration.spec.ts | 38 +++++++ src/extension/api.ts | 8 ++ 5 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 src/extension/__tests__/api-configuration.spec.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 912fed7837..385693d1bf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -196,11 +196,37 @@ export class ClineProvider private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + private providerProfileMutationQueue = Promise.resolve() private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { this.delegationTransitionLocks ??= new Map() return runDelegationTransition(this.delegationTransitionLocks, parentTaskId, fn) } + + private enqueueProviderProfileMutation(fn: () => Promise): Promise { + const run = this.providerProfileMutationQueue.then(fn, fn) + const callerResult = this.withProviderProfileMutationTimeout(run) + this.providerProfileMutationQueue = run.then( + () => undefined, + () => undefined, + ) + return callerResult + } + + private withProviderProfileMutationTimeout(operation: Promise): Promise { + let timeoutId: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("Provider profile mutation timed out")) + }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) + }) + + return Promise.race([operation, timeout]).finally(() => { + if (timeoutId) { + clearTimeout(timeoutId) + } + }) + } private readonly pendingEditOperations: PendingEditOperationStore private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null @@ -1506,9 +1532,15 @@ export class ClineProvider /** * Handle switching to a new mode, including updating the associated API configuration * @param newMode The mode to switch to + * @param targetTask The task whose in-memory mode should be updated. Defaults to the + * current task. Pass null to apply only global mode/profile effects for a pending child. */ - public async handleModeSwitch(newMode: Mode) { - const task = this.getCurrentTask() + public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { + return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask)) + } + + private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise { + const task = targetTask if (task) { TelemetryService.instance.captureModeSwitch(task.taskId, newMode) @@ -1545,7 +1577,9 @@ export class ClineProvider // If workspace lock is on, keep the current API config — don't load mode-specific config const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (lockApiConfigAcrossModes) { - await this.postStateToWebview() + if (targetTask !== null) { + await this.postStateToWebview() + } return } @@ -1571,7 +1605,10 @@ export class ClineProvider const hasActualSettings = !!fullProfile.apiProvider if (hasActualSettings) { - await this.activateProviderProfile({ name: profile.name }) + await this.activateProviderProfileUnlocked( + { name: profile.name }, + targetTask === null ? { skipCurrentTaskRebuild: true } : undefined, + ) } else { // The task will continue with the current/default configuration. } @@ -1591,7 +1628,9 @@ export class ClineProvider } } - await this.postStateToWebview() + if (targetTask !== null) { + await this.postStateToWebview() + } } // Provider Profile Management @@ -1607,8 +1646,9 @@ export class ClineProvider */ private updateTaskApiHandlerIfNeeded( providerSettings: ProviderSettings, - options: { forceRebuild?: boolean } = {}, + options: { forceRebuild?: boolean; skipCurrentTaskRebuild?: boolean } = {}, ): void { + if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() if (!task) return @@ -1724,7 +1764,11 @@ export class ClineProvider await this.postStateToWebview() } - private async persistStickyProviderProfileToCurrentTask(apiConfigName: string): Promise { + private async persistStickyProviderProfileToCurrentTask( + apiConfigName: string, + options: { skipCurrentTaskRebuild?: boolean } = {}, + ): Promise { + if (options.skipCurrentTaskRebuild) return const task = this.getCurrentTask() if (!task) { return @@ -1754,12 +1798,28 @@ export class ClineProvider async activateProviderProfile( args: { name: string } | { id: string }, - options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean }, + options?: { + persistModeConfig?: boolean + persistTaskHistory?: boolean + skipCurrentTaskRebuild?: boolean + }, ) { + return this.enqueueProviderProfileMutation(() => this.activateProviderProfileUnlocked(args, options)) + } + + private async activateProviderProfileUnlocked( + args: { name: string } | { id: string }, + options?: { + persistModeConfig?: boolean + persistTaskHistory?: boolean + skipCurrentTaskRebuild?: boolean + }, + ): Promise { const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true + const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ @@ -1775,17 +1835,19 @@ export class ClineProvider } // Change the provider for the current task. - this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) + this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true, skipCurrentTaskRebuild }) // Update the current task's sticky provider profile, unless this activation is // being used purely as a non-persisting restoration (e.g., reopening a task from history). if (persistTaskHistory) { - await this.persistStickyProviderProfileToCurrentTask(name) + await this.persistStickyProviderProfileToCurrentTask(name, { skipCurrentTaskRebuild }) } - await this.postStateToWebview() + if (!skipCurrentTaskRebuild) { + await this.postStateToWebview() + } - if (providerSettings.apiProvider) { + if (providerSettings.apiProvider && !skipCurrentTaskRebuild) { this.emit(RooCodeEventName.ProviderProfileChanged, { name, provider: providerSettings.apiProvider }) } } diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 3c777d0fc0..f37eb4cf2d 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -3,9 +3,10 @@ import * as vscode from "vscode" import { TelemetryService } from "@roo-code/telemetry" -import { getModelId } from "@roo-code/types" +import { getModelId, RooCodeEventName } from "@roo-code/types" import { ContextProxy } from "../../config/ContextProxy" +import type { Mode } from "../../../shared/modes" import { Task, TaskOptions } from "../../task/Task" import { ClineProvider } from "../ClineProvider" @@ -110,6 +111,7 @@ vi.mock("../../task/Task", () => ({ overwriteApiConversationHistory: vi.fn(), taskId: options?.historyItem?.id || "test-task-id", emit: vi.fn(), + setTaskApiConfigName: vi.fn(), updateApiConfiguration: vi.fn().mockImplementation(function (this: any, newConfig: any) { this.apiConfiguration = newConfig }), @@ -235,6 +237,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { { name: "test-config", id: "test-id", apiProvider: "openrouter", modelId: "openai/gpt-4" }, ]), setModeConfig: vi.fn(), + getModeConfigId: vi.fn().mockResolvedValue(undefined), activateProfile: vi.fn().mockResolvedValue({ name: "test-config", id: "test-id", @@ -410,6 +413,104 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) describe("activateProviderProfile", () => { + test("serializes provider profile mutations without interleaving", async () => { + const events: string[] = [] + let resolveFirst!: () => void + + provider["providerSettingsManager"].activateProfile = vi + .fn() + .mockImplementationOnce(async () => { + events.push("first:start") + await new Promise((resolve) => { + resolveFirst = resolve + }) + events.push("first:end") + return { + name: "first-profile", + id: "first-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4", + } + }) + .mockImplementationOnce(async () => { + events.push("second:start") + return { + name: "second-profile", + id: "second-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4.1-mini", + } + }) + + const first = provider.activateProviderProfile({ name: "first-profile" }) + const second = provider.activateProviderProfile({ name: "second-profile" }) + + await Promise.resolve() + expect(events).toEqual(["first:start"]) + + resolveFirst() + await first + await second + + expect(events).toEqual(["first:start", "first:end", "second:start"]) + }) + + test("provider profile mutation rejection does not poison later queued mutations", async () => { + const firstError = new Error("first profile failed") + + provider["providerSettingsManager"].activateProfile = vi + .fn() + .mockRejectedValueOnce(firstError) + .mockResolvedValueOnce({ + name: "second-profile", + id: "second-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4.1-mini", + }) + + await expect(provider.activateProviderProfile({ name: "first-profile" })).rejects.toThrow(firstError) + await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined() + }) + + test("fan-out preparation leaves the focused task untouched", async () => { + const mockTask = new Task({ + ...defaultTaskOptions, + apiConfiguration: { + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4", + }, + }) + await provider.addClineToStack(mockTask) + provider["providerSettingsManager"].getModeConfigId = vi.fn().mockResolvedValue("ask-id") + provider["providerSettingsManager"].listConfig = vi + .fn() + .mockResolvedValue([{ name: "ask-profile", id: "ask-id", apiProvider: "openrouter" }]) + provider["providerSettingsManager"].getProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4.1-mini", + }) + provider["providerSettingsManager"].activateProfile = vi.fn().mockResolvedValue({ + name: "ask-profile", + id: "ask-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4.1-mini", + }) + const emitSpy = vi.spyOn(provider, "emit") + const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.handleModeSwitch("ask" as Mode, null) + + expect(mockTask.updateApiConfiguration).not.toHaveBeenCalled() + expect(mockTask.setTaskApiConfigName).not.toHaveBeenCalled() + expect(emitSpy).not.toHaveBeenCalledWith( + RooCodeEventName.ProviderProfileChanged, + expect.objectContaining({ name: "ask-profile" }), + ) + expect(postStateSpy).not.toHaveBeenCalled() + }) + test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { const mockTask = new Task({ ...defaultTaskOptions, diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 49d181ed36..07e6b82a64 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -374,14 +374,15 @@ describe("ClineProvider - Lock API Config Across Modes", () => { apiProvider: "anthropic", }) - const activateProviderProfileSpy = vi - .spyOn(provider, "activateProviderProfile") - .mockResolvedValue(undefined) + const activateProfileSpy = vi.spyOn(provider.providerSettingsManager, "activateProfile").mockResolvedValue({ + name: "architect-profile", + apiProvider: "anthropic", + }) await provider.handleModeSwitch("architect") expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect") - expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" }) + expect(activateProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" }) }) }) }) diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts new file mode 100644 index 0000000000..fed44894dc --- /dev/null +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest" +import type * as vscode from "vscode" + +import { API } from "../api" +import type { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("@roo-code/ipc", () => ({ + IpcServer: class {}, +})) + +describe("API - configuration", () => { + it("persists every supplied mode API config mapping", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { setValues }, + providerSettingsManager: { saveConfig, setModeConfig }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider) + + await api.setConfiguration({ + currentApiConfigName: "default", + modeApiConfigs: { code: "code-config", architect: "architect-config" }, + }) + + expect(saveConfig).toHaveBeenCalledWith("default", expect.objectContaining({ currentApiConfigName: "default" })) + expect(setModeConfig).toHaveBeenCalledTimes(2) + expect(setModeConfig).toHaveBeenCalledWith("code", "code-config") + expect(setModeConfig).toHaveBeenCalledWith("architect", "architect-config") + expect(postStateToWebview).toHaveBeenCalledOnce() + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 2d0d5a6975..b57dc89b74 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -23,6 +23,7 @@ import { import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" +import type { Mode } from "../shared/modes" import { ClineProvider } from "../core/webview/ClineProvider" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" @@ -505,6 +506,13 @@ export class API extends EventEmitter implements RooCodeAPI { public async setConfiguration(values: RooCodeSettings) { await this.sidebarProvider.contextProxy.setValues(values) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) + if (values.modeApiConfigs) { + await Promise.all( + Object.entries(values.modeApiConfigs).map(([mode, configId]) => + this.sidebarProvider.providerSettingsManager.setModeConfig(mode as Mode, configId), + ), + ) + } await this.sidebarProvider.postStateToWebview() } From bb936bee8adfb0a49f13a556905af9ce27acb83c Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 1 Aug 2026 15:14:44 +0000 Subject: [PATCH 2/5] test(e2e): restore provider profile after cross-profile subtasks --- apps/vscode-e2e/src/suite/subtasks.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 0c0d410f90..02d3dfe487 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -660,7 +660,8 @@ suite("Roo Code Subtasks", function () { ...parentProfile, openRouterModelId: "openai/gpt-4.1-mini", } - const priorModeApiConfigs = api.getConfiguration().modeApiConfigs ?? {} + const priorConfiguration = api.getConfiguration() + const priorActiveProfile = api.getActiveProfile() const parentProfileId = await api.upsertProfile("subtask-parent-profile", parentProfile, true) const childProfileId = await api.upsertProfile("subtask-child-profile", childProfile, false) await api.setConfiguration({ @@ -735,7 +736,10 @@ suite("Roo Code Subtasks", function () { ) } finally { api.off(RooCodeEventName.Message, messageHandler) - await api.setConfiguration({ modeApiConfigs: priorModeApiConfigs }) + await api.setConfiguration(priorConfiguration) + if (priorActiveProfile) { + await api.setActiveProfile(priorActiveProfile) + } await api.deleteProfile("subtask-child-profile").catch(() => {}) await api.deleteProfile("subtask-parent-profile").catch(() => {}) while (api.getCurrentTaskStack().length > 0) { From 8273a44c38846d8c68dd28a5bf25c775e9895c65 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 1 Aug 2026 16:51:30 +0000 Subject: [PATCH 3/5] fix(provider): recover timed-out profile mutations --- src/core/webview/ClineProvider.ts | 51 ++++++++++++---- .../ClineProvider.apiHandlerRebuild.spec.ts | 61 +++++++++++++++++++ 2 files changed, 101 insertions(+), 11 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 385693d1bf..03794f7c75 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -205,18 +205,43 @@ export class ClineProvider private enqueueProviderProfileMutation(fn: () => Promise): Promise { const run = this.providerProfileMutationQueue.then(fn, fn) - const callerResult = this.withProviderProfileMutationTimeout(run) - this.providerProfileMutationQueue = run.then( + let timedOut = false + const callerResult = this.withProviderProfileMutationTimeout(run, () => { + timedOut = true + this.log("Provider profile mutation timed out; releasing the mutation queue") + }) + + void run.then( + () => { + if (timedOut) { + this.log("Provider profile mutation completed after timing out") + } + }, + (error) => { + if (timedOut) { + this.log( + `Provider profile mutation failed after timing out: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + }, + ) + + // Advance from the timeout-bounded caller result, rather than the raw operation. A + // provider call that never settles must not block all subsequent profile changes. + this.providerProfileMutationQueue = callerResult.then( () => undefined, () => undefined, ) return callerResult } - private withProviderProfileMutationTimeout(operation: Promise): Promise { + private withProviderProfileMutationTimeout(operation: Promise, onTimeout: () => void): Promise { let timeoutId: ReturnType | undefined const timeout = new Promise((_, reject) => { timeoutId = setTimeout(() => { + onTimeout() reject(new Error("Provider profile mutation timed out")) }, ClineProvider.PENDING_OPERATION_TIMEOUT_MS) }) @@ -1535,8 +1560,10 @@ export class ClineProvider * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. */ - public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { - return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask)) + public async handleModeSwitch(newMode: Mode, targetTask?: Task | null) { + return this.enqueueProviderProfileMutation(() => + this.handleModeSwitchUnlocked(newMode, targetTask === undefined ? this.getCurrentTask() : targetTask), + ) } private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise { @@ -1821,12 +1848,14 @@ export class ClineProvider const persistTaskHistory = options?.persistTaskHistory ?? true const skipCurrentTaskRebuild = options?.skipCurrentTaskRebuild ?? false - // See `upsertProviderProfile` for a description of what this is doing. - await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), - this.contextProxy.setProviderSettings(providerSettings), - ]) + if (!skipCurrentTaskRebuild) { + // See `upsertProviderProfile` for a description of what this is doing. + await Promise.all([ + this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.contextProxy.setValue("currentApiConfigName", name), + this.contextProxy.setProviderSettings(providerSettings), + ]) + } const { mode } = await this.getState() diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index f37eb4cf2d..8888d937d2 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -472,6 +472,63 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined() }) + test("provider profile mutation timeout releases later queued mutations", async () => { + vi.useFakeTimers() + const logSpy = vi.spyOn(provider, "log") + provider["providerSettingsManager"].activateProfile = vi + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce({ + name: "second-profile", + id: "second-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4.1-mini", + }) + + try { + const first = provider.activateProviderProfile({ name: "first-profile" }) + const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out") + await vi.advanceTimersByTimeAsync(30_000) + await firstResult + + await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined() + expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; releasing the mutation queue") + } finally { + vi.useRealTimers() + } + }) + + test("mode switch resolves its default task when its queued mutation starts", async () => { + let releaseProfileActivation!: () => void + const profileActivation = new Promise((resolve) => { + releaseProfileActivation = resolve + }) + provider["providerSettingsManager"].activateProfile = vi.fn().mockImplementationOnce(async () => { + await profileActivation + return { + name: "first-profile", + id: "first-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4", + } + }) + + const firstTask = new Task(defaultTaskOptions) + const secondTask = new Task(defaultTaskOptions) + await provider.addClineToStack(firstTask) + + const profileSwitch = provider.activateProviderProfile({ name: "first-profile" }) + const modeSwitch = provider.handleModeSwitch("ask" as Mode) + await provider.addClineToStack(secondTask) + + releaseProfileActivation() + await profileSwitch + await modeSwitch + + expect((firstTask as unknown as { _taskMode?: Mode })._taskMode).not.toBe("ask") + expect((secondTask as unknown as { _taskMode?: Mode })._taskMode).toBe("ask") + }) + test("fan-out preparation leaves the focused task untouched", async () => { const mockTask = new Task({ ...defaultTaskOptions, @@ -499,6 +556,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { }) const emitSpy = vi.spyOn(provider, "emit") const postStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") + const setProviderSettingsSpy = vi.spyOn(provider.contextProxy, "setProviderSettings") await provider.handleModeSwitch("ask" as Mode, null) @@ -509,6 +568,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect.objectContaining({ name: "ask-profile" }), ) expect(postStateSpy).not.toHaveBeenCalled() + expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "ask-profile") + expect(setProviderSettingsSpy).not.toHaveBeenCalled() }) test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { From 6a03068c9bb530ded0ac1e6a753ec2df4d4dc1b7 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 1 Aug 2026 17:34:53 +0000 Subject: [PATCH 4/5] test(provider): target queued mode switches explicitly --- .../ClineProvider.apiHandlerRebuild.spec.ts | 4 ++-- .../__tests__/ClineProvider.sticky-mode.spec.ts | 17 +++++------------ src/eslint-suppressions.json | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 8888d937d2..3d1966cdf1 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -525,8 +525,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await profileSwitch await modeSwitch - expect((firstTask as unknown as { _taskMode?: Mode })._taskMode).not.toBe("ask") - expect((secondTask as unknown as { _taskMode?: Mode })._taskMode).toBe("ask") + expect(firstTask["_taskMode"]).not.toBe("ask") + expect(secondTask["_taskMode"]).toBe("ask") }) test("fan-out preparation leaves the focused task untouched", async () => { diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 4dd33404d8..e6d8c9325f 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -1155,18 +1155,11 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve([]) }) - // Mock getCurrentTask to return different tasks - const getCurrentTaskSpy = vi.spyOn(provider, "getCurrentTask") - - // Simulate simultaneous mode switches for different tasks - getCurrentTaskSpy.mockReturnValue(task1 as any) - const switch1 = provider.handleModeSwitch("architect") - - getCurrentTaskSpy.mockReturnValue(task2 as any) - const switch2 = provider.handleModeSwitch("debug") - - getCurrentTaskSpy.mockReturnValue(task3 as any) - const switch3 = provider.handleModeSwitch("code") + // Simulate simultaneous mode switches for distinct, explicitly targeted tasks. + // These lightweight task doubles only implement the members exercised by this test. + const switch1 = provider.handleModeSwitch("architect", task1 as unknown as Task) + const switch2 = provider.handleModeSwitch("debug", task2 as unknown as Task) + const switch3 = provider.handleModeSwitch("code", task3 as unknown as Task) await Promise.all([switch1, switch2, switch3]) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 608e190d04..eef02fcecd 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1091,7 +1091,7 @@ }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 40 + "count": 37 } }, "core/webview/__tests__/ClineProvider.sticky-profile.spec.ts": { From 71ba421f154ec87b5beaedb2b28b0871f850d6ff Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 1 Aug 2026 18:27:13 +0000 Subject: [PATCH 5/5] fix(provider): preserve queued profile mutation ordering --- src/core/webview/ClineProvider.ts | 18 ++++---- .../ClineProvider.apiHandlerRebuild.spec.ts | 43 +++++++++++++++---- .../__tests__/api-configuration.spec.ts | 27 ++++++++++++ 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 03794f7c75..f0993a6f6e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -195,7 +195,7 @@ export class ClineProvider private taskHistoryStoreInitialized = false private globalStateWriteThroughTimer: ReturnType | null = null private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds - private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds + public static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds private providerProfileMutationQueue = Promise.resolve() private runDelegationTransition(parentTaskId: string, fn: () => Promise): Promise { @@ -204,11 +204,12 @@ export class ClineProvider } private enqueueProviderProfileMutation(fn: () => Promise): Promise { + // Run after either outcome so a rejected mutation never poisons the queue. const run = this.providerProfileMutationQueue.then(fn, fn) let timedOut = false const callerResult = this.withProviderProfileMutationTimeout(run, () => { timedOut = true - this.log("Provider profile mutation timed out; releasing the mutation queue") + this.log("Provider profile mutation timed out; waiting for the in-flight mutation to settle") }) void run.then( @@ -228,9 +229,9 @@ export class ClineProvider }, ) - // Advance from the timeout-bounded caller result, rather than the raw operation. A - // provider call that never settles must not block all subsequent profile changes. - this.providerProfileMutationQueue = callerResult.then( + // Keep the raw operation as the queue boundary. Releasing the queue on timeout + // would allow its later state writes to overwrite a subsequent mutation. + this.providerProfileMutationQueue = run.then( () => undefined, () => undefined, ) @@ -252,6 +253,7 @@ export class ClineProvider } }) } + private readonly pendingEditOperations: PendingEditOperationStore private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null @@ -1560,10 +1562,8 @@ export class ClineProvider * @param targetTask The task whose in-memory mode should be updated. Defaults to the * current task. Pass null to apply only global mode/profile effects for a pending child. */ - public async handleModeSwitch(newMode: Mode, targetTask?: Task | null) { - return this.enqueueProviderProfileMutation(() => - this.handleModeSwitchUnlocked(newMode, targetTask === undefined ? this.getCurrentTask() : targetTask), - ) + public async handleModeSwitch(newMode: Mode, targetTask: Task | null | undefined = this.getCurrentTask()) { + return this.enqueueProviderProfileMutation(() => this.handleModeSwitchUnlocked(newMode, targetTask)) } private async handleModeSwitchUnlocked(newMode: Mode, targetTask: Task | null | undefined): Promise { diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 3d1966cdf1..ffdfc3e6c0 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -457,6 +457,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { test("provider profile mutation rejection does not poison later queued mutations", async () => { const firstError = new Error("first profile failed") + const setValueSpy = vi.spyOn(provider.contextProxy, "setValue") provider["providerSettingsManager"].activateProfile = vi .fn() @@ -470,14 +471,27 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await expect(provider.activateProviderProfile({ name: "first-profile" })).rejects.toThrow(firstError) await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined() + expect(setValueSpy).toHaveBeenCalledWith("currentApiConfigName", "second-profile") }) - test("provider profile mutation timeout releases later queued mutations", async () => { + test("timed-out provider profile mutations retain the queue boundary until they settle", async () => { vi.useFakeTimers() const logSpy = vi.spyOn(provider, "log") + let resolveFirst!: () => void + const firstActivation = new Promise((resolve) => { + resolveFirst = resolve + }) provider["providerSettingsManager"].activateProfile = vi .fn() - .mockImplementationOnce(() => new Promise(() => {})) + .mockImplementationOnce(async () => { + await firstActivation + return { + name: "first-profile", + id: "first-id", + apiProvider: "openrouter", + openRouterModelId: "openai/gpt-4", + } + }) .mockResolvedValueOnce({ name: "second-profile", id: "second-id", @@ -488,17 +502,24 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { try { const first = provider.activateProviderProfile({ name: "first-profile" }) const firstResult = expect(first).rejects.toThrow("Provider profile mutation timed out") - await vi.advanceTimersByTimeAsync(30_000) + await vi.advanceTimersByTimeAsync(ClineProvider.PENDING_OPERATION_TIMEOUT_MS) await firstResult - await expect(provider.activateProviderProfile({ name: "second-profile" })).resolves.toBeUndefined() - expect(logSpy).toHaveBeenCalledWith("Provider profile mutation timed out; releasing the mutation queue") + const second = provider.activateProviderProfile({ name: "second-profile" }) + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(1) + + resolveFirst() + await expect(second).resolves.toBeUndefined() + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledTimes(2) + expect(logSpy).toHaveBeenCalledWith( + "Provider profile mutation timed out; waiting for the in-flight mutation to settle", + ) } finally { vi.useRealTimers() } }) - test("mode switch resolves its default task when its queued mutation starts", async () => { + test("mode switch preserves its default task when queued behind a profile mutation", async () => { let releaseProfileActivation!: () => void const profileActivation = new Promise((resolve) => { releaseProfileActivation = resolve @@ -515,6 +536,10 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { const firstTask = new Task(defaultTaskOptions) const secondTask = new Task(defaultTaskOptions) + Object.defineProperty(firstTask, "taskId", { value: "first-task-id" }) + Object.defineProperty(secondTask, "taskId", { value: "second-task-id" }) + firstTask["_taskMode"] = "code" as Mode + secondTask["_taskMode"] = "code" as Mode await provider.addClineToStack(firstTask) const profileSwitch = provider.activateProviderProfile({ name: "first-profile" }) @@ -525,8 +550,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { await profileSwitch await modeSwitch - expect(firstTask["_taskMode"]).not.toBe("ask") - expect(secondTask["_taskMode"]).toBe("ask") + expect(firstTask["_taskMode"]).toBe("ask") + expect(secondTask["_taskMode"]).toBe("code") }) test("fan-out preparation leaves the focused task untouched", async () => { @@ -570,6 +595,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { expect(postStateSpy).not.toHaveBeenCalled() expect(setValueSpy).not.toHaveBeenCalledWith("currentApiConfigName", "ask-profile") expect(setProviderSettingsSpy).not.toHaveBeenCalled() + expect(emitSpy).toHaveBeenCalledWith(RooCodeEventName.ModeChanged, "ask") + expect(provider["providerSettingsManager"].activateProfile).toHaveBeenCalledWith({ name: "ask-profile" }) }) test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => { diff --git a/src/extension/__tests__/api-configuration.spec.ts b/src/extension/__tests__/api-configuration.spec.ts index fed44894dc..80f8cad9cd 100644 --- a/src/extension/__tests__/api-configuration.spec.ts +++ b/src/extension/__tests__/api-configuration.spec.ts @@ -30,9 +30,36 @@ describe("API - configuration", () => { }) expect(saveConfig).toHaveBeenCalledWith("default", expect.objectContaining({ currentApiConfigName: "default" })) + expect(setValues).toHaveBeenCalledWith( + expect.objectContaining({ + currentApiConfigName: "default", + modeApiConfigs: expect.anything(), + }), + ) expect(setModeConfig).toHaveBeenCalledTimes(2) expect(setModeConfig).toHaveBeenCalledWith("code", "code-config") expect(setModeConfig).toHaveBeenCalledWith("architect", "architect-config") expect(postStateToWebview).toHaveBeenCalledOnce() }) + + it("does not persist mode mappings when none are supplied", async () => { + const setValues = vi.fn().mockResolvedValue(undefined) + const saveConfig = vi.fn().mockResolvedValue("default-id") + const setModeConfig = vi.fn().mockResolvedValue(undefined) + const postStateToWebview = vi.fn().mockResolvedValue(undefined) + const provider = { + context: {}, + on: vi.fn(), + contextProxy: { setValues }, + providerSettingsManager: { saveConfig, setModeConfig }, + postStateToWebview, + } as unknown as ClineProvider + const outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel + const api = new API(outputChannel, provider) + + await api.setConfiguration({ currentApiConfigName: "default" }) + + expect(setModeConfig).not.toHaveBeenCalled() + expect(postStateToWebview).toHaveBeenCalledOnce() + }) })