diff --git a/src/__tests__/ClineProvider.delegation.spec.ts b/src/__tests__/ClineProvider.delegation.spec.ts index 68f39c6517..7e8fc604fe 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 @@ -326,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 }, { @@ -347,6 +396,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 +406,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 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 28a42913f3..3853603568 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -357,6 +357,20 @@ 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() + + /** + * 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, @@ -393,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() @@ -1273,7 +1288,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 +1790,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 +1832,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 +1844,27 @@ 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.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 { + this.localModeConfigIds.set(mode, id) + await this.providerSettingsManager.setModeConfig(mode, id) + } + /** * Updates the current task's API handler. * Rebuilds when: @@ -2034,7 +2070,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 +2192,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. @@ -3380,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() @@ -4007,7 +4046,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.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 93543435e2..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, @@ -2447,6 +2450,68 @@ 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") + }) + + 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]