Skip to content

agentHost: keep the parent output after a background subagent settles - #332075

Open
Ryan Ewen (RyanEwen) wants to merge 1 commit into
microsoft:mainfrom
RyanEwen:fix/subagent-resumed-output
Open

agentHost: keep the parent output after a background subagent settles#332075
Ryan Ewen (RyanEwen) wants to merge 1 commit into
microsoft:mainfrom
RyanEwen:fix/subagent-resumed-output

Conversation

@RyanEwen

@RyanEwen Ryan Ewen (RyanEwen) commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #332073

Problem

ClaudeSdkPipeline._processMessages derives the turn from the prompt queue head:

const parent = this._queue.peekParent();
const turnId = parent?.turnId;

A parent that spawns background subagents ends its own turn, so the queue drains and everything that follows arrives with turnId === undefined. ClaudeSdkMessageRouter.handle then drops it:

if (turnId === undefined) {
	return;
}

When the subagents settle and the parent resumes producing output, every one of those messages is discarded. The chat stops at whatever the parent said before spawning, and the early return is silent at every log level, so nothing is reported. The transcript keeps all of it, which is why restarting the agent host makes the full response appear.

Change

Anchor a message that arrives with no active turn to the turn a background subagent was spawned in:

  • SubagentSpawn records the turn that spawned it, first-writer-wins like its other fields.
  • task_started writes that turn onto the registry as a resume anchor. It lives on the registry rather than the spawn so it outlives removeSpawn, which task_notification performs on completion, and completion is exactly when the parent resumes.
  • ClaudeSdkMessageRouter.handle falls back to the anchor for a message with no turn, and drops the anchor once a message arrives for a different turn.

The anchor is written by the concrete task_started event for that spawn's own tool_use_id, so when several background spawns are alive the most recently started one wins deterministically. Nothing scans the spawn map for a turn.

Nothing changes while a turn is active, or when no background subagent has been spawned: the early return still applies, so an idle session drops stray messages exactly as before.

How to test

  1. In a Claude agent-host session, send a prompt that spawns background subagents, for example "use subagents to audit these two directories in parallel, read-only".
  2. Watch the chat while it runs.

The parent's output keeps appearing as the subagents report back. On main the response stops at the line before the spawn and never resumes, and restarting the agent host is the only way to see the rest.

Unit coverage adds two tests: one that the resume anchor outlives the background spawn that set it, and one end to end that output the parent produces after the subagent's completion notification still reaches the chat.

Note on #331872

That PR hooks the same early return for system / task_notification so a background subagent's chip completes. This is the general case: once a turn is resolved the mapper handles task_notification normally, so this supersedes it and I am happy to close #331872 if this lands.

AI disclosure: this pull request and the related code were written with the assistance of AI.

Copilot AI balanced review requested due to automatic review settings August 22, 2026 01:59
@vs-code-engineering

Copy link
Copy Markdown
Contributor

📬 CODENOTIFY

The following users are being notified based on files changed in this PR:

TylerLeonhardt

Matched files:

  • src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts
  • src/vs/platform/agentHost/node/claude/claudeSubagentRegistry.ts
  • src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes dropped Claude parent output after background subagents by preserving and reusing the spawning turn ID.

Changes:

  • Records turn IDs for subagent spawns.
  • Adds fallback turn resolution for turn-less SDK messages.
  • Adds registry and agent-level coverage.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
claudeSubagentSignals.test.ts Tests spawn turn tracking.
claudeAgent.test.ts Tests resumed parent output.
claudeSubagentSignals.ts Records the spawning turn.
claudeSubagentRegistry.ts Exposes outstanding spawn turn IDs.
claudeSdkMessageRouter.ts Uses spawn turns for turn-less messages.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

* resumes producing output once those settle.
*/
async handle(message: SDKMessage, turnId: string | undefined, context?: IClaudeSdkMessageContext): Promise<void> {
const resolvedTurnId = turnId ?? this._subagents.outstandingSpawnTurnId();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and it is the case that actually matters. The parent resumes after the notification, so removeSpawn clears the anchor at exactly the moment it is needed. My end-to-end test omitted task_notification entirely, so it was guarding the window before completion rather than after it, which is why it passed.

Reworked to anchor on the spawn event rather than on live spawns. task_started records the spawning turn on the registry as a resume anchor, which outlives removeSpawn; the router falls back to it for a message with no turn, and clears it once a message arrives with a real turn. The test now sends task_notification before the resumed stream_event output, so it covers the boundary you named.

AI disclosure: this comment and the related code were written with the assistance of AI.

Comment on lines +178 to +182
outstandingSpawnTurnId(): string | undefined {
for (const spawn of this._spawns.values()) {
if (!spawn.completed && spawn.turnId !== undefined) {
return spawn.turnId;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. outstandingSpawnTurnId is gone, so nothing scans the spawn map for a turn any more. The anchor is written by the concrete task_started event for that spawn's own tool_use_id, so when several background spawns are alive the most recently started one wins deterministically instead of whichever entry the map happened to yield first.

AI disclosure: this comment and the related code were written with the assistance of AI.

The pipeline takes 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 a background
subagent was spawned in. The spawn records that turn first-writer-wins like its
other fields, `task_started` writes it onto the registry as a resume anchor, and
the router falls back to it before deciding to drop.

The anchor lives on the registry rather than the spawn so it outlives the
`removeSpawn` that completion performs, which is the moment the parent actually
resumes. It is written by the concrete `task_started` event for that spawn's own
tool use id, so several live background spawns resolve to the most recently
started one rather than to whichever the spawn map happened to yield first.

Fixes microsoft#332073

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch on this edge case. Fallback logic is robust.

@RyanEwen

Copy link
Copy Markdown
Contributor Author

Correction to this PR's description, which offers to close #331872. It does not fully supersede it.

There is a case #331872 covers that this one does not: a background subagent spawned in turn A, where turn B starts and ends before the subagent settles. The first turn-B message clears the spawn anchor, so by the time the subagent completes there is no active turn and no anchor, and neither the spawn-turn nor the resume-anchor path closes the chip. #331872's idle-completion branch is what closes it.

Keeping both is the smaller change, so I would rather correct the claim here than close #331872 and leave that hole open.

AI disclosure: this comment and the related code were written with the assistance of AI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent host: a Claude session shows nothing more once the parent resumes after a background subagent

5 participants