Skip to content

agentHost: render Claude SDK-initiated reply turns - #336753

Open
Aleksandr Logunov (Longarithm) wants to merge 8 commits into
microsoft:mainfrom
Longarithm:fix/claude-background-turns
Open

Aleksandr Logunov (Longarithm) wants to merge 8 commits into
microsoft:mainfrom
Longarithm:fix/claude-background-turns

Conversation

@Longarithm

@Longarithm Aleksandr Logunov (Longarithm) commented Sep 18, 2026

Copy link
Copy Markdown

Fixes #332073

Problem and scope

Claude can start a parent reply after a background task finishes and the prompt queue has drained. Without a new protocol turn, the chat reducer drops that output. This PR gives idle-parent continuations their own turn and preserves that boundary on restore.

It does not resolve the separate review question about delayed output arriving after a new user prompt has already been admitted. That ownership thread remains open.

Behavior

  • Top-level assistant or stream output on an empty queue starts an SDK-initiated turn without sending another SDK prompt. Its empty request is hidden and uses MessageKind.Agent; the response remains eligible for file-change attribution.
  • Results complete the protocol turn they belong to, even if a different user turn is pending. Steering prompts with the same turn ID still complete together.
  • Registered child output and task notifications continue while the parent is idle, without starting a parent turn. Child tool results pass through file-edit observation before mapping.
  • An idle child's file edits are reassigned from the SDK routing ID to the active child turn before changesets are computed.
  • Stale client cancellations do not clear tools, cancel children, or abort a newer provider turn. Valid client cancellation is already applied by the host; provider abort, stream failure, shutdown, and disposal emit cancellation before listeners are removed.
  • Credits reset at the preceding turn's completion, preserving reports that arrive before the next reply's first frame.

The message loop uses an early-return adoption helper. In-flight entries do not require a fabricated SDK user message.

Restore, fork, and truncate

The SDK does not persist result events, so its transcript alone cannot recover the live boundary. The pipeline stores the first canonical assistant UUID and the adopted protocol turn ID in the chat's metadata database. Replay uses this marker to restore the separate turn, its ID, Agent origin, and hidden request.

Boundary metadata is optional:

  • each marker write opens and closes its own database reference;
  • a marker write failure is logged once and does not fail the live turn;
  • a metadata read failure falls back to replaying the transcript without boundary markers;
  • replay checks the raw assistant envelope before parsing its content, so an empty canonical assistant can still restore and anchor the boundary.

Fork and truncate use the same boundary map when resolving SDK anchors. Fork copies only inherited markers into the destination chat's metadata. Transcript messages without stored markers retain existing replay behavior; this does not repair old transcripts retroactively.

Related work and remaining question

Alternative to #332075: that proposal attributes output to the spawning turn; this PR starts a new turn when the queue is empty.

For the queued-user ownership race raised in review, the SDK declares an optional user_message_uuid on the first reply frame. Its absence can also mean a later frame, a synthetic turn, or an older producer, so absence alone is not a reliable ownership rule. A live SDK ordering trace and host turn-admission coverage are still needed. The queue regression covers a different ordering: an SDK turn is adopted first, then a new user prompt is admitted before its result.

Validation

687 tests pass across claudeAgent, claudePromptQueue, claudeMapSessionEvents, claudeSdkMessageRouter, claudeSdkPipeline, claudeReplayMapper, agentSideEffects, and sessionDatabase.

Regression coverage includes:

  • stream-first and canonical-first continuations, completed reducer state, Agent origin, and file-attribution eligibility;
  • per-turn credits, including reports arriving before the next turn's first frame;
  • adoption without an extra SDK prompt, completion with another protocol turn pending, and retained steering batching;
  • stale cancellation while a newer provider turn is active;
  • idle child output, terminal notifications, file edits, and changeset attribution to the active child turn;
  • exactly one terminal action on client abort, abort during rebind, provider abort, stream failure, and disposal;
  • cold restore through a fresh agent instance, fork marker copying, truncate anchors, empty canonical marker messages, metadata read failure, and marker write failure.

Targeted native TypeScript checking, repository hygiene, formatting, and git diff --check pass. The current diff is +546/-69 across 19 files, down from +555/-63 before these review fixes. Validation reused existing dependencies and Electron; no full application build or live SDK/UI reproduction was run for this upstream branch.

Copilot AI balanced review requested due to automatic review settings September 18, 2026 10:44
@vs-code-engineering

