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>
This commit is contained in:
Benjamin Christopher Simmonds
2026-08-17 13:12:48 +00:00
committed by GitHub
co-authored by Copilot
parent e0d62973af
commit 7ee8d7166b
11 changed files with 314 additions and 39 deletions
@@ -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<boolean>({
type: 'boolean',
@@ -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',
+64 -7
View File
@@ -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<boolean>(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<void> {
@@ -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<string> {
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<string>,
): 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;
}
@@ -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, string[]> = {
[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<boolean> })._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<void>();
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<boolean> })._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<readonly IAgentChatMetadata[] | undefined> }).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<string, URI> })._sessions.set(AgentSession.id(legacy), legacy);
@@ -3937,6 +4084,7 @@ suite('AgentService (node dispatcher)', () => {
(agent as unknown as { _sessions: Map<string, URI> })._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<string, URI> })._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<string, URI> })._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<string, URI> })._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<string, URI> })._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<void> })._sessionListReconciliation;
agent.events.length = 0;
service.addSubscriber(sessionResource, 'client-1');
service.unsubscribe(sessionResource, 'client-1');
@@ -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<ListSessionsResult>('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');
@@ -10,6 +10,7 @@ export const ChatEditAutoApproveSettingId = 'chat.tools.edits.autoApprove';
export type ChatEditAutoApprovePatterns = Readonly<Record<string, boolean>>;
export const enum ChatExternalSessionsMode {
Recent = 'recent',
None = 'none',
All = 'all',
Last24Hours = 'last24Hours',
@@ -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;
@@ -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',
}
);
});
});
@@ -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") },
@@ -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 },
},
@@ -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: <T extends ContextKeyValue = ContextKeyValue>(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 },
],
});
});