From e4a39b418fcf4c4cba325a389de99645aebcaffc Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 24 Jul 2026 19:28:22 -0700 Subject: [PATCH 1/3] agentHost: persist server-side session config changes Persist merged session config values for both client- and server-dispatched actions so mode changes survive session restore.\n\nFixes microsoft/vscode#327435.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentSideEffects.ts | 17 +++++---- .../test/node/agentSideEffects.test.ts | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 302a8f4d19829..9c8574beaaa04 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -199,10 +199,8 @@ export class AgentSideEffects extends Disposable { this._publishAgentInfos(agents, reader); })); - // Server-dispatched ChatToolCallComplete actions (e.g. from - // the disconnect timeout in ProtocolServerHandler) bypass - // handleAction, so the agent's SDK deferred never resolves. - // Listen for these envelopes and notify the agent directly. + // Observe envelopes for side effects that must include server-dispatched + // actions, which bypass handleAction. this._register(this._stateManager.onDidEmitEnvelope(envelope => { if (isAhpChatChannel(envelope.channel) && isChatAction(envelope.action)) { if (envelope.action.type === ActionType.ChatTurnCancelled) { @@ -230,6 +228,12 @@ export class AgentSideEffects extends Disposable { if (envelope.action.type === ActionType.ChatDraftChanged) { this._persistChatDraft(envelope.channel, envelope.action.draft); } + if (envelope.action.type === ActionType.SessionConfigChanged) { + const values = this._stateManager.getSessionState(envelope.channel)?.config?.values; + if (values) { + this._persistSessionFlag(envelope.channel, 'configValues', JSON.stringify(values)); + } + } })); } @@ -1320,13 +1324,8 @@ export class AgentSideEffects extends Disposable { break; } case ActionType.SessionConfigChanged: { - // Persist merged values so a future `restoreSession` can re-hydrate - // the user's previous selections (e.g. autoApprove). const sessionState = this._stateManager.getSessionState(channel); const values = sessionState?.config?.values; - if (values) { - this._persistSessionFlag(channel, 'configValues', JSON.stringify(values)); - } if (this._worktree && sessionState?.lifecycle === SessionLifecycle.Creating) { const sessionId = AgentSession.id(channel); const isolation = values?.[SessionConfigKey.Isolation]; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 44114416d92c7..58550e1a09f0c 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -3854,6 +3854,41 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' }); }); + test('server-dispatched SessionConfigChanged persists merged config values to the database', async () => { + const sessionDataService = createSessionDataService(sessionDb); + const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + createTestSideEffects(disposables, localStateManager, { + getAgent: () => localAgent, + agents: observableValue('agents', [localAgent]), + sessionDataService, + onTurnComplete: () => { }, + }); + + const session = localStateManager.createSession({ + resource: sessionUri.toString(), + provider: 'mock', + title: 'Initial', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + project: { uri: 'file:///test-project', displayName: 'Test Project' }, + }); + session.config = { schema: { type: 'object', properties: {} }, values: { mode: 'plan', autoApprove: 'default' } }; + + localStateManager.dispatchServerAction(sessionUri.toString(), { + type: ActionType.SessionConfigChanged, + config: { mode: 'interactive' }, + }); + + await new Promise(r => setTimeout(r, 50)); + + const persisted = await sessionDb.getMetadata('configValues'); + assert.ok(persisted); + assert.deepStrictEqual(JSON.parse(persisted!), { mode: 'interactive', autoApprove: 'default' }); + }); + test('SessionConfigChanged notifies the agent with the post-reducer merged values', async () => { // The client-action side-effects path is where a picker edit lands // (internal server writes use `dispatchServerAction` and never reach From 996068ab305b77936b5859332e64a0d04b164505 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 24 Jul 2026 23:47:16 -0700 Subject: [PATCH 2/3] agentHost: handle session database open failures Keep fire-and-forget metadata persistence from surfacing synchronous database-open failures.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/shared/persistSessionMetadata.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts index 3be196ad36aa6..4b9c4aa0d5f4e 100644 --- a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts +++ b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts @@ -17,10 +17,15 @@ import type { ISessionDataService } from '../../common/sessionDataService.js'; * re-implement the open/write/dispose dance. */ export function persistSessionMetadata(sessionDataService: ISessionDataService, logService: ILogService, session: string, key: string, value: string): void { - const ref = sessionDataService.openDatabase(URI.parse(session)); - ref.object.setMetadata(key, value).catch(err => { + const onError = (err: unknown) => { logService.warn(`[AgentHost] Failed to persist session metadata '${key}'`, err); - }).finally(() => { - ref.dispose(); - }); + }; + try { + const ref = sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadata(key, value).catch(onError).finally(() => { + ref.dispose(); + }); + } catch (err) { + onError(err); + } } From 7f9aef3e213b87180d628bb4c4a1ca1d3bd91ba9 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Sat, 25 Jul 2026 10:11:07 -0700 Subject: [PATCH 3/3] agentHost: make persistence tests event-driven Poll for persisted metadata with a bounded timeout instead of relying on fixed delays.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/node/agentSideEffects.test.ts | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 58550e1a09f0c..75f0b8c7c3704 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -3695,6 +3695,17 @@ suite('AgentSideEffects', () => { sessionDb = disposables.add(await SessionDatabase.open(':memory:')); }); + async function waitForMetadata(key: string): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const value = await sessionDb.getMetadata(key); + if (value !== undefined) { + return value; + } + await timeout(10); + } + throw new Error(`Session metadata '${key}' was not persisted`); + } + teardown(async () => { await sessionDb.close(); }); @@ -3726,10 +3737,7 @@ suite('AgentSideEffects', () => { title: 'Custom Title', }); - // Wait for the async persistence - await new Promise(r => setTimeout(r, 50)); - - assert.strictEqual(await sessionDb.getMetadata('customTitle'), 'Custom Title'); + assert.strictEqual(await waitForMetadata('customTitle'), 'Custom Title'); }); test('handleListSessions returns persisted custom title', async () => { @@ -3847,11 +3855,8 @@ suite('AgentSideEffects', () => { config: { autoApprove: 'autoApprove' }, }); - await new Promise(r => setTimeout(r, 50)); - - const persisted = await sessionDb.getMetadata('configValues'); - assert.ok(persisted); - assert.deepStrictEqual(JSON.parse(persisted!), { autoApprove: 'autoApprove' }); + const persisted = await waitForMetadata('configValues'); + assert.deepStrictEqual(JSON.parse(persisted), { autoApprove: 'autoApprove' }); }); test('server-dispatched SessionConfigChanged persists merged config values to the database', async () => { @@ -3882,11 +3887,8 @@ suite('AgentSideEffects', () => { config: { mode: 'interactive' }, }); - await new Promise(r => setTimeout(r, 50)); - - const persisted = await sessionDb.getMetadata('configValues'); - assert.ok(persisted); - assert.deepStrictEqual(JSON.parse(persisted!), { mode: 'interactive', autoApprove: 'default' }); + const persisted = await waitForMetadata('configValues'); + assert.deepStrictEqual(JSON.parse(persisted), { mode: 'interactive', autoApprove: 'default' }); }); test('SessionConfigChanged notifies the agent with the post-reducer merged values', async () => {