Skip to content

Commit ef97ca1

Browse files
benibenjCopilot
andcommitted
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>
1 parent 61fd0b9 commit ef97ca1

5 files changed

Lines changed: 75 additions & 21 deletions

File tree

src/vs/platform/agentHost/node/agentHostMain.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,14 +457,15 @@ async function startAgentHost(): Promise<void> {
457457
handler => protocolHandlers.push(handler),
458458
);
459459
configuredWebSocketServer.settleWith(configuredWebSocketServerStart);
460+
// Startup is complete once the last ingress has settled — successfully or
461+
// not, since a failed WebSocket server is non-fatal. Deferred maintenance
462+
// then runs after a client has also been served its first session listing.
460463
void configuredWebSocketServerStart.catch(err => {
461464
logService.error('Failed to start WebSocket server', err);
465+
}).finally(() => {
466+
agentService.markStartupComplete();
462467
});
463468

464-
// Every ingress is wired: deferred maintenance may run once a client has
465-
// also been served its first session listing.
466-
agentService.markStartupComplete();
467-
468469
process.once('exit', () => {
469470
agentService.dispose();
470471
logService.dispose();

src/vs/platform/agentHost/node/agentHostSessionTitleController.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,12 +430,15 @@ export class AgentHostSessionTitleController extends Disposable {
430430
* live state (it is materialized when opened), so the generated title is
431431
* persisted and pushed onto its surfaced summary. A session that already
432432
* carries a persisted title keeps it; a rename during generation cancels it.
433+
*
434+
* Unlike the other entry points this awaits generation, so the caller's
435+
* deferred-work lane stays serialized against it.
433436
*/
434437
async generateExternalSessionTitle(session: ProtocolURI, userPrompt: string): Promise<void> {
435438
if (this._isEphemeralSession(session) || await this._readPersistedTitleMetadata(session, SESSION_CUSTOM_TITLE_KEY)) {
436439
return;
437440
}
438-
this._generateTitleSoon(
441+
await this._startTitleGeneration(
439442
session,
440443
{ content: userPrompt, isConversation: false, gitHubReferenceSource: userPrompt },
441444
'',
@@ -517,10 +520,22 @@ export class AgentHostSessionTitleController extends Disposable {
517520
currentTitleMatchesFallback: () => boolean,
518521
persist: (title: string) => void,
519522
): void {
523+
void this._startTitleGeneration(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist);
524+
}
525+
526+
/** Starts generation and resolves once the title has been applied and persisted. */
527+
private _startTitleGeneration(
528+
key: ProtocolURI,
529+
prompt: ITitlePromptContext,
530+
fallbackTitle: string,
531+
apply: (title: string) => void,
532+
currentTitleMatchesFallback: () => boolean,
533+
persist: (title: string) => void,
534+
): Promise<void> {
520535
this._cancelTitleGeneration(key);
521536
const source = new CancellationTokenSource();
522537
this._titleGenerationCancellationSources.set(key, source);
523-
void this._generateTitle(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => {
538+
return this._generateTitle(key, prompt, fallbackTitle, apply, currentTitleMatchesFallback, persist, source.token).catch(err => {
524539
if (!source.token.isCancellationRequested) {
525540
this._logService.warn(`[AgentHostSessionTitleController] Failed to apply generated title for ${key}`, err);
526541
}

src/vs/platform/agentHost/node/agentService.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1903,10 +1903,17 @@ export class AgentService extends Disposable implements IAgentService {
19031903
if (this._inFlightListSessions.get(mode) === entry) {
19041904
this._inFlightListSessions.delete(mode);
19051905
}
1906-
this._firstListingServed = true;
1907-
this._openStartupSettled();
19081906
};
1909-
void promise.then(clear, clear);
1907+
void promise.then(
1908+
() => {
1909+
clear();
1910+
// Only a served listing ends startup: a failed one is retried, and
1911+
// deferred work must not compete with that retry.
1912+
this._firstListingServed = true;
1913+
this._openStartupSettled();
1914+
},
1915+
clear,
1916+
);
19101917
return [...await promise];
19111918
}
19121919

src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,7 +1088,7 @@ suite('AgentHostSessionTitleController', () => {
10881088

10891089
stateManager.announceSurfacedSession(createSummary(external));
10901090
await controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test');
1091-
await waitForCondition(async () => await db.getMetadata('customTitle') === 'Flaky renderer test', 'generated title should be persisted');
1091+
// No polling: awaiting the call must mean the title is applied and persisted.
10921092

10931093
assert.deepStrictEqual({
10941094
summaryTitles,
@@ -1111,11 +1111,12 @@ suite('AgentHostSessionTitleController', () => {
11111111
const external = URI.parse('agenthost-session://claude/external-session');
11121112

11131113
stateManager.announceSurfacedSession(createSummary(external));
1114-
await controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test');
1114+
const generation = controller.generateExternalSessionTitle(external.toString(), 'Fix the flaky renderer test');
11151115
await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should start');
11161116
controller.markTitleRenamed(external.toString());
11171117
resolveTitle('Flaky renderer test');
1118-
await Promise.resolve();
1118+
// Also proves a cancelled generation settles rather than hanging its caller.
1119+
await generation;
11191120

11201121
assert.deepStrictEqual({
11211122
aborted: copilotApiService.utilityCalls[0].options?.signal?.aborted,

src/vs/platform/agentHost/test/node/agentService.test.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3007,13 +3007,6 @@ suite('AgentService (node dispatcher)', () => {
30073007
await (service as unknown as { _sessionListReconciliation: Promise<void> })._sessionListReconciliation;
30083008
}
30093009

3010-
async function waitForUtilityCalls(copilotApiService: TestCopilotApiService, count: number): Promise<void> {
3011-
for (let i = 0; i < 20 && copilotApiService.utilityCalls.length < count; i++) {
3012-
await new Promise(resolve => setTimeout(resolve, 5));
3013-
}
3014-
assert.strictEqual(copilotApiService.utilityCalls.length, count, 'expected exactly this many title generations');
3015-
}
3016-
30173010
function exposeListedSessions(service: AgentService, sessions: readonly IAgentSessionMetadata[]): void {
30183011
const summaries = sessions.map((session): SessionSummary => {
30193012
const provider = AgentSession.provider(session.session);
@@ -3145,13 +3138,18 @@ suite('AgentService (node dispatcher)', () => {
31453138
const callsBeforeStartupSettled = copilotApiService.utilityCalls.length;
31463139
await svc.listSessions();
31473140
svc.markStartupComplete();
3141+
// The lane is serialized, so settling implies generation finished: no polling.
31483142
await svc.whenDeferredWorkSettled();
3149-
await waitForUtilityCalls(copilotApiService, 2);
31503143

31513144
const titled = [oldest, middle, newest].filter(session => copilotApiService.utilityCalls.some(
31523145
call => call.request.messages.some(message => message.content.includes(`prompt of ${buildDefaultChatUri(session)}`))));
3153-
assert.deepStrictEqual({ callsBeforeStartupSettled, titled: titled.map(session => AgentSession.id(session)) }, {
3146+
assert.deepStrictEqual({
3147+
callsBeforeStartupSettled,
3148+
callsAfterSettled: copilotApiService.utilityCalls.length,
3149+
titled: titled.map(session => AgentSession.id(session)),
3150+
}, {
31543151
callsBeforeStartupSettled: 0,
3152+
callsAfterSettled: 2,
31553153
titled: ['middle', 'newest'],
31563154
});
31573155
});
@@ -4448,6 +4446,38 @@ suite('AgentService (node dispatcher)', () => {
44484446
});
44494447
});
44504448

4449+
test('a failed listing does not settle startup, so deferred work waits for a served one', async () => {
4450+
class UnavailableCatalogAgent extends MockAgent {
4451+
override readonly onDidDiscoverChats = Event.None;
4452+
enumerable = false;
4453+
override async listChatsToMigrate(): Promise<readonly IAgentChatMetadata[] | undefined> {
4454+
return this.enumerable ? [] : undefined;
4455+
}
4456+
}
4457+
const svc = createExternalSessionService();
4458+
const agent = disposables.add(new UnavailableCatalogAgent('copilot'));
4459+
svc.registerProvider(agent);
4460+
svc.markStartupComplete();
4461+
4462+
await assert.rejects(svc.listSessions());
4463+
let deferredWorkSettled = false;
4464+
void svc.whenDeferredWorkSettled().then(() => { deferredWorkSettled = true; });
4465+
// Ample turns for the gated maintenance to run if the gate were open.
4466+
for (let i = 0; i < 50; i++) {
4467+
await timeout(0);
4468+
}
4469+
const settledByFailedListing = deferredWorkSettled;
4470+
4471+
agent.enumerable = true;
4472+
await svc.listSessions();
4473+
await svc.whenDeferredWorkSettled();
4474+
4475+
assert.deepStrictEqual({ settledByFailedListing, settledAfterServedListing: deferredWorkSettled }, {
4476+
settledByFailedListing: false,
4477+
settledAfterServedListing: true,
4478+
});
4479+
});
4480+
44514481
test('overlapping mode computations share ownership of a replacement migration retry', async () => {
44524482
const retryGate = new DeferredPromise<void>();
44534483
class SingleFlightRetryAgent extends MockAgent {

0 commit comments

Comments
 (0)