From a39103299f6be896f6e998bab305fdb6b38a13e6 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sun, 16 Aug 2026 21:38:16 +0200 Subject: [PATCH 1/2] agentHost: fix: preserve active peer chats during idle eviction Use the session-wide active-turn tracker when deciding whether an unsubscribed session can be released, so peer chat turns keep their host state alive. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea077bee-57a9-4f08-8c14-a8778fbcea83 --- .../platform/agentHost/node/agentService.ts | 6 ++-- .../agentHost/test/node/agentService.test.ts | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c93e481e75b71..fd3435ae87827 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3369,7 +3369,7 @@ export class AgentService extends Disposable implements IAgentService { if (!targetState) { return; } - if (targetState.activeTurn !== undefined) { + if (this._stateManager.hasActiveTurn(evictionTargetKey)) { this._scheduleSessionRelease(evictionTarget); return; } @@ -3382,7 +3382,7 @@ export class AgentService extends Disposable implements IAgentService { return; } const settledState = this._stateManager.getSessionState(evictionTargetKey); - if (!settledState || settledState.activeTurn !== undefined) { + if (!settledState || this._stateManager.hasActiveTurn(evictionTargetKey)) { return; } const provider = this._findProviderForSession(evictionTarget); @@ -3401,7 +3401,7 @@ export class AgentService extends Disposable implements IAgentService { if (this._hasSessionSubscribers(evictionTarget)) { return; } - if (this._restoreSessionInFlight.has(evictionTargetKey) || currentState?.activeTurn !== undefined) { + if (this._restoreSessionInFlight.has(evictionTargetKey) || this._stateManager.hasActiveTurn(evictionTargetKey)) { this._scheduleSessionRelease(evictionTarget); return; } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a175cb7d375d5..4a0f5df37f6bb 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9475,6 +9475,34 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); }); + test('a session with an active peer chat is NOT evicted when its last subscriber drops', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + service.registerProvider(copilotAgent); + const sessionResource = await service.createSession({ provider: 'copilot' }); + const peerChat = URI.parse(buildChatUri(sessionResource, 'peer-1')); + service.stateManager.addChat(sessionResource.toString(), peerChat.toString(), {}); + service.addSubscriber(sessionResource, 'client-1'); + service.dispatchAction( + peerChat.toString(), + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, + 'client-1', 1, + ); + + service.unsubscribe(sessionResource, 'client-1'); + await new Promise(resolve => setTimeout(resolve, 30_000)); + + assert.deepStrictEqual({ + hasActiveTurn: service.stateManager.hasActiveTurn(sessionResource.toString()), + hasCachedState: service.stateManager.getSessionState(sessionResource.toString()) !== undefined, + releaseCalls: copilotAgent.releaseSessionCalls.length, + }, { + hasActiveTurn: true, + hasCachedState: true, + releaseCalls: 0, + }); + }); + }); + test('a provider can defer idle release without losing cached state', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const agent = new DeferringReleaseMockAgent('copilot'); From c60305b73262dcb26b8e256a4e835c50855fbba6 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Sun, 16 Aug 2026 22:22:16 +0200 Subject: [PATCH 2/2] agentHost: fix: retry eviction after peer turn race Re-arm idle release when a peer chat becomes active while session data is draining, and cover eviction after that turn completes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: ea077bee-57a9-4f08-8c14-a8778fbcea83 --- .../platform/agentHost/node/agentService.ts | 6 ++- .../agentHost/test/node/agentService.test.ts | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index fd3435ae87827..0bae37d257bf4 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -3382,7 +3382,11 @@ export class AgentService extends Disposable implements IAgentService { return; } const settledState = this._stateManager.getSessionState(evictionTargetKey); - if (!settledState || this._stateManager.hasActiveTurn(evictionTargetKey)) { + if (!settledState) { + return; + } + if (this._stateManager.hasActiveTurn(evictionTargetKey)) { + this._scheduleSessionRelease(evictionTarget); return; } const provider = this._findProviderForSession(evictionTarget); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4a0f5df37f6bb..457193216dcea 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -9503,6 +9503,49 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('a peer turn starting during the session data drain re-arms idle eviction', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const whenIdleStarted = new DeferredPromise(); + const whenIdle = new DeferredPromise(); + class DelayedIdleDatabase extends TestSessionDatabase { + override async whenIdle(): Promise { + whenIdleStarted.complete(); + await whenIdle.p; + } + } + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(new DelayedIdleDatabase()), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + localService.registerProvider(agent); + const sessionResource = await localService.createSession({ provider: 'copilot' }); + const defaultChat = buildDefaultChatUri(sessionResource); + const peerChat = URI.parse(buildChatUri(sessionResource, 'peer-1')); + localService.stateManager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'initial-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'initial', origin: { kind: MessageKind.User } } }); + localService.stateManager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'initial-turn', duration: 1000 }); + localService.stateManager.addChat(sessionResource.toString(), peerChat.toString(), {}); + localService.addSubscriber(sessionResource, 'client-1'); + localService.unsubscribe(sessionResource, 'client-1'); + + await new Promise(resolve => setTimeout(resolve, 30_000)); + await whenIdleStarted.p; + localService.dispatchAction( + peerChat.toString(), + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, + 'client-1', 1, + ); + whenIdle.complete(); + await Promise.resolve(); + localService.dispatchAction( + peerChat.toString(), + { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, + 'client-1', 2, + ); + await new Promise(resolve => setTimeout(resolve, 30_000)); + + assert.strictEqual(localService.stateManager.getSessionState(sessionResource.toString()), undefined); + }); + }); + test('a provider can defer idle release without losing cached state', () => { return runWithFakedTimers({ useFakeTimers: true }, async () => { const agent = new DeferringReleaseMockAgent('copilot');