diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index ce5eadb7faf9cf..63fc5bc410f362 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -220,7 +220,7 @@ a chat URI. New provider code must consume the seams. `register` takes the resolved provenance and whether to check tombstones. Explicit `AgentService.createSession` calls skip the tombstone check and clear any tombstone for that session URI; restore and discovery calls atomically decline to register if the session is or concurrently becomes tombstoned. An explicit row is never rewritten by catalog discovery. A migration-time host-owned marker can correct a previously discovered row back to internal provenance. -Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification when it additively registers the event payload. Every provider starts one memoized initial attempt when the first discovery-event listener is attached; that attempt retries internally, but once it settles it is not re-armed by SDK readiness, so the only later trigger is an explicit one (for Copilot, the migrate-legacy toggle). Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. +Providers own discovery lifecycle and push unknown chats with provider-classified provenance through `onDidDiscoverChats`. Claude, Codex, and Copilot classify their unknown native chats as external, except that Copilot keeps an unknown *legacy extension-host* chat internal because it is adoptable in place rather than someone else's session. Agent Service preserves that classification when it additively registers the event payload. Agent Service always attaches the event listener and queues each provider's external-session discovery through `_runWhenStartupSettled`, so the request waits for both Agent Host startup and the first successful session listing. Providers registered after that barrier opens run their queued work immediately, and a later transition from `none` starts discovery directly. Adopt-in-place legacy migration remains an independent provider-initialization trigger immediately after the discovery listener is attached, and another catalog consumer may also trigger discovery after it enumerates the provider catalog. This keeps `showExternalSessions: none` from initiating native discovery while allowing independently triggered discovery to populate the hidden registry normally. Ordinary list refreshes never enumerate provider catalogs. External discovery has no migration marker or Copilot migrate-legacy gate; only the adoptable legacy extension-host half of Copilot's payload is withheld while migrate-legacy is off. Discovery never prunes a registry row when a provider later omits it and filters subagents and marked internal chat backings. Discovery is registry-first: Agent Service hands each provider an optional `setKnownSessionsFilter` seam that answers, for a whole candidate set in one registry query, which sessions the host already owns. A provider drops those candidates before any per-session database open, and Copilot additionally skips adoptable legacy classification work (project/Git resolution) while migrate-legacy is off, since those candidates would not be emitted. Agent Service in turn rejects an already-registered candidate before `_isChatBacking()` or any other per-session I/O; provenance of a registered row stays owned by the explicit create/restore paths. Tombstoned sessions are absent from the registry and therefore never reported as known, so an explicitly deleted session still reaches `register`, whose atomic tombstone check declines it. diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 7f0a8469d1e7d7..365af415f298e5 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1203,6 +1203,9 @@ export interface IAgent { /** Provides chats that are ready to be registered as Agent Host sessions. */ readonly onDidDiscoverChats: Event; + /** Starts the provider's memoized native chat discovery pass. */ + startChatDiscovery?(): Promise; + /** Lets discovery drop registered candidates before per-session I/O. */ setKnownSessionsFilter?(filter: IAgentKnownSessionsFilter): void; diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 8cc3f0de13c064..bd4e2dd1b72a3b 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -824,7 +824,7 @@ export const platformRootSchema = createSchema({ enum: [ChatExternalSessionsMode.None, ChatExternalSessionsMode.Recent, ChatExternalSessionsMode.Last24Hours, ChatExternalSessionsMode.Last7Days, ChatExternalSessionsMode.Last30Days], enumDescriptions: [ localize('agentHost.config.showExternalSessions.none', "Do not show external sessions."), - localize('agentHost.config.showExternalSessions.recent', "Show up to the 2 most recent external sessions updated in the last 7 days. Once at least 2 local sessions exist, external sessions older than the second-newest local session are hidden."), + localize('agentHost.config.showExternalSessions.recent', "Show up to the 2 most recent external sessions updated in the last 7 days. At startup, external sessions older than the second-most-recently updated local session are hidden."), localize('agentHost.config.showExternalSessions.last24Hours', "Show external sessions updated in the last 24 hours."), localize('agentHost.config.showExternalSessions.last7Days', "Show external sessions updated in the last 7 days."), localize('agentHost.config.showExternalSessions.last30Days', "Show external sessions updated in the last 30 days."), diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8a3206b03583c6..9ce231aff4d788 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -94,6 +94,7 @@ import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SU import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../common/agentHostGitStateService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostChatContributions } from '../common/agentHostChatContributionsService.js'; +import { IAgentHostStorageService } from './agentHostStorageService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -105,14 +106,17 @@ const SESSION_GC_GRACE_MS = 30_000; const DAY_MS = 24 * 60 * 60 * 1000; const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS; const RECENT_EXTERNAL_SESSION_LIMIT = 2; -/** - * How many locally created sessions must postdate an external session's last - * update before {@link AgentHostExternalSessionsMode.Recent} stops surfacing it. - */ -const RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT = 2; +const RECENT_LOCAL_SESSION_UPDATE_LIMIT = 2; +const RECENT_LOCAL_SESSION_UPDATES_STORAGE_KEY = 'recentLocalSessionUpdates'; /** A catalog pass slower than this is logged at info, since it delays every session-list refresh. */ const SLOW_LIST_SESSIONS_THRESHOLD_MS = 1_000; +/** A recent update to one local Agent Host session. */ +interface IRecentLocalSessionUpdate { + readonly session: string; + readonly modifiedTime: number; +} + type AgentHostLegacyMigrationEvent = { provider: string; outcome: 'migrated' | 'skipped' | 'failed'; @@ -204,6 +208,12 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } +function isRecentLocalSessionUpdate(value: unknown): value is IRecentLocalSessionUpdate { + return isRecord(value) + && typeof value.session === 'string' + && Number.isFinite(value.modifiedTime); +} + function isPersistedAnnotationEntry(value: unknown): value is AnnotationEntry { if (!isRecord(value) || typeof value.id !== 'string') { return false; @@ -444,6 +454,8 @@ export class AgentService extends Disposable implements IAgentService { private readonly _orchestratorDatabase: IAgentHostDatabase; /** Serializes durable last-modified advances emitted by live session state. */ private _sessionModifiedTimeWrites: Promise = Promise.resolve(); + private readonly _recentLocalSessionUpdateSnapshot: readonly IRecentLocalSessionUpdate[]; + private _recentLocalSessionUpdates: readonly IRecentLocalSessionUpdate[]; private readonly _providerMigrations = new Map(); private readonly _initialProviderMigrations = new Map>(); @@ -593,6 +605,7 @@ export class AgentService extends Disposable implements IAgentService { @IInstantiationService instantiationService: IInstantiationService, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, + @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, ) { super(); this._authService = core.authenticationService; @@ -601,6 +614,8 @@ export class AgentService extends Disposable implements IAgentService { this._sessionRegistry = core.sessionRegistry; this._stateManager = core.stateManager; this._configurationService = core.configurationService; + this._recentLocalSessionUpdateSnapshot = this._readRecentLocalSessionUpdates(); + this._recentLocalSessionUpdates = this._recentLocalSessionUpdateSnapshot; this.onMcpNotification = this._providerService.onMcpNotification; this._gitHubEndpointService = collaborators.gitHubEndpointService; this._gitStateService = collaborators.gitStateService; @@ -694,7 +709,14 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => { const meta = this._stateManager.getSessionSummary(session)?._meta; if (changes.modifiedAt !== undefined) { - this._writeSessionModifiedTime(URI.parse(session), Date.parse(changes.modifiedAt)); + const modifiedTime = Date.parse(changes.modifiedAt); + if (!readSessionExternal(meta) + && !isSubagentSession(session) + && !this._stateManager.isEphemeralSession(session) + && !this._stateManager.isIdleProvisionalSession(session)) { + this._recordRecentLocalSessionUpdate(URI.parse(session), modifiedTime); + } + this._writeSessionModifiedTime(URI.parse(session), modifiedTime); } if (changes.modifiedAt !== undefined && this._getExternalSessionsMode() === AgentHostExternalSessionsMode.Recent @@ -712,10 +734,12 @@ export class AgentService extends Disposable implements IAgentService { if (nextMode !== externalSessionsMode) { const previousMode = externalSessionsMode; externalSessionsMode = nextMode; - // The only point past startup where `Recent` re-measures the - // superseding local sessions. - this._invalidateRecentSupersedingCutoff(); this._logService.info(`[AgentService] ${AgentHostShowExternalSessionsConfigKey} changed '${previousMode}' -> '${nextMode}'; queueing session list reconciliation`); + if (this._startupSettled.isOpen() && this._hidesAllExternalSessions(previousMode) && !this._hidesAllExternalSessions(nextMode)) { + for (const provider of this._providerService.getProviders()) { + this._startChatDiscovery(provider, 'external sessions were enabled'); + } + } this._queueSessionListReconciliation(previousMode); } const nextAgentMergeEnabled = this._isAgentMergeEnabled(); @@ -758,6 +782,9 @@ export class AgentService extends Disposable implements IAgentService { * ambient timer of its own. */ markStartupComplete(): void { + if (this._hostStartupComplete) { + return; + } this._hostStartupComplete = true; this._openStartupSettled(); } @@ -774,7 +801,7 @@ export class AgentService extends Disposable implements IAgentService { * compete with startup — pruning stale external sessions, titling external * sessions a provider surfaced without a title, and similar. */ - private _runWhenStartupSettled(name: string, work: () => Promise): void { + private _runWhenStartupSettled(name: string, work: () => void | Promise): void { this._deferredWork = this._deferredWork .then(() => this._startupSettled.wait()) .then(() => this._store.isDisposed ? undefined : work()) @@ -1040,6 +1067,7 @@ export class AgentService extends Disposable implements IAgentService { void this._migrateAndRegisterDiscoveredChats(provider, chats).catch(err => this._logService.warn(`[AgentService] registering discovered chats for provider ${provider.id} failed`, err)); })); + this._setupChatDiscoveryForProvider(provider); subscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); subscriptions.add(provider.onDidSpawnChat(e => this._onChatSpawned(e))); this._providerSubscriptions.set(provider.id, subscriptions); @@ -1054,6 +1082,18 @@ export class AgentService extends Disposable implements IAgentService { } } + private _setupChatDiscoveryForProvider(provider: IAgent): void { + if (this._migrateLegacyEnabledSnapshot === true && provider.ensureChatAdopted) { + this._startChatDiscovery(provider, 'legacy chat migration is enabled'); + } else { + this._runWhenStartupSettled(`external session discovery for ${provider.id}`, () => { + if (!this._hidesAllExternalSessions(this._getExternalSessionsMode())) { + this._startChatDiscovery(provider, 'Agent Host startup settled with external sessions enabled'); + } + }); + } + } + private _onDidRegisterProvider(provider: IAgent): void { this._registerSkillCompletionProvider(); const initialMigration = this._ensureLegacyChatsMigrated(provider); @@ -1869,6 +1909,7 @@ export class AgentService extends Disposable implements IAgentService { await this._sessionRegistry.markProviderBackfilled(provider.id); this._deferredProviderMigrations.delete(provider.id); this._readableProviderCatalogs.add(provider.id); + this._startChatDiscovery(provider, 'legacy migration enumerated the provider catalog'); if (registeredExternal) { this._queueSessionListReconciliation(); } @@ -2024,7 +2065,7 @@ export class AgentService extends Disposable implements IAgentService { // Callers own their array; the shared result must not be mutable by one of them. return [...await inFlight.promise]; } - const promise = this._computeSessions(mode, epoch); + const promise = this._computeSessions(mode); const entry = { epoch, promise }; this._inFlightListSessions.set(mode, entry); const clear = () => { @@ -2045,7 +2086,7 @@ export class AgentService extends Disposable implements IAgentService { return [...await promise]; } - private async _computeSessions(mode: AgentHostExternalSessionsMode, epoch = this._registryEpoch): Promise { + private async _computeSessions(mode: AgentHostExternalSessionsMode): Promise { this._logService.trace('[AgentService] listSessions computation started'); const startedAt = Date.now(); // The first list waits for registration-time legacy migration if it is still in flight. @@ -2270,7 +2311,7 @@ export class AgentService extends Disposable implements IAgentService { const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; const now = Date.now(); const recentSessionKeys = mode === AgentHostExternalSessionsMode.Recent - ? this._getRecentSessionKeys(combined, now, this._resolveRecentSupersedingCutoff(allRegistered, epoch)) + ? this._getRecentSessionKeys(combined, now) : undefined; const visible: IAgentSessionMetadata[] = []; // Adoptable-legacy rows are withheld by migrate-legacy, not by the external mode. @@ -2326,16 +2367,22 @@ export class AgentService extends Disposable implements IAgentService { return this._configurationService.getRootValue(platformRootSchema, AgentHostShowExternalSessionsConfigKey) ?? AgentHostExternalSessionsMode.None; } + private _startChatDiscovery(provider: IAgent, reason: string): void { + void provider.startChatDiscovery?.().catch(error => + this._logService.warn(`[AgentService] Chat discovery for provider ${provider.id} failed after ${reason}`, error)); + } + private _isExternalSessionOlderThanMaxAge(modifiedTime: number, now: number): boolean { return modifiedTime < now - EXTERNAL_SESSION_MAX_AGE_MS; } - private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet { + private _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number): ReadonlySet { + const supersededBefore = this._getRecentLocalSessionUpdateCutoff(now); const recentExternalSessions = sessions .filter(session => readSessionExternal(session._meta) && !readSessionEhcliAdoptable(session._meta) && session.modifiedTime >= now - 7 * DAY_MS - && (supersededBefore === undefined || session.modifiedTime >= supersededBefore)) + && session.modifiedTime >= supersededBefore) .sort((a, b) => { const timeDifference = b.modifiedTime - a.modifiedTime; if (timeDifference !== 0) { @@ -2349,47 +2396,60 @@ export class AgentService extends Disposable implements IAgentService { return new Set(recentExternalSessions.map(session => session.session.toString())); } - /** - * Start time of the {@link RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT}-th most - * recently created local session, or `undefined` while fewer exist. `Recent` - * drops external sessions last updated before it. - */ - private _recentSupersedingCutoff: number | undefined; - private _hasRecentSupersedingCutoff = false; + private _getRecentLocalSessionUpdateCutoff(now: number): number { + return this._recentLocalSessionUpdateSnapshot[RECENT_LOCAL_SESSION_UPDATE_LIMIT - 1]?.modifiedTime ?? now - 7 * DAY_MS; + } - /** - * Snapshots the cutoff from the registry, which — unlike the hydrated - * metadata — never drops a local session because its provider is - * unavailable or its metadata read failed. Sending a first message - * materializes a local session, so a per-listing cutoff would rotate an - * external row out of the list mid-use. Committed only while `epoch` still - * holds, so a discarded pass cannot freeze an undercounted value. - */ - private _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined { - if (this._hasRecentSupersedingCutoff) { - return this._recentSupersedingCutoff; - } - // Idle provisional sessions are the composer's eagerly-created - // placeholder, not sessions the user started. - const localStartTimes = registered - .filter(entry => !entry.external - && Number.isFinite(entry.startTime) - && !this._stateManager.isIdleProvisionalSession(entry.session.toString())) - .map(entry => entry.startTime) - .sort((a, b) => b - a); - const cutoff = localStartTimes.length >= RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT - ? localStartTimes[RECENT_EXTERNAL_SUPERSEDING_LOCAL_LIMIT - 1] - : undefined; - if (epoch === this._registryEpoch) { - this._recentSupersedingCutoff = cutoff; - this._hasRecentSupersedingCutoff = true; + private _recordRecentLocalSessionUpdate(session: URI, modifiedTime: number): void { + if (!Number.isFinite(modifiedTime)) { + return; + } + + const sessionKey = session.toString(); + const existing = this._recentLocalSessionUpdates.find(entry => entry.session === sessionKey); + if (existing && existing.modifiedTime >= modifiedTime) { + return; + } + + const next = [ + ...this._recentLocalSessionUpdates.filter(entry => entry.session !== sessionKey), + { session: sessionKey, modifiedTime }, + ] + .sort((a, b) => b.modifiedTime - a.modifiedTime || a.session.localeCompare(b.session)) + .slice(0, RECENT_LOCAL_SESSION_UPDATE_LIMIT); + if (next.length === this._recentLocalSessionUpdates.length + && next.every((entry, index) => entry.session === this._recentLocalSessionUpdates[index].session + && entry.modifiedTime === this._recentLocalSessionUpdates[index].modifiedTime)) { + return; + } + + this._recentLocalSessionUpdates = next; + if (!this._storageService.loadError) { + this._storageService.set(RECENT_LOCAL_SESSION_UPDATES_STORAGE_KEY, next); } - return cutoff; } - private _invalidateRecentSupersedingCutoff(): void { - this._hasRecentSupersedingCutoff = false; - this._recentSupersedingCutoff = undefined; + private _readRecentLocalSessionUpdates(): readonly IRecentLocalSessionUpdate[] { + if (this._storageService.loadError) { + this._logService.warn('[AgentService] Recent local session updates could not be restored because Agent Host storage failed to load.'); + return []; + } + const stored = this._storageService.get(RECENT_LOCAL_SESSION_UPDATES_STORAGE_KEY); + if (stored === undefined) { + return []; + } + if (!Array.isArray(stored) + || stored.length > RECENT_LOCAL_SESSION_UPDATE_LIMIT + || !stored.every(isRecentLocalSessionUpdate)) { + this._logService.warn('[AgentService] Ignoring invalid persisted recent local session updates.'); + return []; + } + const updates: readonly IRecentLocalSessionUpdate[] = stored; + if (new Set(updates.map(entry => entry.session)).size !== updates.length) { + this._logService.warn('[AgentService] Ignoring persisted recent local session updates with duplicate sessions.'); + return []; + } + return updates.toSorted((a, b) => b.modifiedTime - a.modifiedTime || a.session.localeCompare(b.session)); } private _shouldIncludeSession( @@ -2530,7 +2590,7 @@ export class AgentService extends Disposable implements IAgentService { previouslyExposed.add(session); } const listed = previousMode !== undefined - ? await this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed) + ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.Last30Days), previousMode, previouslyExposed) : await this.listSessions(); const visible = new Set(); let published = 0; @@ -2584,20 +2644,15 @@ export class AgentService extends Disposable implements IAgentService { * mode and the mode is just a parameter to {@link _shouldIncludeSession}. * Adds what `previousMode` had exposed into `previouslyExposed`. */ - private async _resolveModeChangeVisibility( + private _resolveModeChangeVisibility( superset: readonly IAgentSessionMetadata[], previousMode: AgentHostExternalSessionsMode, previouslyExposed: Set, - ): Promise { + ): IAgentSessionMetadata[] { const now = Date.now(); const mode = this._getExternalSessionsMode(); - // The pass above ran as `Last30Days`, so it never snapshotted the cutoff. - const epoch = this._registryEpoch; - const supersededBefore = previousMode === AgentHostExternalSessionsMode.Recent || mode === AgentHostExternalSessionsMode.Recent - ? this._resolveRecentSupersedingCutoff(await this._listRegisteredSessions(), epoch) - : undefined; const recentKeysFor = (candidate: AgentHostExternalSessionsMode) => candidate === AgentHostExternalSessionsMode.Recent - ? this._getRecentSessionKeys(superset, now, supersededBefore) + ? this._getRecentSessionKeys(superset, now) : undefined; const previousRecentKeys = recentKeysFor(previousMode); @@ -2710,6 +2765,7 @@ export class AgentService extends Disposable implements IAgentService { this._createProviderSession(provider, config, deferWorktreeCreation), ]); const session = created.session; + const isIdleProvisional = created.provisional === true && !config?.importConversation; this._logService.trace(`[AgentService] createSession: initialization complete`); const creationReference = readSessionCreationReference(config?._meta); if (creationReference && !isEphemeral) { @@ -2728,7 +2784,9 @@ export class AgentService extends Disposable implements IAgentService { () => this._sessionRegistry.tombstone(session), `tombstoning ephemeral session ${session.toString()}`, ); - this._invalidateSessionList(); + if (!isIdleProvisional) { + this._invalidateSessionList(); + } } catch (err) { await this._rollbackProviderSession(provider, session); throw err; @@ -2740,7 +2798,9 @@ export class AgentService extends Disposable implements IAgentService { () => this._sessionRegistry.register(session, { provider: provider.id, startTime: registeredAt, modifiedTime: registeredAt, source: 'explicit' }, { checkTombstone: false }), `registration for ${session.toString()}`, ); - this._invalidateSessionList(); + if (!isIdleProvisional) { + this._invalidateSessionList(); + } } catch (err) { await this._rollbackProviderSession(provider, session); throw err; @@ -2780,7 +2840,7 @@ export class AgentService extends Disposable implements IAgentService { // updates while resolving that snapshot; without a state entry those // actions are rejected as targeting an unknown session and custom agents // can disappear from the picker permanently. - const provisionalState = created.provisional && !config?.importConversation + const provisionalState = isIdleProvisional ? (() => { const summary = this._buildInitialSummary(provider, session, config, created, ''); const state = this._stateManager.createSession(summary, { emitNotification: false }); @@ -3876,6 +3936,7 @@ export class AgentService extends Disposable implements IAgentService { const sessionKey = session.toString(); this._cancelPendingSessionGc(session); const isEphemeral = this._stateManager.isEphemeralSession(sessionKey); + const isIdleProvisional = this._stateManager.isIdleProvisionalSession(sessionKey); this._stateManager.invalidateSessionChatResolutions(session.toString()); const sessionChats = this._stateManager.getSessionState(session.toString())?.chats ?? []; for (const chat of sessionChats) { @@ -3900,7 +3961,9 @@ export class AgentService extends Disposable implements IAgentService { `unregistration for ${session.toString()}`, ); } - this._invalidateSessionList(); + if (!isIdleProvisional) { + this._invalidateSessionList(); + } if (provider) { this._providerService.releaseSession(session.toString()); this._clearDownloadProgressInterest(session.toString()); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index 67111ec4d61964..5d05bf9af346b0 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -429,12 +429,7 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _onDidSpawnChat = this._register(new Emitter()); readonly onDidSpawnChat: Event = this._onDidSpawnChat.event; - private readonly _onDidDiscoverChats = this._register(new Emitter({ - // Discovery is provider-owned and only has observable value once the host - // subscribes. Registered chats remain independently available through - // listChatsToMigrate(). - onDidAddFirstListener: () => { void this._startClaudeCodeChatDiscovery(); }, - })); + private readonly _onDidDiscoverChats = this._register(new Emitter()); readonly onDidDiscoverChats = this._onDidDiscoverChats.event; private _claudeCodeChatDiscovery: Promise | undefined; @@ -2054,6 +2049,10 @@ export class ClaudeAgent extends Disposable implements IAgent { })); } + startChatDiscovery(): Promise { + return this._startClaudeCodeChatDiscovery(); + } + async listChatsToMigrate(): Promise { if (!(await this._sdkService.canLoadWithoutDownload())) { this._logService.info('[Claude] SDK not downloaded yet; deferring the migratable chat list'); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 7eb859e92c34d1..e3ddadf1cc5136 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -1116,10 +1116,9 @@ export class CodexAgent extends Disposable implements IAgent { private _transientAccountConnection: IConnectionReady | undefined; /** Owns a one-off connection even while its initialize handshake is pending. */ private _transientConnectionCancellation: CancellationTokenSource | undefined; - private readonly _onDidDiscoverChats = this._register(new Emitter({ - onDidAddFirstListener: () => { void this._startCodexChatDiscovery(); }, - })); + private readonly _onDidDiscoverChats = this._register(new Emitter()); readonly onDidDiscoverChats = this._onDidDiscoverChats.event; + private _chatDiscoveryRequested = false; private _codexChatDiscovery: Promise | undefined; private _modelsRefreshPromise: Promise | undefined; private readonly _modelRefreshSequencer = new Sequencer(); @@ -2105,7 +2104,7 @@ export class CodexAgent extends Disposable implements IAgent { // flight may have observed the inactive state and skipped Codex models. void this._queueModelRefresh(); void this._refreshProviderConfiguration(); - if (this._onDidDiscoverChats.hasListeners()) { + if (this._chatDiscoveryRequested) { void this._startCodexChatDiscovery(); } } @@ -6572,6 +6571,11 @@ export class CodexAgent extends Disposable implements IAgent { return known.filter((chat): chat is IAgentChatMetadata => chat !== undefined); } + startChatDiscovery(): Promise { + this._chatDiscoveryRequested = true; + return this._startCodexChatDiscovery(); + } + private _startCodexChatDiscovery(): Promise { if (this._isShuttingDown || this._store.isDisposed || !this._activated) { return Promise.resolve(); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 5d22a3acfca908..394d78c3e1d189 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -718,9 +718,7 @@ export class CopilotAgent extends Disposable implements IAgent { * Fires when the native chat catalog may have changed. The {@link AgentService} * responds with an additive discovery pass. */ - private readonly _onDidDiscoverChats = this._register(new Emitter({ - onDidAddFirstListener: () => { void this._startCopilotChatDiscovery(); }, - })); + private readonly _onDidDiscoverChats = this._register(new Emitter()); readonly onDidDiscoverChats = this._onDidDiscoverChats.event; /** * Per-session MCP notifications, fanned in from every active @@ -2391,6 +2389,10 @@ export class CopilotAgent extends Disposable implements IAgent { this._knownSessionsFilter = filter; } + startChatDiscovery(): Promise { + return this._startCopilotChatDiscovery(); + } + /** * One memoized initial discovery attempt, mirroring Claude and Codex. The * CLI client may still be starting when the first discovery listener diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index f1185d12619d08..8d0822826b7266 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -48,7 +48,7 @@ import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDe import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; -import { AgentSessionRegistry, type IRegisteredSession } from '../../node/agentSessionRegistry.js'; +import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AgentHostManagementService } from '../../node/agentHostManagementService.js'; import { AGENT_HOST_TITLE_SOURCE_AUTO, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; import { MockAgent, ScriptedMockAgent } from './mockAgent.js'; @@ -3186,7 +3186,24 @@ suite('AgentService (node dispatcher)', () => { } } - function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService): AgentService { + class ControlledDiscoveryAgent extends TimedExternalAgent { + discoveryStarts = 0; + + override async listExternalChats(): Promise { + return []; + } + + async startChatDiscovery(): Promise { + this.discoveryStarts++; + this.fireDiscoveredChats([...this.catalog.values()].map(entry => discoveredChat(entry.session, true, entry.modifiedTime))); + } + + async ensureChatAdopted(): Promise { + return { adopted: false, eligible: false }; + } + } + + function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService, storageResource?: URI): AgentService { return disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -3200,11 +3217,153 @@ suite('AgentService (node dispatcher)', () => { undefined, [], undefined, - undefined, + storageResource, orchestratorDatabase, )); } + testWithExternalSessionClock('external discovery waits for startup settlement after the setting enables it', async () => { + const database = new TransientRegistryWriteDatabase(); + await database.markProviderBackfilled('copilot'); + const svc = createExternalSessionService(createSessionDataService(), database); + const agent = disposables.add(new ControlledDiscoveryAgent('copilot')); + const external = agent.addSession('setting-enabled-discovery', Date.now()); + registerTestAgentProvider(svc, agent); + + const startsWhileDisabled = agent.discoveryStarts; + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + const startsBeforeStartupComplete = agent.discoveryStarts; + svc.markStartupComplete(); + const startsAfterStartupComplete = agent.discoveryStarts; + const initiallyVisible = await svc.listSessions(); + await svc.whenDeferredWorkSettled(); + for (let attempt = 0; attempt < 50 && (await svc.getRegisteredSessions()).length === 0; attempt++) { + await timeout(0); + } + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + initiallyVisible, + startsWhileDisabled, + startsBeforeStartupComplete, + startsAfterStartupComplete, + startsAfterStartupSettled: agent.discoveryStarts, + visibleAfterStartupSettled: (await svc.listSessions()).map(session => session.session.toString()), + }, { + initiallyVisible: [], + startsWhileDisabled: 0, + startsBeforeStartupComplete: 0, + startsAfterStartupComplete: 0, + startsAfterStartupSettled: 1, + visibleAfterStartupSettled: [external.toString()], + }); + }); + + testWithExternalSessionClock('enabling external sessions after startup settlement starts discovery', async () => { + const database = new TransientRegistryWriteDatabase(); + await database.markProviderBackfilled('copilot'); + const svc = createExternalSessionService(createSessionDataService(), database); + const agent = disposables.add(new ControlledDiscoveryAgent('copilot')); + const external = agent.addSession('post-startup-enablement', Date.now()); + registerTestAgentProvider(svc, agent); + svc.markStartupComplete(); + await svc.listSessions(); + await svc.whenDeferredWorkSettled(); + const startsBeforeEnablement = agent.discoveryStarts; + + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + for (let attempt = 0; attempt < 50 && (await svc.getRegisteredSessions()).length === 0; attempt++) { + await timeout(0); + } + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + startsBeforeEnablement, + startsAfterEnablement: agent.discoveryStarts, + visible: (await svc.listSessions()).map(session => session.session.toString()), + }, { + startsBeforeEnablement: 0, + startsAfterEnablement: 1, + visible: [external.toString()], + }); + }); + + testWithExternalSessionClock('a provider registered after startup starts external discovery immediately', async () => { + const database = new TransientRegistryWriteDatabase(); + await database.markProviderBackfilled('copilot'); + const svc = createExternalSessionService(createSessionDataService(), database); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); + svc.markStartupComplete(); + await svc.listSessions(); + await svc.whenDeferredWorkSettled(); + const agent = disposables.add(new ControlledDiscoveryAgent('copilot')); + const external = agent.addSession('late-provider-discovery', Date.now()); + + registerTestAgentProvider(svc, agent); + await svc.whenDeferredWorkSettled(); + for (let attempt = 0; attempt < 50 && (await svc.getRegisteredSessions()).length === 0; attempt++) { + await timeout(0); + } + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + discoveryStarts: agent.discoveryStarts, + visible: (await svc.listSessions()).map(session => session.session.toString()), + }, { + discoveryStarts: 1, + visible: [external.toString()], + }); + }); + + testWithExternalSessionClock('legacy migration can start discovery while external sessions are hidden', async () => { + const svc = createExternalSessionService(); + const agent = disposables.add(new ControlledDiscoveryAgent('copilot')); + const external = agent.addSession('migration-triggered-discovery', Date.now()); + registerTestAgentProvider(svc, agent); + + await svc.listSessions(); + for (let attempt = 0; attempt < 50 && (await svc.getRegisteredSessions()).length === 0; attempt++) { + await timeout(0); + } + + assert.deepStrictEqual({ + discoveryStarts: agent.discoveryStarts, + registered: (await svc.getRegisteredSessions()).map(session => session.toString()), + visible: await svc.listSessions(), + }, { + discoveryStarts: 1, + registered: [external.toString()], + visible: [], + }); + }); + + testWithExternalSessionClock('enabled legacy migration starts discovery when the provider registry is already backfilled', async () => { + const database = new TransientRegistryWriteDatabase(); + await database.markProviderBackfilled('copilot'); + const svc = createExternalSessionService(createSessionDataService(), database); + getConfigurationService(svc).updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); + svc.primeMigrateLegacyGate(); + const agent = disposables.add(new ControlledDiscoveryAgent('copilot')); + const external = agent.addSession('backfilled-legacy-discovery', Date.now()); + + registerTestAgentProvider(svc, agent); + for (let attempt = 0; attempt < 50 && (await svc.getRegisteredSessions()).length === 0; attempt++) { + await timeout(0); + } + + assert.deepStrictEqual({ + discoveryStarts: agent.discoveryStarts, + providerBackfilled: await svc.isProviderRegistryBackfilled('copilot'), + registered: (await svc.getRegisteredSessions()).map(session => session.toString()), + visible: await svc.listSessions(), + }, { + discoveryStarts: 1, + providerBackfilled: true, + registered: [external.toString()], + visible: [], + }); + }); + function testWithExternalSessionClock(name: string, fn: () => Promise): void { test(name, () => runWithFakedTimers({ useFakeTimers: true, @@ -3430,8 +3589,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - /** An external session two newer local sessions postdate is no longer recent. */ - test('recent drops external sessions that two newer local sessions superseded', () => { + test('recent keeps its startup snapshot while recording local session updates for the next restart', () => { const hour = 60 * 60 * 1000; const at = (hourOfDay: number) => Date.UTC(2026, 0, 1) + hourOfDay * hour; const now = at(18); @@ -3441,142 +3599,113 @@ suite('AgentService (node dispatcher)', () => { modifiedTime, _meta: withSessionExternal(undefined, true), }); - const local = (id: string, startTime: number): IRegisteredSession => ({ - session: AgentSession.uri('copilot', id), - provider: 'copilot', - startTime, - modifiedTime: startTime, - external: false, - source: 'restore', - }); const catalog = [external('external-morning', at(10)), external('external-afternoon', at(16))]; - // The cutoff is snapshotted per service, so each case needs its own. - const recentIds = (...locals: IRegisteredSession[]) => { - const svc = createExternalSessionService() as unknown as { - _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined; - _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet; - _registryEpoch: number; - }; - const cutoff = svc._resolveRecentSupersedingCutoff(locals, svc._registryEpoch); - return [...svc._getRecentSessionKeys(catalog, now, cutoff)].map(key => AgentSession.id(URI.parse(key))).sort(); + const svc = createExternalSessionService() as unknown as { + _recordRecentLocalSessionUpdate(session: URI, modifiedTime: number): void; + _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number): ReadonlySet; + _recentLocalSessionUpdates: readonly { session: string; modifiedTime: number }[]; }; + const recentIds = () => [...svc._getRecentSessionKeys(catalog, now)].map(key => AgentSession.id(URI.parse(key))).sort(); - assert.deepStrictEqual({ - noLocalSessionsAfter: recentIds(local('local-8am', at(8)), local('local-9am', at(9))), - oneLocalSessionAfter: recentIds(local('local-11am', at(11))), - twoLocalSessionsAfterTheMorningOne: recentIds(local('local-11am', at(11)), local('local-5pm', at(17))), - twoLocalSessionsAfterBoth: recentIds(local('local-5pm', at(17)), local('local-5pm-2', at(17))), + const initial = recentIds(); + svc._recordRecentLocalSessionUpdate(AgentSession.uri('copilot', 'local-first'), at(11)); + const afterOneLocalSession = recentIds(); + svc._recordRecentLocalSessionUpdate(AgentSession.uri('copilot', 'local-first'), at(17)); + const afterSameSessionUpdatesAgain = recentIds(); + svc._recordRecentLocalSessionUpdate(AgentSession.uri('copilot', 'local-second'), at(12)); + const afterTwoDifferentSessions = recentIds(); + svc._recordRecentLocalSessionUpdate(AgentSession.uri('copilot', 'local-third'), at(17)); + const afterThreeDifferentSessions = recentIds(); + + assert.deepStrictEqual({ + initial, + afterOneLocalSession, + afterSameSessionUpdatesAgain, + afterTwoDifferentSessions, + afterThreeDifferentSessions, + recordedSessions: svc._recentLocalSessionUpdates.map(entry => AgentSession.id(URI.parse(entry.session))), }, { - noLocalSessionsAfter: ['external-afternoon', 'external-morning'], - oneLocalSessionAfter: ['external-afternoon', 'external-morning'], - twoLocalSessionsAfterTheMorningOne: ['external-afternoon'], - twoLocalSessionsAfterBoth: [], + initial: ['external-afternoon', 'external-morning'], + afterOneLocalSession: ['external-afternoon', 'external-morning'], + afterSameSessionUpdatesAgain: ['external-afternoon', 'external-morning'], + afterTwoDifferentSessions: ['external-afternoon', 'external-morning'], + afterThreeDifferentSessions: ['external-afternoon', 'external-morning'], + recordedSessions: ['local-first', 'local-third'], }); }); - /** - * The cutoff reads the registry, not the hydrated listing: a local session - * whose provider is unavailable is dropped from the latter, which would - * undercount and leave a superseded external row visible. - */ - testWithExternalSessionClock('recent counts local sessions the provider cannot hydrate', async () => { - const hour = 60 * 60 * 1000; - const now = Date.now(); - const at = (hourOfDay: number) => now - (18 - hourOfDay) * hour; - const database = new TransientRegistryWriteDatabase(); - for (const [id, startTime] of [['external-morning', at(10)], ['external-afternoon', at(16)]] as const) { - await database.registerSession(AgentSession.uri('copilot', id).toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); - } - // Registered under a provider that is never registered with the service. - for (const [id, startTime] of [['local-11am', at(11)], ['local-5pm', at(17)]] as const) { - await database.registerSession(AgentSession.uri('claude', id).toString(), { provider: 'claude', startTime, source: 'restore' }, { checkTombstone: true }); - } - await database.markProviderBackfilled('copilot'); - - const svc = createExternalSessionService(createSessionDataService(), database); - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); - await waitForSessionListReconciliation(svc); - const agent = disposables.add(new TimedExternalAgent('copilot')); - agent.addSession('external-morning', at(10)); - agent.addSession('external-afternoon', at(16)); + test('recent local session activity follows summary updates outside Recent mode', async () => { + const svc = createExternalSessionService(); + const agent = disposables.add(new MockAgent('copilot')); registerTestAgentProvider(svc, agent); - - const listed = await svc.listSessions(); - - assert.deepStrictEqual({ - visible: listed.map(session => AgentSession.id(session.session)).sort(), - cutoffCountedUnhydratedLocals: (svc as unknown as { _recentSupersedingCutoff: number | undefined })._recentSupersedingCutoff === at(11), - }, { - visible: ['external-afternoon'], - cutoffCountedUnhydratedLocals: true, - }); - }); - - /** A stale pass must not freeze its cutoff: the registry changed under it. */ - test('recent does not commit a superseding cutoff computed for a stale registry epoch', () => { - const at = (hourOfDay: number) => Date.UTC(2026, 0, 1) + hourOfDay * 60 * 60 * 1000; - const svc = createExternalSessionService() as unknown as { - _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined; - _hasRecentSupersedingCutoff: boolean; - _registryEpoch: number; + const first = await svc.createSession({ provider: 'copilot' }); + const second = await svc.createSession({ provider: 'copilot' }); + const now = Date.now(); + const updateSession = async (session: URI, modifiedTime: number, turnId: string) => { + const modifiedAt = new Date(modifiedTime).toISOString(); + const changed = Event.toPromise(Event.filter( + getStateManager(svc).onDidChangeSessionSummary, + event => event.session === session.toString() && event.changes.modifiedAt === modifiedAt, + )); + getStateManager(svc).dispatchServerAction(buildDefaultChatUri(session), { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: modifiedAt, + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + await changed; }; - const locals: IRegisteredSession[] = [at(11), at(17)].map((startTime, index) => ({ - session: AgentSession.uri('copilot', `local-${index}`), - provider: 'copilot', - startTime, - modifiedTime: startTime, - external: false, - source: 'restore', - })); - const staleCutoff = svc._resolveRecentSupersedingCutoff(locals, svc._registryEpoch - 1); - const committedAfterStalePass = svc._hasRecentSupersedingCutoff; - const currentCutoff = svc._resolveRecentSupersedingCutoff(locals, svc._registryEpoch); + await updateSession(first, now + 60_000, 'turn-first'); + await updateSession(second, now + 120_000, 'turn-second'); - assert.deepStrictEqual({ staleCutoff, committedAfterStalePass, currentCutoff, committedAfterCurrentPass: svc._hasRecentSupersedingCutoff }, { - staleCutoff: at(11), - committedAfterStalePass: false, - currentCutoff: at(11), - committedAfterCurrentPass: true, - }); + const updates = (svc as unknown as { + _recentLocalSessionUpdates: readonly { session: string; modifiedTime: number }[]; + })._recentLocalSessionUpdates; + assert.deepStrictEqual(updates.map(entry => ({ + session: AgentSession.id(URI.parse(entry.session)), + modifiedTime: entry.modifiedTime, + })), [ + { session: AgentSession.id(second), modifiedTime: now + 120_000 }, + { session: AgentSession.id(first), modifiedTime: now + 60_000 }, + ]); }); - /** A first message creates a local session, so the cutoff must not re-measure per listing. */ - testWithExternalSessionClock('recent snapshots the superseding local sessions until the external mode changes', async () => { + test('recent restores local session updates after restart without listing local sessions', async () => { const hour = 60 * 60 * 1000; - const at = (hourOfDay: number) => Date.now() + hourOfDay * hour - 18 * hour; - const now = at(18); - const svc = createExternalSessionService(); - const internals = svc as unknown as { - _resolveRecentSupersedingCutoff(registered: readonly IRegisteredSession[], epoch: number): number | undefined; - _getRecentSessionKeys(sessions: readonly IAgentSessionMetadata[], now: number, supersededBefore: number | undefined): ReadonlySet; - _registryEpoch: number; - }; - const catalog: IAgentSessionMetadata[] = [ - { session: AgentSession.uri('copilot', 'external-morning'), startTime: at(10), modifiedTime: at(10), _meta: withSessionExternal(undefined, true) }, - { session: AgentSession.uri('copilot', 'external-afternoon'), startTime: at(16), modifiedTime: at(16), _meta: withSessionExternal(undefined, true) }, - ]; - const locals: IRegisteredSession[] = []; - const recentIds = () => { - const cutoff = internals._resolveRecentSupersedingCutoff(locals, internals._registryEpoch); - return [...internals._getRecentSessionKeys(catalog, now, cutoff)].map(key => AgentSession.id(URI.parse(key))).sort(); - }; - - const initial = recentIds(); - for (const id of ['local-first', 'local-second']) { - locals.push({ session: AgentSession.uri('copilot', id), provider: 'copilot', startTime: at(17), modifiedTime: at(17), external: false, source: 'restore' }); + const now = Date.now(); + const at = (hourOfDay: number) => now + (hourOfDay - 18) * hour; + const directory = mkdtempSync(join(tmpdir(), 'agent-host-recent-sessions-')); + const storageResource = URI.file(join(directory, 'storage.json')); + try { + const first = createExternalSessionService(createSessionDataService(), undefined, undefined, storageResource) as unknown as { + _recordRecentLocalSessionUpdate(session: URI, modifiedTime: number): void; + _storageService: { whenIdle(): Promise }; + dispose(): void; + }; + first._recordRecentLocalSessionUpdate(AgentSession.uri('copilot', 'local-first'), at(11)); + first._recordRecentLocalSessionUpdate(AgentSession.uri('copilot', 'local-second'), at(17)); + await first._storageService.whenIdle(); + first.dispose(); + + const restored = createExternalSessionService(createSessionDataService(), undefined, undefined, storageResource); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const morning = agent.addSession('external-morning', at(10)); + const afternoon = agent.addSession('external-afternoon', at(16)); + registerTestAgentProvider(restored, agent); + await (restored as unknown as { + _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise; + })._registerDiscoveredChats(agent, [ + discoveredChat(morning, true, at(10)), + discoveredChat(afternoon, true, at(16)), + ]); + + const listed = await restored.listSessions(AgentHostExternalSessionsMode.Recent); + + assert.deepStrictEqual(listed.map(session => AgentSession.id(session.session)), ['external-afternoon']); + } finally { + await rm(directory, { recursive: true, force: true }); } - const afterLocalSessionsCreated = recentIds(); - // Invalidation is synchronous; read before the queued reconciliation re-snapshots. - setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); - const afterModeChange = recentIds(); - await waitForSessionListReconciliation(svc); - - assert.deepStrictEqual({ initial, afterLocalSessionsCreated, afterModeChange }, { - initial: ['external-afternoon', 'external-morning'], - afterLocalSessionsCreated: ['external-afternoon', 'external-morning'], - afterModeChange: [], - }); }); testWithExternalSessionClock('filters external sessions in every mode', async () => { @@ -4127,13 +4256,13 @@ suite('AgentService (node dispatcher)', () => { const agent = disposables.add(new MockAgent('copilot')); registerTestAgentProvider(svc, agent); const gate = new DeferredPromise(); - const inner = svc as unknown as { _computeSessions(mode: AgentHostExternalSessionsMode, epoch?: number): Promise }; + const inner = svc as unknown as { _computeSessions(mode: AgentHostExternalSessionsMode): Promise }; const original = inner._computeSessions; let computations = 0; - inner._computeSessions = async (mode, epoch) => { + inner._computeSessions = async mode => { computations++; await gate.p; - return original.call(svc, mode, epoch); + return original.call(svc, mode); }; const preInvalidation = svc.listSessions(); @@ -5944,6 +6073,48 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('idle provisional create and dispose do not invalidate the session list', async () => { + class ConfigurableProvisionalAgent extends MockAgent { + provisional = true; + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + const created = await base.createChat(chat, context, options); + return created && this.provisional ? { ...created, provisional: true } : created; + }, + })); + } + + const localService = disposables.add(createTestAgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new ConfigurableProvisionalAgent('copilot')); + registerTestAgentProvider(localService, agent); + const registryEpoch = () => (localService as unknown as { _registryEpoch: number })._registryEpoch; + const initialEpoch = registryEpoch(); + + const provisional = await localService.createSession({ provider: agent.id }); + const afterProvisionalCreate = registryEpoch(); + await localService.disposeSession(provisional); + const afterProvisionalDispose = registryEpoch(); + + agent.provisional = false; + const materialized = await localService.createSession({ provider: agent.id }); + const afterMaterializedCreate = registryEpoch(); + await localService.disposeSession(materialized); + + assert.deepStrictEqual({ + initialEpoch, + afterProvisionalCreate, + afterProvisionalDispose, + afterMaterializedCreate, + afterMaterializedDispose: registryEpoch(), + }, { + initialEpoch, + afterProvisionalCreate: initialEpoch, + afterProvisionalDispose: initialEpoch, + afterMaterializedCreate: initialEpoch + 1, + afterMaterializedDispose: initialEpoch + 2, + }); + }); + test('listSessions overlays live workspace metadata over a stale provider snapshot', async () => { class DelayedListAgent extends MockAgent { readonly listStarted = new DeferredPromise(); @@ -6640,6 +6811,7 @@ suite('AgentService (node dispatcher)', () => { { git: gitState }, ); }); + }); test('subscribe to a registered session changeset URI returns a changeset snapshot', async () => { diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 5a97f830943854..0e5276011547b3 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -5250,6 +5250,7 @@ suite('ClaudeAgent', () => { const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); const discoveredChats: number[] = []; disposables.add(agent.onDidDiscoverChats(chats => discoveredChats.push(chats.length))); + void agent.startChatDiscovery(); const sessionUri = AgentSession.uri('claude', 'materialized'); const chat = defaultChatUri(sessionUri); @@ -6228,9 +6229,9 @@ suite('ClaudeAgent — agent SDK setup channel', () => { const ctx = createTestContext(disposables); ctx.sdk.canLoadWithoutDownloadResult = false; ctx.sdk.sessionList = [{ sessionId: 'from-claude-code', summary: 'An existing chat', lastModified: 1000, createdAt: 900 }]; - // Subscribing is what starts discovery. const discovered: number[] = []; disposables.add(ctx.agent.onDidDiscoverChats(chats => discovered.push(chats.length))); + void ctx.agent.startChatDiscovery(); await settle(); const cold = { discovered: [...discovered], diff --git a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts index 53353fbd161cba..fa0b71d5b7faab 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexModelRefresh.test.ts @@ -137,6 +137,9 @@ function createChatGPTConnection(account: unknown = { type: 'chatgpt', email: 'p if (method === 'model/list') { return modelListResponse; } + if (method === 'thread/list') { + return { data: [], nextCursor: null }; + } throw new Error(`Unexpected request: ${method}`); }, }, @@ -189,10 +192,12 @@ suite('CodexAgent model refresh', () => { connectionRequested, // One enumeration, not one per caller that happened to want the connection. enumerations: requests.filter(method => method === 'model/list').length, + discoveries: requests.filter(method => method === 'thread/list').length, models: agent.models.get().map(model => ({ provider: model.provider, id: model.id, name: model.name, meta: model._meta })), }, { connectionRequested: true, enumerations: 1, + discoveries: 0, models: [{ provider: 'codex', id: toCodexModelSelectionId('openai', 'gpt-5.6-sol'), @@ -202,6 +207,29 @@ suite('CodexAgent model refresh', () => { }); }); + test('starts host-requested chat discovery when Codex activates', async () => { + const agent = createAgent(disposables, async () => [], { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true }); + const requests: string[] = []; + const connection = createChatGPTConnection(undefined, requests); + agent['_ensureConnection'] = async () => { + agent['_connection'] = connection as never; + return connection as never; + }; + + await agent.startChatDiscovery(); + const discoveriesBeforeActivation = requests.filter(method => method === 'thread/list').length; + agent['_activate'](); + await agent['_codexChatDiscovery']; + + assert.deepStrictEqual({ + discoveriesBeforeActivation, + discoveriesAfterActivation: requests.filter(method => method === 'thread/list').length, + }, { + discoveriesBeforeActivation: 0, + discoveriesAfterActivation: 1, + }); + }); + test('queues a fresh model refresh when Codex activates during an ambient refresh', async () => { const copilotModels = [{ id: 'copilot-model', name: 'Copilot Model', supported_endpoints: ['/responses'] }] as CCAModel[]; const ambientRefreshStarted = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index b3941122477a7f..344ea20919be42 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1030,7 +1030,7 @@ async function collectDiscoveredChats(agent: CopilotAgent): Promise discovered.push(...chats)); try { - await (agent as unknown as { _startCopilotChatDiscovery(): Promise })._startCopilotChatDiscovery(); + await agent.startChatDiscovery(); return discovered.map(chat => ({ id: sessionIdOfChat(chat.chat), external: chat.external, @@ -5562,6 +5562,7 @@ suite('CopilotAgent', () => { const discoveredChats: Array = []; const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); try { + void agent.startChatDiscovery(); for (let i = 0; i < 10; i++) { await timeout(0); } @@ -5596,6 +5597,7 @@ suite('CopilotAgent', () => { const discoveredChats: Array = []; const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); try { + void agent.startChatDiscovery(); for (let i = 0; i < 50 && discoveredChats.length === 0; i++) { await timeout(0); } @@ -5626,6 +5628,7 @@ suite('CopilotAgent', () => { const discoveredChats: Array = []; const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); try { + void agent.startChatDiscovery(); await listStarted.p; // The gate was snapshotted as enabled at startup, so disabling it mid // discovery is ignored: the adoptable chat still surfaces. @@ -5660,6 +5663,7 @@ suite('CopilotAgent', () => { const discoveredChats: Array = []; const listener = agent.onDidDiscoverChats(chats => discoveredChats.push(chats)); try { + void agent.startChatDiscovery(); for (let i = 0; i < 50 && discoveredChats.length === 0; i++) { await timeout(0); } @@ -5993,7 +5997,7 @@ suite('CopilotAgent', () => { const discovered: IAgentDiscoveredChat[] = []; const listener = agent.onDidDiscoverChats(chats => discovered.push(...chats)); try { - await (agent as unknown as { _startCopilotChatDiscovery(): Promise })._startCopilotChatDiscovery(); + await agent.startChatDiscovery(); return discovered.map(chat => ({ id: sessionIdOfChat(chat.chat), workingDirectory: chat.workingDirectories?.[0]?.fsPath, 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 06a2fd57b13e65..2683ec33908b0f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -445,7 +445,7 @@ configurationRegistry.registerConfiguration({ enum: [AgentHostExternalSessionsMode.None, AgentHostExternalSessionsMode.Recent, AgentHostExternalSessionsMode.Last24Hours, AgentHostExternalSessionsMode.Last7Days, AgentHostExternalSessionsMode.Last30Days], enumDescriptions: [ nls.localize('chat.agentSessions.showExternal.none', "Do not show external sessions."), - nls.localize('chat.agentSessions.showExternal.recent', "Show up to the 2 most recent external sessions updated in the last 7 days. Once at least 2 local sessions exist, external sessions older than the second-newest local session are hidden."), + nls.localize('chat.agentSessions.showExternal.recent', "Show up to the 2 most recent external sessions updated in the last 7 days. At startup, external sessions older than the second-most-recently updated local session are hidden."), nls.localize('chat.agentSessions.showExternal.last24Hours', "Show external sessions updated in the last 24 hours."), nls.localize('chat.agentSessions.showExternal.last7Days', "Show external sessions updated in the last 7 days."), nls.localize('chat.agentSessions.showExternal.last30Days', "Show external sessions updated in the last 30 days."),