Skip to content

Commit ef70f91

Browse files
committed
agentHost: keep the parent's output after a background subagent settles
The pipeline derives the turn from the prompt queue head, so a parent that spawns background subagents ends its own turn and every message after it arrives with no turn id. `ClaudeSdkMessageRouter.handle` dropped those outright, silently at every log level, so once the subagents settled and the parent resumed the chat showed nothing further. The transcript kept all of it, which is why restarting the agent host made the whole response appear. Anchor a message that arrives with no active turn to the turn that spawned a subagent still running. The spawn records that turn first-writer-wins like its other fields, the registry exposes it while the spawn is outstanding, and the router resolves through it before deciding to drop. This makes the live path agree with the restored one: replay already attributes the post-subagent output to the spawning turn. Fixes #332073
1 parent 00cc2df commit ef70f91

5 files changed

Lines changed: 104 additions & 5 deletions

File tree

src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,26 @@ export class ClaudeSdkMessageRouter extends Disposable {
6565
this._clientToolOwner = clientToolOwner;
6666
}
6767

68+
/**
69+
* Routes one SDK message. A message arriving with no active turn is anchored
70+
* to the turn that spawned a still-running subagent, because the parent
71+
* resumes producing output once those settle.
72+
*/
6873
async handle(message: SDKMessage, turnId: string | undefined, context?: IClaudeSdkMessageContext): Promise<void> {
74+
const resolvedTurnId = turnId ?? this._subagents.outstandingSpawnTurnId();
6975
if (message.type === 'assistant') {
7076
this._editObserver.observeAssistant(message, context?.mode, context?.clientContext);
71-
} else if (message.type === 'user' && turnId !== undefined) {
72-
await this._editObserver.observeUser(message, turnId, this._mapperState);
77+
} else if (message.type === 'user' && resolvedTurnId !== undefined) {
78+
await this._editObserver.observeUser(message, resolvedTurnId, this._mapperState);
7379
}
74-
if (turnId === undefined) {
80+
if (resolvedTurnId === undefined) {
7581
return;
7682
}
7783
try {
7884
const signals = mapSDKMessageToAgentSignals(
7985
message,
8086
this._chatChannelUri,
81-
turnId,
87+
resolvedTurnId,
8288
this._mapperState,
8389
this._logService,
8490
this._subagents,

src/vs/platform/agentHost/node/claude/claudeSubagentRegistry.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export class SubagentSpawn {
5656
subagentType: string | undefined;
5757
description: string | undefined;
5858
prompt: string | undefined;
59+
turnId: string | undefined;
5960

6061
private _agentId: string | undefined;
6162
private _announced = false;
@@ -88,6 +89,10 @@ export class SubagentSpawn {
8889
return true;
8990
}
9091

92+
get completed(): boolean {
93+
return this._completed;
94+
}
95+
9196
markCompleted(): boolean {
9297
if (this._completed) {
9398
return false;
@@ -110,6 +115,7 @@ export interface ISubagentSpawnInit {
110115
readonly subagentType?: string;
111116
readonly description?: string;
112117
readonly prompt?: string;
118+
readonly turnId?: string;
113119
}
114120

115121
/**
@@ -159,9 +165,25 @@ export class SubagentRegistry extends Disposable {
159165
if (init?.prompt !== undefined && spawn.prompt === undefined) {
160166
spawn.prompt = init.prompt;
161167
}
168+
if (init?.turnId !== undefined && spawn.turnId === undefined) {
169+
spawn.turnId = init.turnId;
170+
}
162171
return spawn;
163172
}
164173

174+
/**
175+
* The turn that spawned a subagent still running, so output the parent
176+
* produces after its own turn ended can be anchored to that turn.
177+
*/
178+
outstandingSpawnTurnId(): string | undefined {
179+
for (const spawn of this._spawns.values()) {
180+
if (!spawn.completed && spawn.turnId !== undefined) {
181+
return spawn.turnId;
182+
}
183+
}
184+
return undefined;
185+
}
186+
165187
getSpawn(toolUseId: string): SubagentSpawn | undefined {
166188
return this._spawns.get(toolUseId);
167189
}

src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ export function buildTopLevelSubagentReadyAction(
165165
const agentName = typeof input?.subagent_type === 'string' ? input.subagent_type : undefined;
166166
const prompt = typeof input?.prompt === 'string' ? input.prompt : undefined;
167167
const inputJson = block.input !== undefined ? safeStringify(block.input) : undefined;
168-
registry.recordSpawn(block.id, { subagentType: agentName, description, prompt });
168+
registry.recordSpawn(block.id, { subagentType: agentName, description, prompt, turnId });
169169
const meta: Mutable<IToolCallMeta> = { ...buildClaudeToolCallMeta(block.name) };
170170
if (!meta.toolKind) {
171171
meta.toolKind = 'subagent';

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4037,6 +4037,44 @@ suite('ClaudeAgent', () => {
40374037
});
40384038
});
40394039

4040+
test('output the parent produces after a background subagent settles still reaches the chat', async () => {
4041+
const { agent, sdk } = createTestContext(disposables);
4042+
await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok');
4043+
4044+
const created = await createSession(agent, { workingDirectories: [URI.file('/work')] });
4045+
const sessionId = created.sdkSessionId;
4046+
const PARENT = 'toolu_bg_resume';
4047+
4048+
sdk.nextQueryMessages = [
4049+
makeSystemInitMessage(sessionId),
4050+
makeAssistantMessage(sessionId, [
4051+
{ type: 'tool_use', id: PARENT, name: 'Task', input: { description: 'Audit', subagent_type: 'Explore', prompt: 'go' } },
4052+
]),
4053+
{ type: 'system', subtype: 'task_started', task_id: 't1', tool_use_id: PARENT, description: 'bg' } as unknown as SDKMessage,
4054+
// The parent ends its own turn here, so the prompt queue drains.
4055+
makeResultSuccess(sessionId),
4056+
// It resumes when the subagent settles, so these arrive with no turn.
4057+
makeStreamEvent(sessionId, makeMessageStart()),
4058+
makeStreamEvent(sessionId, makeContentBlockStartText(0)),
4059+
makeStreamEvent(sessionId, makeTextDelta(0, 'the audit agent came back')),
4060+
makeStreamEvent(sessionId, makeContentBlockStop(0)),
4061+
makeStreamEvent(sessionId, makeMessageStop()),
4062+
];
4063+
4064+
const signals: AgentSignal[] = [];
4065+
disposables.add(agent.onDidChatProgress(s => signals.push(s)));
4066+
4067+
await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(created.session)));
4068+
4069+
// `sendMessage` settles on the result; the loop still drains what follows.
4070+
const resumed = () => signals.some(s => JSON.stringify(s).includes('the audit agent came back'));
4071+
for (let i = 0; i < 50 && !resumed(); i++) {
4072+
await tick();
4073+
}
4074+
4075+
assert.strictEqual(resumed(), true, `expected the resumed parent output to produce a signal; saw kinds: ${signals.map(s => s.kind).join(', ')}`);
4076+
});
4077+
40404078
test('canonical SDKAssistantMessage with text content does not double-emit signals already produced by stream_event partials (Phase 6.1 / Cycle F)', async () => {
40414079
// CONTEXT.md M8:875 — partials are advisory, final
40424080
// `SDKAssistantMessage` is canonical. With `includePartialMessages:

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,39 @@ suite('claudeSubagentSignals — Phase 12 emission', () => {
396396
});
397397
});
398398

399+
test('a background spawn records its turn and exposes it until it completes', () => {
400+
const state = new ClaudeMapperState();
401+
const log = new NullLogService();
402+
const registry = r();
403+
const PARENT = 'toolu_bg_anchor';
404+
405+
mapSDKMessageToAgentSignals(
406+
makeAssistantMessage(SESSION_ID, [
407+
{ type: 'tool_use', id: PARENT, name: 'Task', input: { description: 'Audit', subagent_type: 'Explore', prompt: 'go' } },
408+
]),
409+
SESSION, TURN_ID, state, log, registry,
410+
);
411+
mapSDKMessageToAgentSignals(
412+
{ type: 'system', subtype: 'task_started', task_id: 't1', tool_use_id: PARENT, description: 'bg' } as unknown as SDKMessage,
413+
SESSION, TURN_ID, state, log, registry,
414+
);
415+
const whileRunning = registry.outstandingSpawnTurnId();
416+
417+
mapSDKMessageToAgentSignals(
418+
{ type: 'system', subtype: 'task_notification', task_id: 't1', tool_use_id: PARENT, status: 'completed', output_file: 'o', summary: 's' } as unknown as SDKMessage,
419+
SESSION, TURN_ID, state, log, registry,
420+
);
421+
422+
// The router anchors the parent's post-turn output to this turn.
423+
assert.deepStrictEqual({
424+
whileRunning,
425+
afterCompletion: registry.outstandingSpawnTurnId(),
426+
}, {
427+
whileRunning: TURN_ID,
428+
afterCompletion: undefined,
429+
});
430+
});
431+
399432
// #region focused contract tests on the extracted exports
400433

401434
test('buildTopLevelSubagentReadyAction omits _meta description/agentName when input fields are missing or wrong-typed; still records the spawn', () => {

0 commit comments

Comments
 (0)