diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index f27bde7a150b31..fc1cb5314077a9 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -472,8 +472,13 @@ async function startAgentHost(): Promise { handler => protocolHandlers.push(handler), ); configuredWebSocketServer.settleWith(configuredWebSocketServerStart); + // Startup is complete once the last ingress has settled — successfully or + // not, since a failed WebSocket server is non-fatal. Deferred maintenance + // then runs after a client has also been served its first session listing. void configuredWebSocketServerStart.catch(err => { logService.error('Failed to start WebSocket server', err); + }).finally(() => { + agentService.markStartupComplete(); }); process.once('exit', () => { diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 8e35d5add58a2f..f8000897691e91 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -337,6 +337,7 @@ async function main(): Promise { function reportReady(addr: string): void { const listeningPort = Number(addr.split(':').pop()); process.stdout.write(`READY:${listeningPort}\n`); + agentService.markStartupComplete(); const urls = resolveServerUrls(options.host, listeningPort); for (const url of urls.local) { diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index e851c18fe2f394..f11f7ea8fc5b43 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -424,6 +424,38 @@ export class AgentHostSessionTitleController extends Disposable { dispatch(title); } + /** + * Generates a title for an external session whose provider surfaced it + * without one, from the user's first prompt. Such a session usually has no + * live state (it is materialized when opened), so the generated title is + * persisted and pushed onto its surfaced summary. A session that already + * carries a persisted title keeps it; a rename during generation cancels it. + * + * Unlike the other entry points this awaits generation, so the caller's + * deferred-work lane stays serialized against it. + */ + async generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise { + if (this._isEphemeralSession(session) || await this._readPersistedTitleMetadata(session, SESSION_CUSTOM_TITLE_KEY)) { + return; + } + await this._startTitleGeneration( + session, + { content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt }, + '', + title => this._applyExternalSessionTitle(session, title), + () => true, + title => this._persistAutoTitle(session, undefined, title), + ); + } + + private _applyExternalSessionTitle(session: ProtocolURI, title: string): void { + if (this._stateManager.getSessionState(session)) { + this._applySeedTitle(session, undefined, title); + } else { + this._applyTitle(session, title, t => this._stateManager.updateSurfacedSessionTitle(session, t)); + } + } + cancelTitleGeneration(session: ProtocolURI): void { this._cancelTitleGeneration(session); } @@ -468,7 +500,7 @@ export class AgentHostSessionTitleController extends Disposable { return undefined; } const sourceKey = independentChat ? customChatTitleSourceMetadataKey(independentChat) : SESSION_CUSTOM_TITLE_SOURCE_KEY; - const source = await this._readPersistedTitleSource(channel, sourceKey); + const source = await this._readPersistedTitleMetadata(channel, sourceKey); if (source === AGENT_HOST_TITLE_SOURCE_USER || source === AGENT_HOST_TITLE_SOURCE_AGENT) { this.markTitleRenamed(channel, independentChat); return undefined; @@ -488,10 +520,22 @@ export class AgentHostSessionTitleController extends Disposable { currentTitleMatchesFallback: () => boolean, persist: (title: string) => void, ): void { + void this._startTitleGeneration(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist); + } + + /** Starts generation and resolves once the title has been applied and persisted. */ + private _startTitleGeneration( + key: ProtocolURI, + prompt: ITitlePromptContext, + fallbackTitle: string, + apply: (title: string) => void, + currentTitleMatchesFallback: () => boolean, + persist: (title: string) => void, + ): Promise { this._cancelTitleGeneration(key); const source = new CancellationTokenSource(); this._titleGenerationCancellationSources.set(key, source); - void this._generateTitle(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => { + return this._generateTitle(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => { if (!source.token.isCancellationRequested) { this._logService.warn(`[AgentHostSessionTitleController] Failed to apply generated title for ${key}`, err); } @@ -810,7 +854,7 @@ export class AgentHostSessionTitleController extends Disposable { return this._stateManager.isEphemeralSession(channel); } - private async _readPersistedTitleSource(session: ProtocolURI, key: string): Promise { + private async _readPersistedTitleMetadata(session: ProtocolURI, key: string): Promise { try { const ref = await this._options.sessionDataService.tryOpenDatabase?.(URI.parse(session)); if (!ref) { @@ -822,7 +866,7 @@ export class AgentHostSessionTitleController extends Disposable { ref.dispose(); } } catch (err) { - this._logService.warn(`[AgentHostSessionTitleController] Failed to read title source '${key}'`, err); + this._logService.warn(`[AgentHostSessionTitleController] Failed to read title metadata '${key}'`, err); return undefined; } } diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index d702f760906096..f02923e921f556 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -318,20 +318,22 @@ export class AgentHostStateManager extends Disposable { const entry = this._sessionStates.get(session); 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', - channel: ROOT_STATE_URI, - session, - changes, - }); - } - }, + (session, changes) => this._emitSessionSummaryChanged(session, changes), )); } + private _emitSessionSummaryChanged(session: string, changes: SessionSummaryChangedParams['changes']): void { + this._onDidChangeSessionSummary.fire({ session, changes }); + if (this._publishedSessionSummaries.has(session)) { + this._onDidEmitNotification.fire({ + type: 'root/sessionSummaryChanged', + channel: ROOT_STATE_URI, + session, + changes, + }); + } + } + private _emitSessionAdded(summary: SessionSummary): void { if (readEphemeralSessionMeta(summary).isEphemeral) { return; @@ -817,6 +819,19 @@ export class AgentHostStateManager extends Disposable { this._emitSessionAdded(summary); } + /** + * Retitles a surfaced session (one with no live state) so clients update it + * in place. Live sessions are retitled through the reducer instead. + */ + updateSurfacedSessionTitle(session: string, title: string): void { + const announced = this._summaryNotifier.getAnnounced(session); + if (this._sessionStates.has(session) || !announced || announced.title === title) { + return; + } + this._summaryNotifier.announce(session, { ...announced, title }); + this._emitSessionSummaryChanged(session, { title }); + } + /** Removes a surfaced session without affecting a live session. */ retractSurfacedSession(session: string): void { if (this._sessionStates.has(session)) { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index c996057d7b05aa..f5ae74d46c4dd8 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -5,7 +5,7 @@ import { open, unlink, type FileHandle } from 'fs/promises'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; -import { DeferredPromise, disposableTimeout, Limiter, Promises, ResourceQueue } from '../../../base/common/async.js'; +import { Barrier, DeferredPromise, disposableTimeout, Limiter, Promises, ResourceQueue } from '../../../base/common/async.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; @@ -96,7 +96,6 @@ import { IAgentHostChangesetOperationService } from '../common/agentHostChangese const SESSION_GC_GRACE_MS = 30_000; const DAY_MS = 24 * 60 * 60 * 1000; const EXTERNAL_SESSION_MAX_AGE_MS = 30 * DAY_MS; -const EXTERNAL_SESSION_PRUNE_DELAY_MS = 60_000; const RECENT_EXTERNAL_SESSION_LIMIT = 2; /** 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; @@ -710,16 +709,50 @@ export class AgentService extends Disposable implements IAgentService { }); })); this._editAttributionService.setEnabled(this._stateManager.rootState.config?.values[AgentHostEditTelemetryEnabledConfigKey] !== false); - this._scheduleExternalSessionPrune(); + this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions()); this._register(core.disposables); } - private _scheduleExternalSessionPrune(): void { - this._register(disposableTimeout(() => { - void this._pruneStaleExternalSessions().catch(error => { - this._logService.warn('[AgentService] Failed to prune stale external sessions', error); - }); - }, EXTERNAL_SESSION_PRUNE_DELAY_MS)); + /** Opens once startup settled: the host finished starting and the first listing was served. */ + private readonly _startupSettled = new Barrier(); + private _hostStartupComplete = false; + private _firstListingServed = false; + /** Serializes deferred work so background maintenance never overlaps. */ + private _deferredWork = Promise.resolve(); + + /** + * Signals that host startup finished. Deferred work runs once this and the + * first session listing have both happened, so background maintenance never + * competes with startup. Called by the process mains; the service owns no + * ambient timer of its own. + */ + markStartupComplete(): void { + this._hostStartupComplete = true; + this._openStartupSettled(); + } + + private _openStartupSettled(): void { + if (this._hostStartupComplete && this._firstListingServed) { + this._startupSettled.open(); + } + } + + /** + * Runs `work` once startup has settled, serialized behind any deferred work + * queued before it. For maintenance that is fine to run late and must not + * 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 { + this._deferredWork = this._deferredWork + .then(() => this._startupSettled.wait()) + .then(() => this._store.isDisposed ? undefined : work()) + .catch(error => this._logService.warn(`[AgentService] Deferred work '${name}' failed`, error)); + } + + /** Test surface: settles once all deferred work queued so far has run. */ + async whenDeferredWorkSettled(): Promise { + await this._deferredWork; } private async _pruneStaleExternalSessions(): Promise { @@ -762,6 +795,60 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] pruned ${staleExternalSessions.length} stale external session row(s) older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`); } + /** External sessions registered without a provider title, awaiting a generated one. */ + private readonly _untitledExternalSessions = new Map(); + private _externalSessionTitlingQueued = false; + + /** + * Queues external sessions whose provider surfaced them without a title. + * Titling is deferred past startup and capped at the + * {@link RECENT_EXTERNAL_SESSION_LIMIT} most recently updated candidates, so + * a large provider catalog cannot trigger a burst of model calls. + */ + private _scheduleExternalSessionTitles(sessions: readonly IAgentSessionMetadata[]): void { + for (const session of sessions) { + this._untitledExternalSessions.set(session.session.toString(), session); + } + if (this._externalSessionTitlingQueued) { + return; + } + this._externalSessionTitlingQueued = true; + this._runWhenStartupSettled('external session titles', () => { + this._externalSessionTitlingQueued = false; + return this._titleUntitledExternalSessions(); + }); + } + + /** Titles the most recently updated queued sessions and drops the rest. */ + private async _titleUntitledExternalSessions(): Promise { + const candidates = [...this._untitledExternalSessions.values()] + .sort((a, b) => b.modifiedTime - a.modifiedTime) + .slice(0, RECENT_EXTERNAL_SESSION_LIMIT); + this._untitledExternalSessions.clear(); + for (const candidate of candidates) { + try { + await this._generateExternalSessionTitle(candidate); + } catch (error) { + this._logService.warn(`[AgentService] Failed to title external session ${candidate.session.toString()}`, error); + } + } + } + + /** Titles one external session from the first user prompt of its default chat. */ + private async _generateExternalSessionTitle(metadata: IAgentSessionMetadata): Promise { + const session = metadata.session; + const agent = this._findProviderForSession(session); + if (!agent) { + return; + } + const chat = URI.parse(buildDefaultChatUri(session)); + const turns = await agent.chats.getMessages(chat, this._chatContext(session, chat)); + const prompt = turns[0]?.message.text.trim(); + if (prompt) { + await this._sideEffects.generateExternalSessionTitle(session.toString(), prompt); + } + } + // ---- provider registration ---------------------------------------------- /** @@ -1500,6 +1587,7 @@ export class AgentService extends Disposable implements IAgentService { let registeredExternal = false; let alreadyRegistered = 0; let registryChanged = false; + const untitledExternal: IAgentSessionMetadata[] = []; const results = await Promise.all(chats.map(({ external, ...metadata }) => discoveryLimiter.queue(async () => { const sessionMetadata = this._toSessionMetadata(metadata); const session = sessionMetadata.session; @@ -1530,6 +1618,9 @@ export class AgentService extends Disposable implements IAgentService { await this._initializeExternalSessionReadState(session); } registeredKeys.add(session.toString()); + if (external && !sessionMetadata.summary) { + untitledExternal.push(sessionMetadata); + } if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) { registeredExternal = true; } else { @@ -1551,6 +1642,9 @@ export class AgentService extends Disposable implements IAgentService { if (registeredExternal) { this._queueSessionListReconciliation(); } + if (untitledExternal.length > 0) { + this._scheduleExternalSessionTitles(untitledExternal); + } this._logService.info(`[AgentService] discovery for provider ${provider.id}: ${chats.length} candidate(s) (${chats.filter(chat => chat.external).length} external), ${registered} registered, ${alreadyRegistered} already registered, ${suppressed} suppressed as subagent/chat backing, ${skippedAsStale} skipped as older than ${EXTERNAL_SESSION_MAX_AGE_MS / DAY_MS} days`); return registered > 0; } @@ -1583,6 +1677,7 @@ export class AgentService extends Disposable implements IAgentService { return { session: s.session, provider: provider.id, startTime: s.startTime, external, source: external ? 'discovery' : 'restore' }; }))); let registeredExternal = false; + const untitledExternal: IAgentSessionMetadata[] = []; for (let index = 0; index < identities.length; index++) { const identity = identities[index]; if (!identity) { @@ -1599,6 +1694,9 @@ export class AgentService extends Disposable implements IAgentService { await this._initializeExternalSessionReadState(identity.session); } existing.set(identity.session.toString(), identity.external); + if (identity.external && !metadata.summary) { + untitledExternal.push(metadata); + } if (identity.external && !readSessionEhcliAdoptable(metadata._meta)) { registeredExternal = true; } else { @@ -1610,6 +1708,9 @@ export class AgentService extends Disposable implements IAgentService { if (registeredExternal) { this._queueSessionListReconciliation(); } + if (untitledExternal.length > 0) { + this._scheduleExternalSessionTitles(untitledExternal); + } } /** Seeds external sessions as read. Avoiding this DB requires a durable registry default. */ @@ -1748,7 +1849,16 @@ export class AgentService extends Disposable implements IAgentService { this._inFlightListSessions.delete(mode); } }; - void promise.then(clear, clear); + void promise.then( + () => { + clear(); + // Only a served listing ends startup: a failed one is retried, and + // deferred work must not compete with that retry. + this._firstListingServed = true; + this._openStartupSettled(); + }, + clear, + ); return [...await promise]; } @@ -6695,6 +6805,9 @@ export class AgentService extends Disposable implements IAgentService { } override dispose(): void { + // Unblocks pending deferred work so its chain drains; the disposal guard + // in `_runWhenStartupSettled` keeps the work itself from running. + this._startupSettled.open(); for (const provider of this._providers.values()) { provider.dispose(); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 0c27cf18493428..4bba47cd263c58 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1920,6 +1920,11 @@ export class AgentSideEffects extends Disposable { this._titleController.markTitleAuto(channel, chatChannel, title); } + /** Generates a title for an external session the provider surfaced without one. */ + generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise { + return this._titleController.generateExternalSessionTitle(session, userPrompt); + } + markTitleRenamed(channel: ProtocolURI, chatChannel?: ProtocolURI): void { this._titleController.markTitleRenamed(channel, chatChannel); } diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 391f1123135207..8d2919ff267493 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -13,7 +13,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostSessionTitleController } from '../../node/agentHostSessionTitleController.js'; import { withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; -import { ActionType } from '../../common/state/sessionActions.js'; +import { ActionType, NotificationType } from '../../common/state/sessionActions.js'; import { buildChatUri, buildDefaultChatUri, MessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type ResponsePart, type SessionSummary, type ToolCallCompletedState, type Turn } from '../../common/state/sessionState.js'; import { type AutoMergeMethod, type CreatedPullRequest, type GitHubIssueOrPullRequest, type IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; @@ -1073,4 +1073,75 @@ suite('AgentHostSessionTitleController', () => { persistedTitle: undefined, }); }); + + test('generateExternalSessionTitle titles a surfaced external session from its first prompt', async () => { + const copilotApiService = new TestCopilotApiService(); + copilotApiService.response = 'Flaky renderer test'; + const { controller, stateManager, db } = setup(copilotApiService); + const external = URI.parse('agenthost-session://claude/external-session'); + const summaryTitles: (string | undefined)[] = []; + disposables.add(stateManager.onDidEmitNotification(n => { + if (n.type === NotificationType.SessionSummaryChanged && n.session === external.toString()) { + summaryTitles.push(n.changes.title); + } + })); + + stateManager.announceSurfacedSession(createSummary(external)); + await controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test'); + // No polling: awaiting the call must mean the title is applied and persisted. + + assert.deepStrictEqual({ + summaryTitles, + persistedTitle: await db.getMetadata('customTitle'), + persistedSource: await db.getMetadata(SESSION_CUSTOM_TITLE_SOURCE_KEY), + isLive: !!stateManager.getSessionState(external.toString()), + }, { + summaryTitles: ['Flaky renderer test'], + persistedTitle: 'Flaky renderer test', + persistedSource: AGENT_HOST_TITLE_SOURCE_AUTO, + isLive: false, + }); + }); + + test('generateExternalSessionTitle does not clobber a rename during generation', async () => { + const copilotApiService = new TestCopilotApiService(); + let resolveTitle!: (title: string) => void; + copilotApiService.responsePromise = new Promise(resolve => { resolveTitle = resolve; }); + const { controller, stateManager, db } = setup(copilotApiService); + const external = URI.parse('agenthost-session://claude/external-session'); + + stateManager.announceSurfacedSession(createSummary(external)); + const generation = controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test'); + await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should start'); + controller.markTitleRenamed(external.toString()); + resolveTitle('Flaky renderer test'); + // Also proves a cancelled generation settles rather than hanging its caller. + await generation; + + assert.deepStrictEqual({ + aborted: copilotApiService.utilityCalls[0].options?.signal?.aborted, + persistedTitle: await db.getMetadata('customTitle'), + }, { + aborted: true, + persistedTitle: undefined, + }); + }); + + test('generateExternalSessionTitle keeps an already persisted title', async () => { + const copilotApiService = new TestCopilotApiService(); + const { controller, stateManager, db } = setup(copilotApiService); + const external = URI.parse('agenthost-session://claude/external-session'); + await db.setMetadata('customTitle', 'Renamed by the user'); + + stateManager.announceSurfacedSession(createSummary(external)); + await controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test'); + + assert.deepStrictEqual({ + utilityCalls: copilotApiService.utilityCalls.length, + persistedTitle: await db.getMetadata('customTitle'), + }, { + utilityCalls: 0, + persistedTitle: 'Renamed by the user', + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 326d6ac31be0de..41673173e367a9 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2894,7 +2894,7 @@ suite('AgentService (node dispatcher)', () => { } } - function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase): AgentService { + function createExternalSessionService(sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase, copilotApiService?: ICopilotApiService): AgentService { return disposables.add(createTestAgentService( new NullLogService(), fileService, @@ -2904,7 +2904,7 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, undefined, - undefined, + copilotApiService, undefined, [], undefined, @@ -3011,13 +3011,13 @@ suite('AgentService (node dispatcher)', () => { const svc = createExternalSessionService(); const agent = disposables.add(new TimedExternalAgent('copilot')); const stale = agent.addSession('stale', now - 30 * day - 1); - const fresh = agent.addSession('fresh', now - 30 * day + 60_000); + const fresh = agent.addSession('fresh', now - 29 * day); setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Last30Days, 1); await waitForSessionListReconciliation(svc); svc.registerProvider(agent); await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ { chat: URI.parse(buildDefaultChatUri(stale)), startTime: now - 30 * day - 1, modifiedTime: now - 30 * day - 1, external: true }, - { chat: URI.parse(buildDefaultChatUri(fresh)), startTime: now - 30 * day + 60_000, modifiedTime: now - 30 * day + 60_000, external: true }, + { chat: URI.parse(buildDefaultChatUri(fresh)), startTime: now - 29 * day, modifiedTime: now - 29 * day, external: true }, ]); const listed = (await svc.listSessions()).map(session => AgentSession.id(session.session)).sort(); @@ -3033,6 +3033,52 @@ suite('AgentService (node dispatcher)', () => { assert.ok(!registered.has(stale.toString())); }); + test('defers titling the two most recently updated untitled external sessions until startup settled', async () => { + const now = Date.now(); + const copilotApiService = new TestCopilotApiService(); + const svc = createExternalSessionService(createPerSessionDataService().service, undefined, copilotApiService); + const agent = disposables.add(new TimedExternalAgent('copilot')); + const oldest = agent.addSession('oldest', now - 3000); + const middle = agent.addSession('middle', now - 2000); + const newest = agent.addSession('newest', now - 1000); + agent.chats.getMessages = async (chat: URI) => [{ + id: 'turn-1', + state: TurnState.Complete, + message: { text: `prompt of ${chat.toString()}`, origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }]; + svc.registerProvider(agent); + await svc.authenticate({ + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, + scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, + token: 'gh-token', + }); + + await (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats(agent, [ + discoveredChat(oldest, true, now - 3000), + discoveredChat(middle, true, now - 2000), + discoveredChat(newest, true, now - 1000), + ]); + const callsBeforeStartupSettled = copilotApiService.utilityCalls.length; + await svc.listSessions(); + svc.markStartupComplete(); + // The lane is serialized, so settling implies generation finished: no polling. + await svc.whenDeferredWorkSettled(); + + const titled = [oldest, middle, newest].filter(session => copilotApiService.utilityCalls.some( + call => call.request.messages.some(message => message.content.includes(`prompt of ${buildDefaultChatUri(session)}`)))); + assert.deepStrictEqual({ + callsBeforeStartupSettled, + callsAfterSettled: copilotApiService.utilityCalls.length, + titled: titled.map(session => AgentSession.id(session)), + }, { + callsBeforeStartupSettled: 0, + callsAfterSettled: 2, + titled: ['middle', 'newest'], + }); + }); + testWithExternalSessionClock('prune removes stale external sessions but keeps adoptable-legacy sessions', async () => { const day = 24 * 60 * 60 * 1000; const now = Date.now(); @@ -4325,6 +4371,38 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('a failed listing does not settle startup, so deferred work waits for a served one', async () => { + class UnavailableCatalogAgent extends MockAgent { + override readonly onDidDiscoverChats = Event.None; + enumerable = false; + override async listChatsToMigrate(): Promise { + return this.enumerable ? [] : undefined; + } + } + const svc = createExternalSessionService(); + const agent = disposables.add(new UnavailableCatalogAgent('copilot')); + svc.registerProvider(agent); + svc.markStartupComplete(); + + await assert.rejects(svc.listSessions()); + let deferredWorkSettled = false; + void svc.whenDeferredWorkSettled().then(() => { deferredWorkSettled = true; }); + // Ample turns for the gated maintenance to run if the gate were open. + for (let i = 0; i < 50; i++) { + await timeout(0); + } + const settledByFailedListing = deferredWorkSettled; + + agent.enumerable = true; + await svc.listSessions(); + await svc.whenDeferredWorkSettled(); + + assert.deepStrictEqual({ settledByFailedListing, settledAfterServedListing: deferredWorkSettled }, { + settledByFailedListing: false, + settledAfterServedListing: true, + }); + }); + test('overlapping mode computations share ownership of a replacement migration retry', async () => { const retryGate = new DeferredPromise(); class SingleFlightRetryAgent extends MockAgent {