Skip to content

Commit 02e8716

Browse files
committed
fix: isolate state between duplicate VSCode workspaces using sessionId
- Add session-specific keys to ContextProxy to prevent state sharing - Use vscode.env.sessionId to create unique state keys per window - Maintain shared keys for API configurations that should persist - Handle cases where sessionId is not available (e.g., in tests) Fixes #7698
1 parent 90e7d09 commit 02e8716

File tree

2 files changed

+85
-25
lines changed

2 files changed

+85
-25
lines changed

src/core/config/ContextProxy.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,18 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({
3636

3737
export class ContextProxy {
3838
private readonly originalContext: vscode.ExtensionContext
39+
private readonly sessionId: string
3940

4041
private stateCache: GlobalState
4142
private secretCache: SecretState
4243
private _isInitialized = false
4344

4445
constructor(context: vscode.ExtensionContext) {
4546
this.originalContext = context
47+
// Use sessionId to isolate state between multiple VSCode windows
48+
// This ensures each window maintains its own independent state
49+
// Fallback to empty string if sessionId is not available (e.g., in tests)
50+
this.sessionId = vscode.env.sessionId || ""
4651
this.stateCache = {}
4752
this.secretCache = {}
4853
this._isInitialized = false
@@ -55,8 +60,9 @@ export class ContextProxy {
5560
public async initialize() {
5661
for (const key of GLOBAL_STATE_KEYS) {
5762
try {
58-
// Revert to original assignment
59-
this.stateCache[key] = this.originalContext.globalState.get(key)
63+
// Use session-specific key for state isolation
64+
const sessionKey = this.getSessionKey(key)
65+
this.stateCache[key] = this.originalContext.globalState.get(sessionKey)
6066
} catch (error) {
6167
logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`)
6268
}
@@ -91,13 +97,38 @@ export class ContextProxy {
9197
this._isInitialized = true
9298
}
9399

100+
/**
101+
* Creates a session-specific key by combining the base key with the session ID.
102+
* This ensures state isolation between multiple VSCode windows.
103+
*
104+
* @param key The base state key
105+
* @returns The session-specific key
106+
*/
107+
private getSessionKey(key: string): string {
108+
// For certain keys that should be shared across sessions (like API configs),
109+
// we don't add the session prefix
110+
const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"]
111+
if (sharedKeys.includes(key)) {
112+
return key
113+
}
114+
115+
// If no sessionId is available (e.g., in tests), use the key as-is
116+
if (!this.sessionId) {
117+
return key
118+
}
119+
120+
// For all other keys, add session prefix to isolate state
121+
return `session_${this.sessionId}_${key}`
122+
}
123+
94124
/**
95125
* Migrates old nested openRouterImageGenerationSettings to the new flattened structure
96126
*/
97127
private async migrateImageGenerationSettings() {
98128
try {
99-
// Check if there's an old nested structure
100-
const oldNestedSettings = this.originalContext.globalState.get<any>("openRouterImageGenerationSettings")
129+
// Check if there's an old nested structure (use session-specific key)
130+
const sessionKey = this.getSessionKey("openRouterImageGenerationSettings")
131+
const oldNestedSettings = this.originalContext.globalState.get<any>(sessionKey)
101132

102133
if (oldNestedSettings && typeof oldNestedSettings === "object") {
103134
logger.info("Migrating old nested image generation settings to flattened structure")
@@ -114,16 +145,14 @@ export class ContextProxy {
114145

115146
// Migrate the selected model if it exists and we don't already have one
116147
if (oldNestedSettings.selectedModel && !this.stateCache.openRouterImageGenerationSelectedModel) {
117-
await this.originalContext.globalState.update(
118-
"openRouterImageGenerationSelectedModel",
119-
oldNestedSettings.selectedModel,
120-
)
148+
const modelSessionKey = this.getSessionKey("openRouterImageGenerationSelectedModel")
149+
await this.originalContext.globalState.update(modelSessionKey, oldNestedSettings.selectedModel)
121150
this.stateCache.openRouterImageGenerationSelectedModel = oldNestedSettings.selectedModel
122151
logger.info("Migrated openRouterImageGenerationSelectedModel to global state")
123152
}
124153

125154
// Clean up the old nested structure
126-
await this.originalContext.globalState.update("openRouterImageGenerationSettings", undefined)
155+
await this.originalContext.globalState.update(sessionKey, undefined)
127156
logger.info("Removed old nested openRouterImageGenerationSettings")
128157
}
129158
} catch (error) {
@@ -166,7 +195,9 @@ export class ContextProxy {
166195
getGlobalState<K extends GlobalStateKey>(key: K, defaultValue: GlobalState[K]): GlobalState[K]
167196
getGlobalState<K extends GlobalStateKey>(key: K, defaultValue?: GlobalState[K]): GlobalState[K] {
168197
if (isPassThroughStateKey(key)) {
169-
const value = this.originalContext.globalState.get<GlobalState[K]>(key)
198+
// Use session-specific key for pass-through state as well
199+
const sessionKey = this.getSessionKey(key)
200+
const value = this.originalContext.globalState.get<GlobalState[K]>(sessionKey)
170201
return value === undefined || value === null ? defaultValue : value
171202
}
172203

@@ -175,12 +206,14 @@ export class ContextProxy {
175206
}
176207

177208
updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
209+
const sessionKey = this.getSessionKey(key)
210+
178211
if (isPassThroughStateKey(key)) {
179-
return this.originalContext.globalState.update(key, value)
212+
return this.originalContext.globalState.update(sessionKey, value)
180213
}
181214

182215
this.stateCache[key] = value
183-
return this.originalContext.globalState.update(key, value)
216+
return this.originalContext.globalState.update(sessionKey, value)
184217
}
185218

186219
private getAllGlobalState(): GlobalState {
@@ -362,7 +395,10 @@ export class ContextProxy {
362395
this.secretCache = {}
363396

364397
await Promise.all([
365-
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
398+
...GLOBAL_STATE_KEYS.map((key) => {
399+
const sessionKey = this.getSessionKey(key)
400+
return this.originalContext.globalState.update(sessionKey, undefined)
401+
}),
366402
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
367403
...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)),
368404
])

src/core/config/__tests__/ContextProxy.spec.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ vi.mock("vscode", () => ({
1515
Production: 2,
1616
Test: 3,
1717
},
18+
env: {
19+
sessionId: "test-session-id-12345",
20+
},
1821
}))
1922

2023
describe("ContextProxy", () => {
@@ -72,11 +75,21 @@ describe("ContextProxy", () => {
7275
it("should initialize state cache with all global state keys", () => {
7376
// +1 for the migration check of old nested settings
7477
expect(mockGlobalState.get).toHaveBeenCalledTimes(GLOBAL_STATE_KEYS.length + 1)
78+
79+
// When sessionId is available, session-specific keys are used for non-shared keys
80+
// In test environment with mocked sessionId, check for session-specific keys
81+
const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"]
7582
for (const key of GLOBAL_STATE_KEYS) {
76-
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
83+
if (sharedKeys.includes(key)) {
84+
expect(mockGlobalState.get).toHaveBeenCalledWith(key)
85+
} else {
86+
expect(mockGlobalState.get).toHaveBeenCalledWith(`session_test-session-id-12345_${key}`)
87+
}
7788
}
78-
// Also check for migration call
79-
expect(mockGlobalState.get).toHaveBeenCalledWith("openRouterImageGenerationSettings")
89+
// Also check for migration call with session-specific key
90+
expect(mockGlobalState.get).toHaveBeenCalledWith(
91+
"session_test-session-id-12345_openRouterImageGenerationSettings",
92+
)
8093
})
8194

8295
it("should initialize secret cache with all secret keys", () => {
@@ -116,9 +129,9 @@ describe("ContextProxy", () => {
116129
// Use a pass-through key (taskHistory)
117130
const result = proxy.getGlobalState("taskHistory")
118131

119-
// Should get value directly from original context
132+
// Should get value directly from original context with session-specific key (when sessionId is available)
120133
expect(result).toBe("pass-through-value")
121-
expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory")
134+
expect(mockGlobalState.get).toHaveBeenCalledWith("session_test-session-id-12345_taskHistory")
122135
})
123136

124137
it("should respect default values for pass-through state keys", async () => {
@@ -149,7 +162,7 @@ describe("ContextProxy", () => {
149162
it("should update state directly in original context", async () => {
150163
await proxy.updateGlobalState("apiProvider", "deepseek")
151164

152-
// Should have called original context
165+
// Should have called original context (apiProvider is a shared key, no session prefix)
153166
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "deepseek")
154167

155168
// Should have stored the value in cache
@@ -172,16 +185,19 @@ describe("ContextProxy", () => {
172185

173186
await proxy.updateGlobalState("taskHistory", historyItems)
174187

175-
// Should update original context
176-
expect(mockGlobalState.update).toHaveBeenCalledWith("taskHistory", historyItems)
188+
// Should update original context with session-specific key (when sessionId is available)
189+
expect(mockGlobalState.update).toHaveBeenCalledWith(
190+
"session_test-session-id-12345_taskHistory",
191+
historyItems,
192+
)
177193

178194
// Setup mock for subsequent get
179195
mockGlobalState.get.mockReturnValue(historyItems)
180196

181-
// Should get fresh value from original context
197+
// Should get fresh value from original context with session-specific key (when sessionId is available)
182198
const storedValue = proxy.getGlobalState("taskHistory")
183199
expect(storedValue).toBe(historyItems)
184-
expect(mockGlobalState.get).toHaveBeenCalledWith("taskHistory")
200+
expect(mockGlobalState.get).toHaveBeenCalledWith("session_test-session-id-12345_taskHistory")
185201
})
186202
})
187203

@@ -387,9 +403,17 @@ describe("ContextProxy", () => {
387403
// Reset all state
388404
await proxy.resetAllState()
389405

390-
// Should have called update with undefined for each key
406+
// Should have called update with undefined for each key using session-specific keys (when sessionId is available)
407+
const sharedKeys = ["listApiConfigMeta", "currentApiConfigName", "apiProvider"]
391408
for (const key of GLOBAL_STATE_KEYS) {
392-
expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined)
409+
if (sharedKeys.includes(key)) {
410+
expect(mockGlobalState.update).toHaveBeenCalledWith(key, undefined)
411+
} else {
412+
expect(mockGlobalState.update).toHaveBeenCalledWith(
413+
`session_test-session-id-12345_${key}`,
414+
undefined,
415+
)
416+
}
393417
}
394418

395419
// Total calls should include initial setup + reset operations

0 commit comments

Comments
 (0)