diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index ab952da949..455ba419fa 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,7 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ export class ContextProxy { private readonly originalContext: vscode.ExtensionContext + private readonly sessionId: string private stateCache: GlobalState private secretCache: SecretState @@ -43,6 +44,10 @@ export class ContextProxy { constructor(context: vscode.ExtensionContext) { this.originalContext = context + // Use sessionId to isolate state between multiple VSCode windows + // This ensures each window maintains its own independent state + // Fallback to empty string if sessionId is not available (e.g., in tests) + this.sessionId = vscode.env.sessionId || "" this.stateCache = {} this.secretCache = {} this._isInitialized = false @@ -55,8 +60,9 @@ export class ContextProxy { public async initialize() { for (const key of GLOBAL_STATE_KEYS) { try { - // Revert to original assignment - this.stateCache[key] = this.originalContext.globalState.get(key) + // Use session-specific key for state isolation + const sessionKey = this.getSessionKey(key) + this.stateCache[key] = this.originalContext.globalState.get(sessionKey) } catch (error) { logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`) } @@ -91,13 +97,38 @@ export class ContextProxy { this._isInitialized = true } + /** + * Creates a session-specific key by combining the base key with the session ID. + * This ensures state isolation between multiple VSCode windows. + * + * @param key The base state key + * @returns The session-specific key + */ + private getSessionKey(key: string): string { + // For certain keys that should be shared across sessions (like API configs), + // we don't add the session prefix + const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"] + if (sharedKeys.includes(key)) { + return key + } + + // If no sessionId is available (e.g., in tests), use the key as-is + if (!this.sessionId) { + return key + } + + // For all other keys, add session prefix to isolate state + return `session_${this.sessionId}_${key}` + } + /** * Migrates old nested openRouterImageGenerationSettings to the new flattened structure */ private async migrateImageGenerationSettings() { try { - // Check if there's an old nested structure - const oldNestedSettings = this.originalContext.globalState.get("openRouterImageGenerationSettings") + // Check if there's an old nested structure (use session-specific key) + const sessionKey = this.getSessionKey("openRouterImageGenerationSettings") + const oldNestedSettings = this.originalContext.globalState.get(sessionKey) if (oldNestedSettings && typeof oldNestedSettings === "object") { logger.info("Migrating old nested image generation settings to flattened structure") @@ -114,16 +145,14 @@ export class ContextProxy { // Migrate the selected model if it exists and we don't already have one if (oldNestedSettings.selectedModel && !this.stateCache.openRouterImageGenerationSelectedModel) { - await this.originalContext.globalState.update( - "openRouterImageGenerationSelectedModel", - oldNestedSettings.selectedModel, - ) + const modelSessionKey = this.getSessionKey("openRouterImageGenerationSelectedModel") + await this.originalContext.globalState.update(modelSessionKey, oldNestedSettings.selectedModel) this.stateCache.openRouterImageGenerationSelectedModel = oldNestedSettings.selectedModel logger.info("Migrated openRouterImageGenerationSelectedModel to global state") } // Clean up the old nested structure - await this.originalContext.globalState.update("openRouterImageGenerationSettings", undefined) + await this.originalContext.globalState.update(sessionKey, undefined) logger.info("Removed old nested openRouterImageGenerationSettings") } } catch (error) { @@ -166,7 +195,9 @@ export class ContextProxy { getGlobalState(key: K, defaultValue: GlobalState[K]): GlobalState[K] getGlobalState(key: K, defaultValue?: GlobalState[K]): GlobalState[K] { if (isPassThroughStateKey(key)) { - const value = this.originalContext.globalState.get(key) + // Use session-specific key for pass-through state as well + const sessionKey = this.getSessionKey(key) + const value = this.originalContext.globalState.get(sessionKey) return value === undefined || value === null ? defaultValue : value } @@ -175,12 +206,14 @@ export class ContextProxy { } updateGlobalState(key: K, value: GlobalState[K]) { + const sessionKey = this.getSessionKey(key) + if (isPassThroughStateKey(key)) { - return this.originalContext.globalState.update(key, value) + return this.originalContext.globalState.update(sessionKey, value) } this.stateCache[key] = value - return this.originalContext.globalState.update(key, value) + return this.originalContext.globalState.update(sessionKey, value) } private getAllGlobalState(): GlobalState { @@ -362,7 +395,10 @@ export class ContextProxy { this.secretCache = {} await Promise.all([ - ...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)), + ...GLOBAL_STATE_KEYS.map((key) => { + const sessionKey = this.getSessionKey(key) + return this.originalContext.globalState.update(sessionKey, undefined) + }), ...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)), ...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)), ]) diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 58dae7e24e..a544c1fadb 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -15,6 +15,9 @@ vi.mock("vscode", () => ({ Production: 2, Test: 3, }, + env: { + sessionId: "test-session-id-12345", + }, })) describe("ContextProxy", () => { @@ -72,11 +75,21 @@ describe("ContextProxy", () => { it("should initialize state cache with all global state keys", () => { // +1 for the migration check of old nested settings expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 1) + + // When sessionId is available, session-specific keys are used for non-shared keys + // In test environment with mocked sessionId, check for session-specific keys + const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"] for (const key of GLOBAL_STATE_KEYS) { - expect(mockGlobalState.get).toHaveBeenCalledWith(key) + if (sharedKeys.includes(key)) { + expect(mockGlobalState.get).toHaveBeenCalledWith(key) + } else { + expect(mockGlobalState.get).toHaveBeenCalledWith(`session_test-session-id-12345_${key}`) + } } - // Also check for migration call - expect(mockGlobalState.get).toHaveBeenCalledWith("openRouterImageGenerationSettings") + // Also check for migration call with session-specific key + expect(mockGlobalState.get).toHaveBeenCalledWith( + "session_test-session-id-12345_openRouterImageGenerationSettings", + ) }) it("should initialize secret cache with all secret keys", () => { @@ -116,9 +129,9 @@ describe("ContextProxy", () => { // Use a pass-through key (taskHistory) const result = proxy.getGlobalState("taskHistory") - // Should get value directly from original context + // Should get value directly from original context with session-specific key (when sessionId is available) expect(result).toBe("pass-through-value") - expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory") + expect(mockGlobalState.get).toHaveBeenCalledWith("session_test-session-id-12345_taskHistory") }) it("should respect default values for pass-through state keys", async () => { @@ -149,7 +162,7 @@ describe("ContextProxy", () => { it("should update state directly in original context", async () => { await proxy.updateGlobalState("apiProvider", "deepseek") - // Should have called original context + // Should have called original context (apiProvider is a shared key, no session prefix) expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "deepseek") // Should have stored the value in cache @@ -172,16 +185,19 @@ describe("ContextProxy", () => { await proxy.updateGlobalState("taskHistory", historyItems) - // Should update original context - expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", historyItems) + // Should update original context with session-specific key (when sessionId is available) + expect(mockGlobalState.update).toHaveBeenCalledWith( + "session_test-session-id-12345_taskHistory", + historyItems, + ) // Setup mock for subsequent get mockGlobalState.get.mockReturnValue(historyItems) - // Should get fresh value from original context + // Should get fresh value from original context with session-specific key (when sessionId is available) const storedValue = proxy.getGlobalState("taskHistory") expect(storedValue).toBe(historyItems) - expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory") + expect(mockGlobalState.get).toHaveBeenCalledWith("session_test-session-id-12345_taskHistory") }) }) @@ -387,9 +403,17 @@ describe("ContextProxy", () => { // Reset all state await proxy.resetAllState() - // Should have called update with undefined for each key + // Should have called update with undefined for each key using session-specific keys (when sessionId is available) + const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"] for (const key of GLOBAL_STATE_KEYS) { - expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined) + if (sharedKeys.includes(key)) { + expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined) + } else { + expect(mockGlobalState.update).toHaveBeenCalledWith( + `session_test-session-id-12345_${key}`, + undefined, + ) + } } // Total calls should include initial setup + reset operations