vs-code-engineering Bot commented Sep 18, 2026

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/claudeAgent.ts
  • src/vs/platform/agentHost/node/claude/claudeAgentSession.ts
  • src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts
  • src/vs/platform/agentHost/node/claude/claudePromptQueue.ts
  • src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts
  • src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts
  • src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts
  • src/vs/platform/agentHost/node/claude/claudeSessionMetadataStore.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.

🟡 Changes recommended

Unresolved routing, attribution, cancellation, and usage-accounting issues can produce missing or incorrect turn state.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds protocol turns for Claude SDK-initiated replies after the prompt queue drains.

Changes:

  • Adopts unsolicited top-level SDK output as a new hidden turn.
  • Completes or cancels adopted turns across lifecycle paths.
  • Adds reducer-backed queue and agent tests.
File summaries
File Description
src/vs/platform/agentHost/test/node/claudePromptQueue.test.ts Tests unsolicited-turn queue behavior.
src/vs/platform/agentHost/test/node/claudeAgent.test.ts Tests resumed replies and cancellation paths.
src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts Creates and manages SDK-initiated turns.
src/vs/platform/agentHost/node/claude/claudePromptQueue.ts Supports non-prompt in-flight turn entries.
Review details

Suppressed comments (1)

src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts:565

  • The new dispose() behavior is not exercised by the added lifecycle tests: they cover abort and stream failure, but never dispose while an SDK-initiated turn is active. Add a regression test that tears down an active adopted turn and verifies one cancellation, no later completion, and a closed reducer state.
	override dispose(): void {
		this._cancelSdkTurn();
		super.dispose();
  • Files reviewed: 4/4 changed files
  • Comments generated: 5
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +529 to +530
private _adoptSdkTurn(message: SDKMessage): void {
if (!this._queue.isEmpty || (message.type !== 'assistant' && message.type !== 'stream_event') || message.parent_tool_use_id !== null) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The current guard only handles an empty prompt queue; it does not distinguish a delayed SDK continuation from output for a newly admitted user prompt. I checked that QueueDrainContribution can admit another turn once the active turn ends. I have not reproduced the claimed SDK ordering yet, so I cannot claim this case is fixed. It needs coverage with queued-message admission and evidence of the SDK's prompt/output ordering before choosing an ownership rule; assigning a fresh turn to every top-level message would also misattribute normal user replies.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Still open; no ownership change for this case. I checked the SDK declarations: user_message_uuid can correlate the first reply frame with a client prompt, but it is optional and absent on later frames, synthetic turns and older producers. Absence alone would not safely identify a separate continuation. I have not obtained a live trace proving the claimed queued-prompt ordering, and a fabricated fake-SDK sequence would not establish that ordering. The PR description now explicitly limits the fix to idle-parent continuations and records this unresolved case. The other review fixes pass 366 tests plus targeted type checking and hygiene.

Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts
Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts
@Longarithm

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Use agent origin for resumed replies, clear reported credit totals at turn completion, and route registered subagent content and task notifications while the parent is idle. Avoid duplicate client cancellations and cancel provider turns before session event teardown. Cover credit reports before first output, edit attribution, idle child completion, abort during rebind, stream failure and disposal.
@saitanallensantiago26-beep

Fixes #332073

Problem and scope

Claude can start a parent reply after a background task completes and the prompt queue has drained. The pipeline routes that reply without a turn ID and never emits ChatTurnStarted, so the chat reducer has no active turn to receive it.

This PR handles that idle-parent continuation. The review concern about a delayed SDK continuation racing with an already admitted user prompt remains open; this PR does not claim to distinguish those two streams when a prompt is already pending.

Change

  • Register top-level assistant/stream output on an empty queue as an SDK-initiated turn without sending another SDK prompt.
  • Emit a hidden request with MessageKind.Agent, so the reply remains a real provider turn eligible for file-change attribution.
  • Complete the adopted turn on its result. Clear reported credits at the preceding turn's completion, preserving new credit reports that arrive before the next reply's first frame.
  • Route registered child-subagent content and task notifications while the parent is idle, without creating a parent turn. The host remaps tagged child actions to the child's active turn.
  • Treat client abort as an already-applied protocol cancellation, including during rebind. Emit provider cancellation on stream failure, shutdown or disposal, before the session's event subscriptions are torn down.

The message loop uses an early-return helper rather than nested adoption conditions. In-flight queue entries do not require a fabricated SDK user message.

Related work and open question

Alternative to #332075. That proposal attributes output to the spawning turn; this PR starts a new turn when the queue is empty so the reducer accepts output after the original turn completes.

For the queued-user race raised in review, the SDK declares an optional user_message_uuid on the first reply frame. Its absence can also mean a later frame, a synthetic turn or an older producer, so absence alone is not a reliable ownership rule. A live SDK ordering trace and host turn-admission coverage are still needed for that case. The review thread remains unresolved.

Validation

Regression tests use the fake SDK with the real pipeline and chat reducer. They cover:

  • Stream-first and canonical-first continuation output retained in a completed second turn.
  • Agent origin, exclusion from host-only notices, and last-attributable-turn selection.
  • Two-turn proxy usage: the second turn reports 20 nano-AIU, not the previous 100 plus 20; the new report arrives before its first reply frame.
  • Registered child output and terminal task notification while idle; unknown idle child output does not start a parent turn.
  • Exactly one terminal action and closed reducer state on client abort, abort during rebind, provider abort, stream failure and disposal, with no later completion.
  • Queue adoption without yielding another prompt.

366 tests pass across claudeAgent, claudePromptQueue, claudeMapSessionEvents, claudeSdkMessageRouter, and claudeSdkPipeline. Targeted TypeScript checking and repository hygiene/formatting/ESLint pass. Validation reused existing dependencies and Electron. No full application build or live SDK/UI reproduction was run for this upstream branch.

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.

🟡 Changes recommended

Unresolved turn-lifecycle, replay-boundary, and child file-attribution bugs can leave state inconsistent or lose attribution.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

src/vs/platform/agentHost/node/claude/claudePromptQueue.ts:116

  • This four-sentence JSDoc exceeds the repository's 1–2 short-sentence limit for API documentation. Keep the essential queue invariant concise; the implementation already makes the sentinel details clear.
	/**
	 * Register a turn the SDK started on its own — one the client never
	 * sent (e.g. the model resuming after a background task finishes).
	 * Seeds an in-flight entry so {@link peekParent} tags the turn's actions
	 * with `turnId` and {@link settleHead} fires its completion on `result`.
  • Files reviewed: 8/8 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeAgent.ts
Comment thread src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts
@saitanallensantiago26-beep

Fixes #332073

Problem and scope

Claude can start a parent reply after a background task completes and the prompt queue has drained. The pipeline routes that reply without a turn ID and never emits ChatTurnStarted, so the chat reducer has no active turn to receive it.

This PR handles that idle-parent continuation. The review concern about a delayed SDK continuation racing with an already admitted user prompt remains open; this PR does not claim to distinguish those two streams when a prompt is already pending.

Change

  • Register top-level assistant/stream output on an empty queue as an SDK-initiated turn without sending another SDK prompt.
  • Emit a hidden request with MessageKind.Agent, so the reply remains a real provider turn eligible for file-change attribution.
  • Complete the adopted turn on its result. Clear reported credits at the preceding turn's completion, preserving new credit reports that arrive before the next reply's first frame.
  • Route registered child-subagent content and task notifications while the parent is idle, without creating a parent turn. The host remaps tagged child actions to the child's active turn.
  • Treat client abort as an already-applied protocol cancellation, including during rebind. Emit provider cancellation on stream failure, shutdown or disposal, before the session's event subscriptions are torn down.

The message loop uses an early-return helper rather than nested adoption conditions. In-flight queue entries do not require a fabricated SDK user message.

Related work and open question

Alternative to #332075. That proposal attributes output to the spawning turn; this PR starts a new turn when the queue is empty so the reducer accepts output after the original turn completes.

For the queued-user race raised in review, the SDK declares an optional user_message_uuid on the first reply frame. Its absence can also mean a later frame, a synthetic turn or an older producer, so absence alone is not a reliable ownership rule. A live SDK ordering trace and host turn-admission coverage are still needed for that case. The review thread remains unresolved.

Validation

Regression tests use the fake SDK with the real pipeline and chat reducer. They cover:

  • Stream-first and canonical-first continuation output retained in a completed second turn.
  • Agent origin, exclusion from host-only notices, and last-attributable-turn selection.
  • Two-turn proxy usage: the second turn reports 20 nano-AIU, not the previous 100 plus 20; the new report arrives before its first reply frame.
  • Registered child output and terminal task notification while idle; unknown idle child output does not start a parent turn.
  • Exactly one terminal action and closed reducer state on client abort, abort during rebind, provider abort, stream failure and disposal, with no later completion.
  • Queue adoption without yielding another prompt.

366 tests pass across claudeAgent, claudePromptQueue, claudeMapSessionEvents, claudeSdkMessageRouter, and claudeSdkPipeline. Targeted TypeScript checking and repository hygiene/formatting/ESLint pass. Validation reused existing dependencies and Electron. No full application build or live SDK/UI reproduction was run for this upstream branch.

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.

🔵 Needs a closer look

Cross-cutting turn ownership, cancellation, and persistence still have unresolved correctness risks without live SDK validation.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 5
  • Review effort level: Balanced

Comment thread src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts
Comment thread src/vs/platform/agentHost/node/claude/claudeAgent.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts Outdated
Comment thread src/vs/platform/agentHost/node/claude/claudeSdkPipeline.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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@saitanallensantiago26-beep

Fixes #332073

Problem and scope

Claude can start a parent reply after a background task finishes and the prompt queue has drained. Without a new protocol turn, the chat reducer drops that output. This PR gives idle-parent continuations their own turn and preserves that boundary on restore.

It does not resolve the separate review question about delayed output arriving after a new user prompt has already been admitted. That ownership thread remains open.

Behavior

  • Top-level assistant or stream output on an empty queue starts an SDK-initiated turn without sending another SDK prompt. Its empty request is hidden and uses MessageKind.Agent; the response remains eligible for file-change attribution.
  • Results complete the protocol turn they belong to, even if a different user turn is pending. Steering prompts with the same turn ID still complete together.
  • Registered child output and task notifications continue while the parent is idle, without starting a parent turn. Child tool results pass through file-edit observation before mapping.
  • An idle child's file edits are reassigned from the SDK routing ID to the active child turn before changesets are computed.
  • Stale client cancellations do not clear tools, cancel children, or abort a newer provider turn. Valid client cancellation is already applied by the host; provider abort, stream failure, shutdown, and disposal emit cancellation before listeners are removed.
  • Credits reset at the preceding turn's completion, preserving reports that arrive before the next reply's first frame.

The message loop uses an early-return adoption helper. In-flight entries do not require a fabricated SDK user message.

Restore, fork, and truncate

The SDK does not persist result events, so its transcript alone cannot recover the live boundary. The pipeline stores the first canonical assistant UUID and the adopted protocol turn ID in the chat's metadata database. Replay uses this marker to restore the separate turn, its ID, Agent origin, and hidden request.

Boundary metadata is optional:

  • each marker write opens and closes its own database reference;
  • a marker write failure is logged once and does not fail the live turn;
  • a metadata read failure falls back to replaying the transcript without boundary markers;
  • replay checks the raw assistant envelope before parsing its content, so an empty canonical assistant can still restore and anchor the boundary.

Fork and truncate use the same boundary map when resolving SDK anchors. Fork copies only inherited markers into the destination chat's metadata. Transcript messages without stored markers retain existing replay behavior; this does not repair old transcripts retroactively.

Related work and remaining question

Alternative to #332075: that proposal attributes output to the spawning turn; this PR starts a new turn when the queue is empty.

For the queued-user ownership race raised in review, the SDK declares an optional user_message_uuid on the first reply frame. Its absence can also mean a later frame, a synthetic turn, or an older producer, so absence alone is not a reliable ownership rule. A live SDK ordering trace and host turn-admission coverage are still needed. The queue regression covers a different ordering: an SDK turn is adopted first, then a new user prompt is admitted before its result.

Validation

687 tests pass across claudeAgent, claudePromptQueue, claudeMapSessionEvents, claudeSdkMessageRouter, claudeSdkPipeline, claudeReplayMapper, agentSideEffects, and sessionDatabase.

Regression coverage includes:

  • stream-first and canonical-first continuations, completed reducer state, Agent origin, and file-attribution eligibility;
  • per-turn credits, including reports arriving before the next turn's first frame;
  • adoption without an extra SDK prompt, completion with another protocol turn pending, and retained steering batching;
  • stale cancellation while a newer provider turn is active;
  • idle child output, terminal notifications, file edits, and changeset attribution to the active child turn;
  • exactly one terminal action on client abort, abort during rebind, provider abort, stream failure, and disposal;
  • cold restore through a fresh agent instance, fork marker copying, truncate anchors, empty canonical marker messages, metadata read failure, and marker write failure.

Targeted native TypeScript checking, repository hygiene, formatting, and git diff --check pass. The current diff is +546/-69 across 19 files, down from +555/-63 before these review fixes. Validation reused existing dependencies and Electron; no full application build or live SDK/UI reproduction was run for this upstream branch.

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

4 participants