From fa0e36e5b8faf03c32b927cf52f312e88f420401 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 21 Aug 2026 16:58:46 +0200 Subject: [PATCH 1/3] Agents - generate titles for untitled external sessions External sessions whose provider surfaces them without a title showed up unnamed in the sessions list. Generate one from the user's first prompt, reusing the existing rename infrastructure. Adds a deferred-work lane on AgentService: `markStartupComplete()` is signalled by the process mains, and work queued through `_runWhenStartupSettled` runs once that and the first served session listing have both happened. Background maintenance therefore never competes with startup, and the service owns no ambient timer. The stale external-session prune moves onto the same lane, replacing its 60s timer. Discovery and legacy migration queue newly registered external sessions that have no provider title. The job keeps the 2 most recently updated candidates, reads the first user prompt from the default chat, and hands it to `AgentHostSessionTitleController.generateExternalSessionTitle`, which reuses the same utility-model prompt, cleaning, persistence, and cancellation as first-message titling. Sessions that already carry a persisted title are left alone, and a rename during generation cancels it. Since these sessions are surfaced but not live, `updateSurfacedSessionTitle` pushes the title onto the surfaced summary so clients update in place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentHostMain.ts | 4 + .../agentHost/node/agentHostServerMain.ts | 1 + .../node/agentHostSessionTitleController.ts | 35 ++++- .../agentHost/node/agentHostStateManager.ts | 37 ++++-- .../platform/agentHost/node/agentService.ts | 124 ++++++++++++++++-- .../agentHost/node/agentSideEffects.ts | 5 + .../agentHostSessionTitleController.test.ts | 72 +++++++++- .../agentHost/test/node/agentService.test.ts | 56 +++++++- 8 files changed, 306 insertions(+), 28 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index af19c42c4dcfd5..12626dea7f0a2c 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -461,6 +461,10 @@ async function startAgentHost(): Promise { logService.error('Failed to start WebSocket server', err); }); + // Every ingress is wired: deferred maintenance may run once a client has + // also been served its first session listing. + agentService.markStartupComplete(); + process.once('exit', () => { agentService.dispose(); logService.dispose(); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 3162e25175def1..01e96481580e97 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -318,6 +318,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..864e498905b295 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -424,6 +424,35 @@ 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. + */ + async generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise { + if (this._isEphemeralSession(session) || await this._readPersistedTitleMetadata(session, SESSION_CUSTOM_TITLE_KEY)) { + return; + } + this._generateTitleSoon( + 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 +497,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; @@ -810,7 +839,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 +851,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 73c882cffcf6f2..33d6caf69fba2b 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -316,20 +316,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; @@ -815,6 +817,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 241ae294e7ba0b..1ab49895c2b207 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, type Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; @@ -101,7 +101,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; @@ -767,7 +766,7 @@ export class AgentService extends Disposable implements IAgentService { reason: AuthRequiredReason.Required, }); })); - this._scheduleExternalSessionPrune(); + this._runWhenStartupSettled('external session prune', () => this._pruneStaleExternalSessions()); } /** @@ -788,12 +787,46 @@ export class AgentService extends Disposable implements IAgentService { return this._sideEffects.onDidStartTurn; } - 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 { @@ -836,6 +869,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 ---------------------------------------------- /** @@ -1585,6 +1672,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; @@ -1614,6 +1702,9 @@ export class AgentService extends Disposable implements IAgentService { await this._initializeExternalSessionReadState(session); } existing.set(session.toString(), external); + if (external && !sessionMetadata.summary) { + untitledExternal.push(sessionMetadata); + } if (external && !readSessionEhcliAdoptable(sessionMetadata._meta)) { registeredExternal = true; } else { @@ -1635,6 +1726,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; } @@ -1663,6 +1757,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) { @@ -1679,6 +1774,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 { @@ -1690,6 +1788,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. */ @@ -1801,6 +1902,8 @@ export class AgentService extends Disposable implements IAgentService { if (this._inFlightListSessions.get(mode) === entry) { this._inFlightListSessions.delete(mode); } + this._firstListingServed = true; + this._openStartupSettled(); }; void promise.then(clear, clear); return [...await promise]; @@ -6769,6 +6872,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 b140e4ff382a25..f30b7d089e7425 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -1914,6 +1914,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..6ec15de0281784 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,74 @@ 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'); + await waitForCondition(async () => await db.getMetadata('customTitle') === 'Flaky renderer test', 'generated title should be 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)); + await 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'); + await Promise.resolve(); + + 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 bd3d854d6ea1db..468625ee28fb68 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2965,7 +2965,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, @@ -2975,7 +2975,7 @@ suite('AgentService (node dispatcher)', () => { undefined, undefined, undefined, - undefined, + copilotApiService, undefined, [], undefined, @@ -3003,6 +3003,13 @@ suite('AgentService (node dispatcher)', () => { await (service as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; } + async function waitForUtilityCalls(copilotApiService: TestCopilotApiService, count: number): Promise { + for (let i = 0; i < 20 && copilotApiService.utilityCalls.length < count; i++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + assert.strictEqual(copilotApiService.utilityCalls.length, count, 'expected exactly this many title generations'); + } + function exposeListedSessions(service: AgentService, sessions: readonly IAgentSessionMetadata[]): void { const summaries = sessions.map((session): SessionSummary => { const provider = AgentSession.provider(session.session); @@ -3082,13 +3089,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(); @@ -3104,6 +3111,47 @@ 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(); + await svc.whenDeferredWorkSettled(); + await waitForUtilityCalls(copilotApiService, 2); + + 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, titled: titled.map(session => AgentSession.id(session)) }, { + callsBeforeStartupSettled: 0, + 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(); From 61fd0b975721ae1e95113880185f95c27a114dab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:04:32 +0000 Subject: [PATCH 2/3] Fix stale artifactLocation reference in sessionArtifacts Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts index 4786ffd10fbfd7..594b46ef69db84 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionArtifacts.ts @@ -184,7 +184,7 @@ export function buildSessionArtifactSections(artifacts: readonly ISessionArtifac id: uri.toString(), label, resource: uri, - ...artifactLocation(uri, label), + ...sessionArtifactLocation(uri, label), ...(imageCarouselEnabled ? { ariaLabel: localize('sessionArtifacts.openImage', "Open {0} in Images Preview", label), From ef97ca1bc4c44d2c0933bf56df9df038489eb3f2 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Fri, 21 Aug 2026 21:32:57 +0200 Subject: [PATCH 3/3] Address PR feedback on external-session titling - `generateExternalSessionTitle` returned a promise that resolved before generation had run, because `_generateTitleSoon` starts the work fire-and-forget. The awaited loop in `_titleUntitledExternalSessions` therefore launched both model calls concurrently and `whenDeferredWorkSettled()` reported completion while generation and persistence were still in flight, breaking the lane's serialization contract. Split out `_startTitleGeneration`, which returns the tracked promise, and await it on the external-session path. - `listSessions` marked the first listing as served from its rejection handler too, so deferred maintenance could start after a failed listing and compete with the retry the gate exists to protect. Keep in-flight cleanup on both paths but only set the flag on fulfillment. - `agentHostMain` marked startup complete while the configured WebSocket server was still being created and wired, so deferred work could begin concurrently with that remaining startup. Mark completion once the optional startup promise settles, keeping its non-fatal error handling. Adds a regression test for the failed-listing gate, and tightens the existing titling tests to assert the awaited-generation contract without polling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/agentHost/node/agentHostMain.ts | 9 ++-- .../node/agentHostSessionTitleController.ts | 19 +++++++- .../platform/agentHost/node/agentService.ts | 13 +++-- .../agentHostSessionTitleController.test.ts | 7 +-- .../agentHost/test/node/agentService.test.ts | 48 +++++++++++++++---- 5 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 12626dea7f0a2c..fc7e9cd1aeb293 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -457,14 +457,15 @@ 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(); }); - // Every ingress is wired: deferred maintenance may run once a client has - // also been served its first session listing. - agentService.markStartupComplete(); - process.once('exit', () => { agentService.dispose(); logService.dispose(); diff --git a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts index 864e498905b295..f11f7ea8fc5b43 100644 --- a/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts +++ b/src/vs/platform/agentHost/node/agentHostSessionTitleController.ts @@ -430,12 +430,15 @@ export class AgentHostSessionTitleController extends Disposable { * 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; } - this._generateTitleSoon( + await this._startTitleGeneration( session, { content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt }, '', @@ -517,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); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 257a6a5f95def3..ba2b8bc1c6b430 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -1903,10 +1903,17 @@ export class AgentService extends Disposable implements IAgentService { if (this._inFlightListSessions.get(mode) === entry) { this._inFlightListSessions.delete(mode); } - this._firstListingServed = true; - this._openStartupSettled(); }; - 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]; } diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index 6ec15de0281784..8d2919ff267493 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -1088,7 +1088,7 @@ suite('AgentHostSessionTitleController', () => { stateManager.announceSurfacedSession(createSummary(external)); await controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test'); - await waitForCondition(async () => await db.getMetadata('customTitle') === 'Flaky renderer test', 'generated title should be persisted'); + // No polling: awaiting the call must mean the title is applied and persisted. assert.deepStrictEqual({ summaryTitles, @@ -1111,11 +1111,12 @@ suite('AgentHostSessionTitleController', () => { const external = URI.parse('agenthost-session://claude/external-session'); stateManager.announceSurfacedSession(createSummary(external)); - await controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test'); + 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'); - await Promise.resolve(); + // Also proves a cancelled generation settles rather than hanging its caller. + await generation; assert.deepStrictEqual({ aborted: copilotApiService.utilityCalls[0].options?.signal?.aborted, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 2b8d3c64efa97c..e0997119fa07dd 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -3007,13 +3007,6 @@ suite('AgentService (node dispatcher)', () => { await (service as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; } - async function waitForUtilityCalls(copilotApiService: TestCopilotApiService, count: number): Promise { - for (let i = 0; i < 20 && copilotApiService.utilityCalls.length < count; i++) { - await new Promise(resolve => setTimeout(resolve, 5)); - } - assert.strictEqual(copilotApiService.utilityCalls.length, count, 'expected exactly this many title generations'); - } - function exposeListedSessions(service: AgentService, sessions: readonly IAgentSessionMetadata[]): void { const summaries = sessions.map((session): SessionSummary => { const provider = AgentSession.provider(session.session); @@ -3145,13 +3138,18 @@ suite('AgentService (node dispatcher)', () => { const callsBeforeStartupSettled = copilotApiService.utilityCalls.length; await svc.listSessions(); svc.markStartupComplete(); + // The lane is serialized, so settling implies generation finished: no polling. await svc.whenDeferredWorkSettled(); - await waitForUtilityCalls(copilotApiService, 2); 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, titled: titled.map(session => AgentSession.id(session)) }, { + assert.deepStrictEqual({ + callsBeforeStartupSettled, + callsAfterSettled: copilotApiService.utilityCalls.length, + titled: titled.map(session => AgentSession.id(session)), + }, { callsBeforeStartupSettled: 0, + callsAfterSettled: 2, titled: ['middle', 'newest'], }); }); @@ -4448,6 +4446,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 {