From ce0fb202a742c55cf062766680289adb8ea139ca Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Tue, 20 Oct 2026 21:41:11 +0200 Subject: [PATCH 1/3] fix(profiles): keep mode profile choices per window The mode -> provider profile map lives in shared extension storage, so a profile selected in one VS Code window leaked into subtasks and mode switches in other windows. Prefer this window's own choices and fall back to the shared map. Fixes #2109 Co-Authored-By: Claude Sonnet 5.5 --- src/core/webview/ClineProvider.ts | 28 +++++++++++++++---- .../webview/__tests__/ClineProvider.spec.ts | 15 ++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 28a42913f3..60806b9acd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -357,6 +357,13 @@ export class ClineProvider public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager + /** + * Mode -> profile id choices made by this window. The persisted mode map in + * `providerSettingsManager` is shared by every VS Code window, so reading it + * alone lets another window's profile selection leak into this window's tasks. + */ + private readonly localModeConfigIds = new Map() + constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, @@ -1273,7 +1280,7 @@ export class ClineProvider const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) { - const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) + const savedConfigId = await this.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data. @@ -1775,7 +1782,7 @@ export class ClineProvider if (signal?.aborted) return // Load the saved API config for the new mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const savedConfigId = await this.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() if (signal?.aborted) return @@ -1817,7 +1824,7 @@ export class ClineProvider const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) if (config?.id) { - await this.providerSettingsManager.setModeConfig(newMode, config.id) + await this.setModeConfig(newMode, config.id) } } } @@ -1829,6 +1836,15 @@ export class ClineProvider // Provider Profile Management + private async getModeConfigId(mode: Mode): Promise { + return this.localModeConfigIds.get(mode) ?? (await this.providerSettingsManager.getModeConfigId(mode)) + } + + private async setModeConfig(mode: Mode, id: string): Promise { + this.localModeConfigIds.set(mode, id) + await this.providerSettingsManager.setModeConfig(mode, id) + } + /** * Updates the current task's API handler. * Rebuilds when: @@ -2034,7 +2050,7 @@ export class ClineProvider const activationResults = await Promise.allSettled([ this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), this.updateGlobalState("currentApiConfigName", name), - this.providerSettingsManager.setModeConfig(mode, id), + this.setModeConfig(mode, id), this.contextProxy.setProviderSettings(providerSettings), ]) const failedActivation = activationResults.find( @@ -2156,7 +2172,7 @@ export class ClineProvider const { mode } = await this.getState() if (id && persistModeConfig) { - await this.providerSettingsManager.setModeConfig(mode, id) + await this.setModeConfig(mode, id) } // Change the provider for the current task. @@ -4007,7 +4023,7 @@ export class ClineProvider mode !== parentMode && this.context.workspaceState.get("lockApiConfigAcrossModes", false) let savedModeProfile: { name?: string; apiConfiguration: ProviderSettings } | undefined if (mode !== parentMode && !lockApiConfigAcrossModes) { - const savedConfigId = await this.providerSettingsManager.getModeConfigId(mode as Mode) + const savedConfigId = await this.getModeConfigId(mode as Mode) if (savedConfigId) { try { const { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 93543435e2..4da68d7904 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2447,6 +2447,21 @@ describe("ClineProvider", () => { expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") }) + it("prefers this window's mode profile over one another window saved in shared storage", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const manager = provider.providerSettingsManager + vi.spyOn(manager, "getModeConfigId").mockResolvedValue("other-window-id") + vi.spyOn(manager, "setModeConfig").mockResolvedValue(undefined) + + await provider["setModeConfig"]("code", "this-window-id") + + expect(manager.setModeConfig).toHaveBeenCalledWith("code", "this-window-id") + await expect(provider["getModeConfigId"]("code")).resolves.toBe("this-window-id") + // A mode this window never chose still falls back to the shared value. + await expect(provider["getModeConfigId"]("architect")).resolves.toBe("other-window-id") + }) + it("saves current config when switching to mode without config", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] From 2724c8fef1557e9dfd877a8e8cc0edbb49e6847f Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Tue, 20 Oct 2026 21:56:29 +0200 Subject: [PATCH 2/3] test: give delegation test doubles the window-local mode lookup Co-Authored-By: Claude Sonnet 5.5 --- src/__tests__/ClineProvider.delegation.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 68f39c6517..fb5a5532b0 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -295,6 +295,8 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { isViewLaunched: false, taskHistoryStore: makeStoreStub(), providerSettingsManager, + // No window-local choice is recorded, so the lookup reaches the shared manager. + getModeConfigId: (mode: string) => providerSettingsManager.getModeConfigId(mode), context: { workspaceState: { get: workspaceGet } }, } as unknown as ClineProvider @@ -347,6 +349,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ? vi.fn().mockRejectedValue(savedProfile) : vi.fn().mockResolvedValue(savedProfile) const log = vi.fn() + const getModeConfigId = vi.fn().mockResolvedValue(savedConfigId) const provider = { taskScheduler: new TaskScheduler(), emit: vi.fn(), @@ -356,10 +359,9 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { log, isViewLaunched: false, taskHistoryStore: makeStoreStub(), - providerSettingsManager: { - getModeConfigId: vi.fn().mockResolvedValue(savedConfigId), - getProfile, - }, + providerSettingsManager: { getModeConfigId, getProfile }, + // No window-local choice is recorded, so the lookup reaches the shared manager. + getModeConfigId, context: { workspaceState: { get: vi.fn().mockReturnValue(false) } }, } as unknown as ClineProvider From a4e06d411ae755671d951911d07298d1991668fa Mon Sep 17 00:00:00 2001 From: PierrunoYT Date: Sun, 25 Oct 2026 11:09:10 +0100 Subject: [PATCH 3/3] fix(webview): snapshot mode profile choices per window and clear them on reset Co-Authored-By: Claude Sonnet 5.5 --- .../ClineProvider.delegation.spec.ts | 47 +++++++++++++++++ src/core/config/ProviderSettingsManager.ts | 15 ++++++ .../__tests__/ProviderSettingsManager.spec.ts | 35 +++++++++++++ src/core/webview/ClineProvider.ts | 25 +++++++++- .../ClineProvider.apiHandlerRebuild.spec.ts | 2 + .../ClineProvider.lockApiConfig.spec.ts | 2 + .../webview/__tests__/ClineProvider.spec.ts | 50 +++++++++++++++++++ 7 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index fb5a5532b0..7e8fc604fe 100644 --- a/src/__tests__/ClineProvider.delegation.spec.ts +++ b/src/__tests__/ClineProvider.delegation.spec.ts @@ -328,6 +328,53 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { ) }) + it("uses this window's mode profile instead of one another window saved in shared storage", async () => { + const parentTask = makeParentTask() + const createTask = vi + .fn() + .mockResolvedValue({ taskId: "child-local", run: vi.fn().mockResolvedValue(undefined) }) + const getProfile = vi.fn().mockResolvedValue({ + name: "local-profile", + id: "local-profile-id", + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "openai/gpt-4.1-mini", + }) + const sharedGetModeConfigId = vi.fn().mockResolvedValue("other-window-profile-id") + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + log: vi.fn(), + isViewLaunched: false, + taskHistoryStore: makeStoreStub(), + providerSettingsManager: { getModeConfigId: sharedGetModeConfigId, getProfile }, + // This window's own choice differs from the shared value another window wrote. + getModeConfigId: vi.fn().mockResolvedValue("local-profile-id"), + context: { workspaceState: { get: vi.fn().mockReturnValue(false) } }, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Local child", + initialTodos: [], + mode: "ask", + }) + + expect(getProfile).toHaveBeenCalledOnce() + expect(getProfile).toHaveBeenCalledWith({ id: "local-profile-id" }) + expect(sharedGetModeConfigId).not.toHaveBeenCalled() + expect(createTask).toHaveBeenCalledWith( + "Local child", + undefined, + parentTask, + expect.objectContaining({ + handoffExecutionContext: expect.objectContaining({ mode: "ask", apiConfigName: "local-profile" }), + }), + ) + }) + it.each([ { name: "has no saved mode profile", savedConfigId: undefined, savedProfile: undefined }, { diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 01695d7994..a355b2ff01 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -574,6 +574,21 @@ export class ProviderSettingsManager { } } + /** + * Get a copy of the persisted mode -> API config ID map. The map is shared by every VS Code + * window, so callers that need a stable view must keep their own copy. + */ + public async getModeConfigIds(): Promise> { + try { + return await this.lock(async () => { + const { modeApiConfigs } = await this.load() + return { ...modeApiConfigs } + }) + } catch (error) { + throw new Error(`Failed to get mode configs: ${error}`) + } + } + public async export() { try { return await this.lock(async () => { diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index a1fc9ac7de..c77464f9f2 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1352,6 +1352,41 @@ describe("ProviderSettingsManager", () => { }) }) + describe("GetModeConfigIds", () => { + const profilesWithModes = (modeApiConfigs?: Record) => + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: { id: "default" } }, + modeApiConfigs, + migrations: { rateLimitSecondsMigrated: true }, + }) + + it("returns a copy of the stored mode map", async () => { + mockSecrets.get.mockResolvedValue(profilesWithModes({ code: "code-id", architect: "architect-id" })) + + const ids = await providerSettingsManager.getModeConfigIds() + ids.code = "changed" + + expect(ids).toEqual({ code: "changed", architect: "architect-id" }) + await expect(providerSettingsManager.getModeConfigIds()).resolves.toEqual({ + code: "code-id", + architect: "architect-id", + }) + }) + + it("returns an empty map when none is stored", async () => { + mockSecrets.get.mockResolvedValue(profilesWithModes(undefined)) + + await expect(providerSettingsManager.getModeConfigIds()).resolves.toEqual({}) + }) + + it("should throw error if secrets storage fails", async () => { + mockSecrets.get.mockRejectedValue(new Error("Storage failed")) + + await expect(providerSettingsManager.getModeConfigIds()).rejects.toThrow("Failed to get mode configs") + }) + }) + describe("HasConfig", () => { it("should return true for existing config", async () => { const existingConfig: ProviderProfiles = { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 60806b9acd..3853603568 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -364,6 +364,13 @@ export class ClineProvider */ private readonly localModeConfigIds = new Map() + /** + * The shared mode map as it was when this window opened. Modes this window has not changed + * resolve against it, so another window's later selection cannot reach this window's tasks + * (including delegated subtasks). + */ + private initialModeConfigIds: Promise> = Promise.resolve({}) + constructor( readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel, @@ -400,6 +407,7 @@ export class ClineProvider this._workspaceTracker = new WorkspaceTracker(this) this.providerSettingsManager = new ProviderSettingsManager(this.context) + this.captureInitialModeConfigIds() this.customModesManager = new CustomModesManager(this.context, async () => { await this.postStateToWebviewWithoutClineMessages() @@ -1836,8 +1844,20 @@ export class ClineProvider // Provider Profile Management + private captureInitialModeConfigIds(): void { + this.initialModeConfigIds = this.providerSettingsManager.getModeConfigIds().catch((error) => { + this.log(`Failed to snapshot mode profile choices: ${error instanceof Error ? error.message : error}`) + return {} + }) + } + private async getModeConfigId(mode: Mode): Promise { - return this.localModeConfigIds.get(mode) ?? (await this.providerSettingsManager.getModeConfigId(mode)) + return ( + this.localModeConfigIds.get(mode) ?? + (await this.initialModeConfigIds)[mode] ?? + // Modes created after this window opened have no snapshot entry. + (await this.providerSettingsManager.getModeConfigId(mode)) + ) } private async setModeConfig(mode: Mode, id: string): Promise { @@ -3396,6 +3416,9 @@ export class ClineProvider await this.contextProxy.resetAllState() await this.providerSettingsManager.resetAllConfigs() + // Cached choices point at profiles that no longer exist; re-read the reset mapping. + this.localModeConfigIds.clear() + this.captureInitialModeConfigIds() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() await this.postStateToWebview() diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 3121fe1046..8a5fdd26a4 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -240,6 +240,8 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { new ContextProxy(mockContext), new WebviewFocusTracker(), ) + // These tests stub providerSettingsManager and expect it to be consulted for mode profiles. + provider["initialModeConfigIds"] = Promise.resolve({}) // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 6472b77749..512eaf6d2a 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -332,6 +332,8 @@ describe("ClineProvider - Lock API Config Across Modes", () => { new ContextProxy(mockContext), new WebviewFocusTracker(), ) + // These tests stub providerSettingsManager and expect it to be consulted for mode profiles. + provider["initialModeConfigIds"] = Promise.resolve({}) // Mock getMcpHub method provider.getMcpHub = vi.fn().mockReturnValue({ diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4da68d7904..4790b243a2 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -552,6 +552,9 @@ describe("ClineProvider", () => { new ContextProxy(mockContext), new WebviewFocusTracker(), ) + // Most tests stub providerSettingsManager and expect it to be consulted for mode profiles, + // so start without the snapshot the real manager would take of the seeded mode map. + provider["initialModeConfigIds"] = Promise.resolve({}) defaultTaskOptions = { provider, @@ -2462,6 +2465,53 @@ describe("ClineProvider", () => { await expect(provider["getModeConfigId"]("architect")).resolves.toBe("other-window-id") }) + describe("mode profile snapshot", () => { + it("keeps the choice this window opened with when another window changes the shared value", async () => { + provider["initialModeConfigIds"] = Promise.resolve({ architect: "opened-with-id" }) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("other-window-id") + + await expect(provider["getModeConfigId"]("architect")).resolves.toBe("opened-with-id") + // A mode created after this window opened has no snapshot entry, so the shared value applies. + await expect(provider["getModeConfigId"]("new-custom-mode")).resolves.toBe("other-window-id") + }) + + it("prefers an explicit local choice over the snapshot", async () => { + provider["initialModeConfigIds"] = Promise.resolve({ architect: "opened-with-id" }) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + + await provider["setModeConfig"]("architect", "chosen-id") + + await expect(provider["getModeConfigId"]("architect")).resolves.toBe("chosen-id") + }) + + it("falls back to the shared value when the snapshot cannot be read", async () => { + vi.spyOn(provider.providerSettingsManager, "getModeConfigIds").mockRejectedValue(new Error("unreadable")) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue("shared-id") + + provider["captureInitialModeConfigIds"]() + + await expect(provider["getModeConfigId"]("architect")).resolves.toBe("shared-id") + }) + + it("drops local choices and re-reads the mapping when state is reset", async () => { + const { t } = await import("../../../i18n") + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce(t("common:answers.yes") as never) + vi.spyOn(provider.providerSettingsManager, "setModeConfig").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "resetAllConfigs").mockResolvedValue(undefined) + vi.spyOn(provider.contextProxy, "resetAllState").mockResolvedValue(undefined) + // The shared custom-modes mock does not implement reset. + Object.assign(provider.customModesManager, { resetCustomModes: vi.fn().mockResolvedValue(undefined) }) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + vi.spyOn(provider.providerSettingsManager, "getModeConfigIds").mockResolvedValue({ code: "reset-id" }) + vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockResolvedValue(undefined) + await provider["setModeConfig"]("code", "removed-profile-id") + + await provider.resetState() + + await expect(provider["getModeConfigId"]("code")).resolves.toBe("reset-id") + }) + }) + it("saves current config when switching to mode without config", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]