From 7ee8d7166be54ef580b425c7b5d82fd8ea8a743d Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:12:48 +0200 Subject: [PATCH] Add recent external sessions filter (#331181) * Add recent external sessions filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address recent session review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update session lifecycle test for external default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/agentHostSchema.ts | 4 +- .../agentHost/node/agentHostStateManager.ts | 5 +- .../platform/agentHost/node/agentService.ts | 71 +++++++- .../agentHost/test/node/agentService.test.ts | 172 +++++++++++++++++- .../sessionLifecycle.integrationTest.ts | 19 ++ src/vs/platform/chat/common/chatSettings.ts | 1 + .../chat/browser/externalSessionBanner.ts | 28 ++- .../browser/externalSessionBanner.test.ts | 30 ++- .../externalSessionsFilterMenu.ts | 1 + .../chat/browser/chat.shared.contribution.ts | 9 +- .../externalSessionsFilterMenu.test.ts | 13 +- 11 files changed, 314 insertions(+), 39 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 5f680626548..ad13e7f3091 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -759,8 +759,8 @@ export const platformRootSchema = createSchema({ type: 'string', title: localize('agentHost.config.showExternalSessions.title', "Show External Agent Sessions"), description: localize('agentHost.config.showExternalSessions.description', "Controls whether sessions created outside the Agent Host are included in the session catalog."), - enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.All, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days], - default: ChatExternalSessionsMode.Last7Days, + enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.Recent, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days, ChatExternalSessionsMode.All], + default: ChatExternalSessionsMode.None, }), [AgentHostCopilotMultiRootEnabledConfigKey]: schemaProperty({ type: 'boolean', diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 10febffd02a..5cdcaa9cd30 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -10,7 +10,7 @@ import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; @@ -280,6 +280,8 @@ export class AgentHostStateManager extends Disposable { private readonly _onDidChangeSessionWorkingDirectories = this._register(new Emitter<{ session: string }>()); readonly onDidChangeSessionWorkingDirectories: Event<{ session: string }> = this._onDidChangeSessionWorkingDirectories.event; + private readonly _onDidChangeSessionSummary = this._register(new Emitter<{ session: string; changes: SessionSummaryChangedParams['changes'] }>()); + readonly onDidChangeSessionSummary: Event<{ session: string; changes: SessionSummaryChangedParams['changes'] }> = this._onDidChangeSessionSummary.event; constructor( @ILogService private readonly _logService: ILogService, @@ -309,6 +311,7 @@ export class AgentHostStateManager extends Disposable { return entry ? this._toSummary(session, entry) : undefined; }, (session, changes) => { + this._onDidChangeSessionSummary.fire({ session, changes }); if (this._publishedSessionSummaries.has(session)) { this._onDidEmitNotification.fire({ type: 'root/sessionSummaryChanged', diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c0cbb1ceb7e..bbb5839ece1 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -117,6 +117,8 @@ import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; * provider-side session, worktree, and on-disk state. */ const SESSION_GC_GRACE_MS = 30_000; +const DAY_MS = 24 * 60 * 60 * 1000; +const RECENT_EXTERNAL_SESSION_LIMIT = 2; type AgentHostLegacyMigrationEvent = { provider: string; @@ -576,6 +578,15 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidEmitEnvelope(e => this._trackPendingSubagentChatFromEnvelope(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._persistAnnotations(e))); this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); + this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => { + const meta = this._stateManager.getSessionSummary(session)?._meta; + if (changes.modifiedAt !== undefined + && this._getExternalSessionsMode() === AgentHostExternalSessionsMode.Recent + && readSessionExternal(meta) + && !readSessionEhcliAdoptable(meta)) { + this._queueSessionListReconciliation(); + } + })); // Build a local instantiation scope so downstream components can // consume {@link IAgentConfigurationService} (and later {@link ILogService}) @@ -1345,6 +1356,7 @@ export class AgentService extends Disposable implements IAgentService { const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const discoveryLimiter = new Limiter(4); let suppressed = 0; + let registeredExternal = false; const results = await Promise.all(chats.map(({ external, ...metadata }) => discoveryLimiter.queue(async () => { const sessionMetadata = this._toSessionMetadata(metadata); const session = sessionMetadata.session; @@ -1363,7 +1375,11 @@ export class AgentService extends Disposable implements IAgentService { await this._initializeExternalSessionReadState(session); } existing.set(session.toString(), external); - await this._announceSurfacedSession({ ...sessionMetadata, _meta: withSessionExternal(sessionMetadata._meta, external) }, provider.id); + if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) { + registeredExternal = true; + } else { + await this._announceSurfacedSession({ ...sessionMetadata, _meta: withSessionExternal(sessionMetadata._meta, external) }, provider.id); + } } else { this._logService.trace(`[AgentService] discovery: ${session.toString()} was not registered (tombstoned)`); } @@ -1374,6 +1390,9 @@ export class AgentService extends Disposable implements IAgentService { } }))); const registered = results.filter(changed => changed).length; + if (registeredExternal) { + this._queueSessionListReconciliation(); + } this._logService.info(`[AgentService] discovery for provider ${provider.id}: ${chats.length} candidate(s) (${chats.filter(chat => chat.external).length} external), ${registered} registered, ${suppressed} suppressed as subagent/chat backing`); return registered > 0; } @@ -1401,6 +1420,7 @@ export class AgentService extends Disposable implements IAgentService { const external = await this._isExternalProviderChat(s.session); return { session: s.session, provider: provider.id, startTime: s.startTime, external, source: external ? 'discovery' : 'restore' }; }))); + let registeredExternal = false; for (let index = 0; index < identities.length; index++) { const identity = identities[index]; if (!identity) { @@ -1413,10 +1433,17 @@ export class AgentService extends Disposable implements IAgentService { await this._initializeExternalSessionReadState(identity.session); } existing.set(identity.session.toString(), identity.external); - await this._announceSurfacedSession({ ...metadata, _meta: withSessionExternal(metadata._meta, identity.external) }, provider.id); + if (identity.external && !readSessionEhcliAdoptable(metadata._meta)) { + registeredExternal = true; + } else { + await this._announceSurfacedSession({ ...metadata, _meta: withSessionExternal(metadata._meta, identity.external) }, provider.id); + } } } await this._sessionRegistry.markProviderBackfilled(provider.id); + if (registeredExternal) { + this._queueSessionListReconciliation(); + } } private async _initializeExternalSessionReadState(session: URI): Promise { @@ -1710,11 +1737,15 @@ export class AgentService extends Disposable implements IAgentService { }); } const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; + const now = this._now(); + const recentSessionKeys = mode === AgentHostExternalSessionsMode.Recent + ? this._getRecentSessionKeys(combined, now) + : undefined; const visible: IAgentSessionMetadata[] = []; // Adoptable-legacy rows are withheld by migrate-legacy, not by the external mode. let hiddenByExternalMode = 0; for (const session of combined) { - if (this._shouldIncludeSession(session, mode)) { + if (this._shouldIncludeSession(session, mode, now, recentSessionKeys)) { visible.push(session); } else if (!readSessionEhcliAdoptable(session._meta)) { hiddenByExternalMode++; @@ -1748,10 +1779,33 @@ export class AgentService extends Disposable implements IAgentService { } private _getExternalSessionsMode(): AgentHostExternalSessionsMode { - return this._configurationService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey) ?? AgentHostExternalSessionsMode.Last7Days; + return this._configurationService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey) ?? AgentHostExternalSessionsMode.None; } - private _shouldIncludeSession(session: IAgentSessionMetadata, mode = this._getExternalSessionsMode()): boolean { + private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number): ReadonlySet { + const recentExternalSessions = sessions + .filter(session => readSessionExternal(session._meta) + && !readSessionEhcliAdoptable(session._meta) + && session.modifiedTime >= now - 7 * DAY_MS) + .sort((a, b) => { + const timeDifference = b.modifiedTime - a.modifiedTime; + if (timeDifference !== 0) { + return timeDifference; + } + const aKey = a.session.toString(); + const bKey = b.session.toString(); + return aKey < bKey ? -1 : aKey > bKey ? 1 : 0; + }) + .slice(0, RECENT_EXTERNAL_SESSION_LIMIT); + return new Set(recentExternalSessions.map(session => session.session.toString())); + } + + private _shouldIncludeSession( + session: IAgentSessionMetadata, + mode = this._getExternalSessionsMode(), + now = this._now(), + recentSessionKeys?: ReadonlySet, + ): boolean { // While migration is off, un-adopted adoptable-legacy sessions belong to the extension-host provider — exclude so a refresh cannot re-surface an unopenable row. if (readSessionEhcliAdoptable(session._meta) && !this._isMigrateLegacyEnabled()) { return false; @@ -1760,12 +1814,15 @@ export class AgentService extends Disposable implements IAgentService { return true; } switch (mode) { + case AgentHostExternalSessionsMode.Recent: + return session.modifiedTime >= now - 7 * DAY_MS + && (recentSessionKeys === undefined || recentSessionKeys.has(session.session.toString())); case AgentHostExternalSessionsMode.All: return true; case AgentHostExternalSessionsMode.Last24Hours: - return session.modifiedTime >= this._now() - 24 * 60 * 60 * 1000; + return session.modifiedTime >= now - DAY_MS; case AgentHostExternalSessionsMode.Last7Days: - return session.modifiedTime >= this._now() - 7 * 24 * 60 * 60 * 1000; + return session.modifiedTime >= now - 7 * DAY_MS; case AgentHostExternalSessionsMode.None: return false; } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 39be736a2de..346ba52f636 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2699,6 +2699,7 @@ suite('AgentService (node dispatcher)', () => { test('listSessions discovers provider-native sessions as external and restore preserves provenance', async () => { const db = new TestSessionDatabase(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = new MockAgent('copilot'); disposables.add(toDisposable(() => agent.dispose())); @@ -2749,23 +2750,161 @@ suite('AgentService (node dispatcher)', () => { svc.registerProvider(agent); const listedByMode: Record = { + [AgentHostExternalSessionsMode.Recent]: [], [AgentHostExternalSessionsMode.None]: [], [AgentHostExternalSessionsMode.All]: [], [AgentHostExternalSessionsMode.Last24Hours]: [], [AgentHostExternalSessionsMode.Last7Days]: [], }; + const listedByDefault = (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(); let clientSeq = 1; - for (const mode of [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.All, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days]) { + for (const mode of [AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.All, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days]) { setExternalSessionsMode(svc, mode, clientSeq++); await waitForSessionListReconciliation(svc); listedByMode[mode] = (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(); } - assert.deepStrictEqual(listedByMode, { - [AgentHostExternalSessionsMode.None]: [], - [AgentHostExternalSessionsMode.All]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'older-than-7-days', 'recent'], - [AgentHostExternalSessionsMode.Last24Hours]: ['at-24-hours', 'recent'], - [AgentHostExternalSessionsMode.Last7Days]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'recent'], + assert.deepStrictEqual({ listedByDefault, listedByMode }, { + listedByDefault: [], + listedByMode: { + [AgentHostExternalSessionsMode.Recent]: ['at-24-hours', 'recent'], + [AgentHostExternalSessionsMode.None]: [], + [AgentHostExternalSessionsMode.All]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'older-than-7-days', 'recent'], + [AgentHostExternalSessionsMode.Last24Hours]: ['at-24-hours', 'recent'], + [AgentHostExternalSessionsMode.Last7Days]: ['at-24-hours', 'at-7-days', 'older-than-24-hours', 'recent'], + }, + }); + }); + + test('recent replaces the oldest visible external session when a newer session is discovered', async () => { + const now = Date.now(); + const svc = createExternalSessionService(() => now); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + await waitForSessionListReconciliation(svc); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const first = agent.addSession('first', now - 1); + const second = agent.addSession('second', now - 2); + svc.registerProvider(agent); + await svc.listSessions(); + await waitForSessionListReconciliation(svc); + + const notifications: string[] = []; + disposables.add(svc.onDidNotification(notification => { + if (notification.type === NotificationType.SessionAdded) { + notifications.push(`add:${AgentSession.id(URI.parse(notification.summary.resource))}`); + } else if (notification.type === NotificationType.SessionRemoved) { + notifications.push(`remove:${AgentSession.id(URI.parse(notification.session))}`); + } + })); + + const newest = agent.addSession('newest', now); + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [{ + chat: URI.parse(buildDefaultChatUri(newest)), + startTime: now, + modifiedTime: now, + external: true, + }]); + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), + notifications, + }, { + visible: [AgentSession.id(first), AgentSession.id(newest)].sort(), + notifications: ['add:newest', `remove:${AgentSession.id(second)}`], + }); + }); + + test('external discovery reconciles against a mode change that completes while registration is in flight', async () => { + const now = Date.now(); + const svc = createExternalSessionService(() => now); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + await waitForSessionListReconciliation(svc); + const agent = disposables.add(new TimedExternalAgent('copilot')); + svc.registerProvider(agent); + await svc.listSessions(); + + const first = agent.addSession('first', now); + const second = agent.addSession('second', now - 1); + const third = agent.addSession('third', now - 2); + const registry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; + const originalRegister = registry.register.bind(registry); + const registrationGate = new DeferredPromise(); + let registrationsStarted = 0; + registry.register = async (session, sessionOptions, registerOptions) => { + registrationsStarted++; + await registrationGate.p; + return originalRegister(session, sessionOptions, registerOptions); + }; + + const notifications: string[] = []; + disposables.add(svc.onDidNotification(notification => { + if (notification.type === NotificationType.SessionAdded) { + notifications.push(`add:${AgentSession.id(URI.parse(notification.summary.resource))}`); + } + })); + const registration = (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + { chat: URI.parse(buildDefaultChatUri(first)), startTime: now, modifiedTime: now, external: true }, + { chat: URI.parse(buildDefaultChatUri(second)), startTime: now - 1, modifiedTime: now - 1, external: true }, + { chat: URI.parse(buildDefaultChatUri(third)), startTime: now - 2, modifiedTime: now - 2, external: true }, + ]); + for (let attempt = 0; attempt < 20 && registrationsStarted < 3; attempt++) { + await timeout(0); + } + + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 2); + await waitForSessionListReconciliation(svc); + registrationGate.complete(); + await registration; + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), + notifications: notifications.sort(), + }, { + visible: ['first', 'second'], + notifications: ['add:first', 'add:second'], + }); + }); + + test('recent reconciles clients when a hidden external session becomes more recent', async () => { + const now = Date.now(); + const svc = createExternalSessionService(() => now); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + await waitForSessionListReconciliation(svc); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const first = agent.addSession('first', now - 1); + const second = agent.addSession('second', now - 2); + const third = agent.addSession('third', now - 3); + svc.registerProvider(agent); + await svc.listSessions(); + await waitForSessionListReconciliation(svc); + await svc.restoreSession(third); + + const notifications: string[] = []; + disposables.add(svc.onDidNotification(notification => { + if (notification.type === NotificationType.SessionAdded) { + notifications.push(`add:${AgentSession.id(URI.parse(notification.summary.resource))}`); + } else if (notification.type === NotificationType.SessionRemoved) { + notifications.push(`remove:${AgentSession.id(URI.parse(notification.session))}`); + } + })); + + svc.stateManager.dispatchServerAction(buildDefaultChatUri(third), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-third', + startedAt: new Date(now).toISOString(), + message: { text: 'Update', origin: { kind: MessageKind.User } }, + }); + await timeout(150); + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(), + notifications, + }, { + visible: [AgentSession.id(first), AgentSession.id(third)].sort(), + notifications: ['add:third', `remove:${AgentSession.id(second)}`], }); }); @@ -2802,6 +2941,8 @@ suite('AgentService (node dispatcher)', () => { test('unpublishes and republishes a restored external session as the configured mode changes', async () => { const now = Date.now(); const svc = createExternalSessionService(() => now); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 1); + await waitForSessionListReconciliation(svc); const agent = disposables.add(new TimedExternalAgent('copilot')); const session = agent.addSession('restored-external', now); const notifications: string[] = []; @@ -2817,10 +2958,10 @@ suite('AgentService (node dispatcher)', () => { await svc.restoreSession(session); notifications.length = 0; - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.None, 1); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.None, 2); await waitForSessionListReconciliation(svc); const hidden = (await svc.listSessions()).map(entry => entry.session.toString()); - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 2); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.All, 3); await waitForSessionListReconciliation(svc); assert.deepStrictEqual({ @@ -3193,6 +3334,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new GatedListAgent('copilot')); svc.registerProvider(agent); const legacy = AgentSession.uri('copilot', 'legacy-concurrent'); @@ -3242,6 +3384,7 @@ suite('AgentService (node dispatcher)', () => { } const db = new TestSessionDatabase(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new TransientListFailureAgent('copilot')); svc.registerProvider(agent); const legacy = AgentSession.uri('copilot', 'legacy-session'); @@ -3262,6 +3405,7 @@ suite('AgentService (node dispatcher)', () => { test('a late-registered provider gets its own native discovery pass', async () => { const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const early = disposables.add(new MockAgent('copilot')); svc.registerProvider(early); @@ -3399,6 +3543,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const providerA = disposables.add(new CountingAgent('copilot')); const providerB = disposables.add(new FailingThenRecoveringAgent('other')); @@ -3449,6 +3594,7 @@ suite('AgentService (node dispatcher)', () => { } } const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new NotYetEnumerableAgent('copilot')); const originalListExternalChats = agent.listExternalChats.bind(agent); (agent as unknown as { listExternalChats: () => Promise }).listExternalChats = async () => { @@ -3724,6 +3870,7 @@ suite('AgentService (node dispatcher)', () => { // Simulate an old database whose legacy one-time marker is set. await db.markSessionRegistryBackfilled(); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new CountingAgent('copilot')); const legacy = AgentSession.uri('copilot', 'old-db-native-session'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); @@ -3937,6 +4084,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -3982,6 +4130,7 @@ suite('AgentService (node dispatcher)', () => { (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4004,6 +4153,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4024,6 +4174,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(toDisposable(() => agent.dispose())); (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4041,6 +4192,7 @@ suite('AgentService (node dispatcher)', () => { }; (agent as unknown as { _sessions: Map })._sessions.set(sessionId, sessionUri); const svc = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4066,6 +4218,7 @@ suite('AgentService (node dispatcher)', () => { return []; }; const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, gitService)); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.registerProvider(agent); const sessions = await svc.listSessions(); @@ -4100,6 +4253,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getWorktreeRoots = async () => [primaryRoot, linkedCheckout, sessionWorktree]; const sessionDataService = createSessionDataService(db); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -4146,6 +4300,7 @@ suite('AgentService (node dispatcher)', () => { gitService.getDefaultBranch = async () => ({ name: 'main', startPoint: 'main' }); const sessionDataService = createSessionDataService(db); const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); svc.setWorktreeIsolation(disposables.add(new WorktreeIsolation( { generateBranchName: async () => 'agents/test' }, gitService, @@ -10028,6 +10183,7 @@ suite('AgentService (node dispatcher)', () => { { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, ]; await service.restoreSession(sessionResource); + await (service as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; agent.events.length = 0; service.addSubscriber(sessionResource, 'client-1'); service.unsubscribe(sessionResource, 'client-1'); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts index eafd05292e3..074390d8670 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts @@ -14,6 +14,7 @@ import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registr import type { ListSessionsResult } from '../../../common/state/sessionProtocol.js'; import { buildDefaultChatUri, ResponsePartKind, ROOT_STATE_URI, SessionStatus, type MarkdownResponsePart, type ISessionWithDefaultChat, type ToolCallResponsePart } from '../../../common/state/sessionState.js'; import { AgentHostSessionReleaseGraceMsEnvVar } from '../../../common/agentService.js'; +import { AgentHostExternalSessionsMode, AgentHostShowExternalSessionsConfigKey } from '../../../common/agentHostSchema.js'; import { PRE_EXISTING_SESSION_URI } from '../mockAgent.js'; import { createAndSubscribeSession, @@ -129,7 +130,25 @@ suite('Protocol WebSocket — Session Lifecycle', function () { // through the server's handleCreateSession -- simulating a session // from a previous server lifetime. const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); + client.notify('dispatchAction', { + channel: ROOT_STATE_URI, + clientSeq: 1, + action: { + type: 'root/configChanged', + config: { [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }, + }, + }); + await client.call('ping'); const list = await client.call('listSessions', { channel: ROOT_STATE_URI }); + client.notify('dispatchAction', { + channel: ROOT_STATE_URI, + clientSeq: 2, + action: { + type: 'root/configChanged', + config: { [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.None }, + }, + }); + await client.call('ping'); const preExisting = list.items.find(s => s.resource === preExistingUri); assert.ok(preExisting, 'listSessions should include the pre-existing session'); diff --git a/src/vs/platform/chat/common/chatSettings.ts b/src/vs/platform/chat/common/chatSettings.ts index decb13aa7fa..397749566d3 100644 --- a/src/vs/platform/chat/common/chatSettings.ts +++ b/src/vs/platform/chat/common/chatSettings.ts @@ -10,6 +10,7 @@ export const ChatEditAutoApproveSettingId = 'chat.tools.edits.autoApprove'; export type ChatEditAutoApprovePatterns = Readonly>; export const enum ChatExternalSessionsMode { + Recent = 'recent', None = 'none', All = 'all', Last24Hours = 'last24Hours', diff --git a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts index 333c2797148..965cb50ec3d 100644 --- a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts +++ b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts @@ -41,8 +41,10 @@ interface IExternalSessionBannerOptions { readonly onDidDismissWithFocus?: () => void; } -export function willExternalSessionBeHidden(mode: ChatExternalSessionsMode, updatedAt: Date, now: number): boolean { +export function shouldConfirmExternalSessionVisibilityChange(mode: ChatExternalSessionsMode, updatedAt: Date, now: number): boolean { switch (mode) { + case ChatExternalSessionsMode.Recent: + return true; case ChatExternalSessionsMode.None: return true; case ChatExternalSessionsMode.All: @@ -55,9 +57,20 @@ export function willExternalSessionBeHidden(mode: ChatExternalSessionsMode, upda } export function getExternalSessionVisibilityConfirmation(mode: ChatExternalSessionsMode, updatedAt: Date, now: number, productName: string): IConfirmation { - const message = localize('externalSessionBanner.confirm.message', "This session will no longer appear in {0}", productName); + const message = mode === ChatExternalSessionsMode.Recent + ? localize('externalSessionBanner.confirm.recent.message', "This session may no longer appear in {0}", productName) + : localize('externalSessionBanner.confirm.message', "This session will no longer appear in {0}", productName); const primaryButton = localize({ key: 'externalSessionBanner.confirm.save', comment: ['&& denotes a mnemonic'] }, "&&Save Anyway"); + if (mode === ChatExternalSessionsMode.Recent) { + return { + type: 'warning', + message, + detail: localize('externalSessionBanner.confirm.recent.detail', "Only the 2 most recently updated external sessions from the last 7 days will be shown. Are you sure you want to save this change?"), + primaryButton, + }; + } + if (mode === ChatExternalSessionsMode.None) { return { type: 'warning', @@ -208,6 +221,13 @@ export class ExternalSessionBanner extends Disposable { description: localize('externalSessionBanner.select.none.description', "Do not show sessions created in another application."), }, }, + { + mode: ChatExternalSessionsMode.Recent, + item: { + text: localize('externalSessionBanner.select.recent', "Recent"), + description: localize('externalSessionBanner.select.recent.description', "Show the 2 most recently updated external sessions from the last 7 days."), + }, + }, { mode: ChatExternalSessionsMode.Last24Hours, item: { @@ -219,7 +239,7 @@ export class ExternalSessionBanner extends Disposable { mode: ChatExternalSessionsMode.Last7Days, item: { text: localize('externalSessionBanner.select.last7Days', "Last 7 Days"), - description: localize('externalSessionBanner.select.last7Days.description', "Show external sessions updated in the last 7 days. This is the default."), + description: localize('externalSessionBanner.select.last7Days.description', "Show external sessions updated in the last 7 days."), }, }, { @@ -274,7 +294,7 @@ export class ExternalSessionBanner extends Disposable { try { const now = Date.now(); const updatedAt = session.updatedAt.get(); - if (willExternalSessionBeHidden(mode, updatedAt, now)) { + if (shouldConfirmExternalSessionVisibilityChange(mode, updatedAt, now)) { const confirmation = await this._dialogService.confirm(getExternalSessionVisibilityConfirmation(mode, updatedAt, now, this._productService.nameShort)); if (!confirmation.confirmed) { return; diff --git a/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts index 66a1ab03513..c5841d767e5 100644 --- a/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/externalSessionBanner.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ChatExternalSessionsMode } from '../../../../../platform/chat/common/chatSettings.js'; -import { getExternalSessionVisibilityConfirmation, willExternalSessionBeHidden } from '../../browser/externalSessionBanner.js'; +import { getExternalSessionVisibilityConfirmation, shouldConfirmExternalSessionVisibilityChange } from '../../browser/externalSessionBanner.js'; suite('Sessions - External Session Banner', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -16,13 +16,15 @@ suite('Sessions - External Session Banner', () => { const now = Date.UTC(2026, 7, 16, 12); assert.deepStrictEqual({ - none: willExternalSessionBeHidden(ChatExternalSessionsMode.None, new Date(now), now), - all: willExternalSessionBeHidden(ChatExternalSessionsMode.All, new Date(0), now), - at24Hours: willExternalSessionBeHidden(ChatExternalSessionsMode.Last24Hours, new Date(now - day), now), - olderThan24Hours: willExternalSessionBeHidden(ChatExternalSessionsMode.Last24Hours, new Date(now - day - 1), now), - at7Days: willExternalSessionBeHidden(ChatExternalSessionsMode.Last7Days, new Date(now - 7 * day), now), - olderThan7Days: willExternalSessionBeHidden(ChatExternalSessionsMode.Last7Days, new Date(now - 7 * day - 1), now), + recent: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Recent, new Date(now), now), + none: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.None, new Date(now), now), + all: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.All, new Date(0), now), + at24Hours: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last24Hours, new Date(now - day), now), + olderThan24Hours: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last24Hours, new Date(now - day - 1), now), + at7Days: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last7Days, new Date(now - 7 * day), now), + olderThan7Days: shouldConfirmExternalSessionVisibilityChange(ChatExternalSessionsMode.Last7Days, new Date(now - 7 * day - 1), now), }, { + recent: true, none: true, all: false, at24Hours: false, @@ -46,4 +48,18 @@ suite('Sessions - External Session Banner', () => { } ); }); + + test('warns that recent may hide the open session', () => { + const now = Date.UTC(2026, 7, 16, 12); + + assert.deepStrictEqual( + getExternalSessionVisibilityConfirmation(ChatExternalSessionsMode.Recent, new Date(now), now, 'Code - OSS'), + { + type: 'warning', + message: 'This session may no longer appear in Code - OSS', + detail: 'Only the 2 most recently updated external sessions from the last 7 days will be shown. Are you sure you want to save this change?', + primaryButton: '&&Save Anyway', + } + ); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts index 98f027be079..603d6cec716 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/externalSessionsFilterMenu.ts @@ -14,6 +14,7 @@ import { ChatConfiguration } from '../../common/constants.js'; const externalSessionOptions = [ { mode: ChatExternalSessionsMode.None, title: localize2('agentSessions.filter.external.none', "None") }, + { mode: ChatExternalSessionsMode.Recent, title: localize2('agentSessions.filter.external.recent', "Recent") }, { mode: ChatExternalSessionsMode.Last24Hours, title: localize2('agentSessions.filter.external.last24Hours', "Last 24 Hours") }, { mode: ChatExternalSessionsMode.Last7Days, title: localize2('agentSessions.filter.external.last7Days', "Last 7 Days") }, { mode: ChatExternalSessionsMode.All, title: localize2('agentSessions.filter.external.all', "All") }, diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index aa9d945f7c4..d5c5284df09 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -398,14 +398,15 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.ShowExternalAgentSessions]: { type: 'string', - enum: [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.All, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days], + enum: [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days, AgentHostExternalSessionsMode.All], enumDescriptions: [ nls.localize('chat.agentSessions.showExternal.none', "Only shows sessions created by the Agent Host."), - nls.localize('chat.agentSessions.showExternal.all', "Shows all sessions discovered from supported external agent applications."), + nls.localize('chat.agentSessions.showExternal.recent', "Shows the 2 most recently updated external sessions from the last 7 days."), nls.localize('chat.agentSessions.showExternal.last24Hours', "Shows external sessions updated in the last 24 hours."), - nls.localize('chat.agentSessions.showExternal.last7Days', "Shows external sessions updated in the last 7 days. This is the default."), + nls.localize('chat.agentSessions.showExternal.last7Days', "Shows external sessions updated in the last 7 days."), + nls.localize('chat.agentSessions.showExternal.all', "Shows all sessions discovered from supported external agent applications."), ], - default: AgentHostExternalSessionsMode.Last7Days, + default: AgentHostExternalSessionsMode.None, markdownDescription: nls.localize('chat.agentSessions.showExternal', "Controls which external agent sessions, created outside VS Code's Agent Host, are shown."), agentHost: { key: AgentHostShowExternalSessionsConfigKey }, }, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts index 2268f9b2186..ea545123c34 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/externalSessionsFilterMenu.test.ts @@ -40,10 +40,10 @@ suite('External Sessions Filter Menu', () => { }, options: options.map(item => ({ title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, - checkedForLast7Days: getToggledExpression(item.command.toggled)?.evaluate({ + checkedForRecent: getToggledExpression(item.command.toggled)?.evaluate({ getValue: (key: string) => ( key === `config.${ChatConfiguration.ShowExternalAgentSessions}` - ? ChatExternalSessionsMode.Last7Days + ? ChatExternalSessionsMode.Recent : undefined ) as T, }), @@ -55,10 +55,11 @@ suite('External Sessions Filter Menu', () => { submenu: submenuId.id, }, options: [ - { title: 'None', checkedForLast7Days: false }, - { title: 'Last 24 Hours', checkedForLast7Days: false }, - { title: 'Last 7 Days', checkedForLast7Days: true }, - { title: 'All', checkedForLast7Days: false }, + { title: 'None', checkedForRecent: false }, + { title: 'Recent', checkedForRecent: true }, + { title: 'Last 24 Hours', checkedForRecent: false }, + { title: 'Last 7 Days', checkedForRecent: false }, + { title: 'All', checkedForRecent: false }, ], }); });