Conversation
The CLI re-emits a sub-agent's events on the parent session stream with the child's agentId, and the plain send_and_wait waiter never checked it, so a child's session.error failed the parent's wait, a child's session.idle resolved it, and a child's assistant.message could be returned as the reply. Learn sub-agent ids from the subagent.* lifecycle events on the same stream and let only events whose agentId is absent, empty, or not a known sub-agent drive the waiter. Child events are still broadcast to subscribers. The structured-output path is unchanged. Fixes #2750 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unknown non-empty agent IDs are incorrectly treated as root events, leaving the reported failure possible after missed lifecycle events.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
What changed in this PR
Fixes Rust send_and_wait handling so known sub-agent events do not complete a parent wait.
Changes:
- Tracks sub-agent IDs from lifecycle events.
- Filters waiter events by agent identity.
- Adds unit and regression coverage.
| File | Description |
|---|---|
rust/src/session.rs |
Adds sub-agent tracking and waiter filtering. |
rust/tests/session_test.rs |
Adds sub-agent event regression tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| fn is_root_agent_event(event: &SessionEvent, observed_sub_agents: &HashSet<String>) -> bool { | ||
| match event.agent_id.as_deref() { | ||
| None | Some("") => true, | ||
| Some(agent_id) => !observed_sub_agents.contains(agent_id), |
This comment has been minimized.
This comment has been minimized.
A resumed session starts with no known sub-agents and no history is replayed, so a background sub-agent that outlived the parent's detach could still resolve the resumed parent's send_and_wait with its own session.idle or session.error. While no sub-agent has been observed, treat every non-empty agentId as a sub-agent's; switch to the observed-set rule once one is known. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Pushed b746998: the gate now has two regimes. While no sub-agent has been observed on the session, every non-empty |
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for #2755 · copilot · sonnet50 · 49.5 AIC · ⌖ 11.9 AIC · ⊞ 8.1K
| !observed_sub_agents.is_empty() && !observed_sub_agents.contains(agent_id) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Good fix — this correctly prevents a sub-agent's assistant.message / session.idle / session.error (re-emitted on the parent stream with the child's agentId) from resolving/rejecting the parent's unstamped send_and_wait. However, this same bug appears to be present, unfixed, in the equivalent unstamped wait paths of the other five SDKs (Node.js Session.sendAndWait, Python CopilotSession.send_and_wait, Go Session.SendAndWait, .NET Session.SendAndWaitAsync, Java CopilotSession.sendAndWait). Each of those SDKs already filters agentId/AgentId/agent_id in their structured-output wait path but not in the plain/unstamped path. Consider filing a follow-up (or extending this PR) to port the is_root_agent_event / register_sub_agent logic to the other languages for parity. See summary comment for details.
The runtime's sub-agent bridge re-emits a child's assistant.message and session.error on the parent stream but not its session.idle. Reword the gate and test docs so the idle case reads as defensive coverage rather than protocol behaviour, and note that nested sub-agents are announced with their own ids. Comment-only change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Cross-SDK consistency reviewThis PR only touches the Rust SDK ( Finding: the same bug class appears to exist, unfixed, in the plain
Notably, Rust's own SuggestionConsider filing/tracking follow-up work to bring Go, .NET, Node.js, Python, and Java's plain
|

Fixes #2750
Problem
Session::send_and_waitreturned early, with the wrong result, when a sub-agent spawned during the turn failed or went idle. The CLI re-emits a child agent's events on the parent session stream with the envelopeagentIdset to the child's id, andsession.error,session.idleandassistant.messageare among them. The plain waiter inhandle_notificationnever looked atagent_id, so a child'ssession.errorfailed the parent's wait, a child'ssession.idleresolved it, and a child'sassistant.messagecould be returned as the reply.Note that
StructuredOutputState::observe(the schema-bearingsend_and_wait/send_and_wait_typedpath) already ignored events with a non-emptyagentId; this is a different code path from the plain waiter and is not changed here.Fix
Two private helpers in
rust/src/session.rs:register_sub_agentrecords the envelopeagentIdof everysubagent.started/subagent.configured/subagent.completed/subagent.failedevent in aHashSet<String>owned by the session's event-loop task (no lock:handle_notificationis awaited inline). Ids are never removed, since a child's final events can trail its completion event.is_root_agent_eventdecides whether an event may drive the waiter. Events with noagentIdor an empty one are always the root agent's. A non-emptyagentIdis judged in two regimes: while no sub-agent has been observed on the session, every stamped event is treated as a sub-agent's; once at least one sub-agent is known, only the ids in the set are.The waiter arm in
handle_notificationis now guarded byis_root_agent_event, so child-taggedassistant.message/session.idle/session.errorevents no longer touch the waiter. They are still broadcast toSession::subscribesubscribers and still reach the rest of the dispatch code. The autopilot-continuation idle exclusion is unchanged.Why the two regimes:
Sessionstarts with an empty set and no history is replayed, so a background sub-agent that outlived the parent's detach is unknown to the resumed parent; treating any stamped event as a sub-agent's until one is announced keeps such a child from failing or resolving the wait. In this regime the gate matches the absent-or-empty check used by the structured-output path.agentIdon root-agent events today and exposes no root agent id on the wire, so an absent-or-empty-only gate would break the day the runtime starts stamping root events: the waiter would never resolve and a fast failure would become a silent wait timeout. Once the session has learned its sub-agents from the lifecycle events the CLI already emits on the same stream, an unknown id is treated as the root's; the only way to misclassify a child is to have missed its lifecycle events, which is exactly the pre-fix behaviour and never worse.Tests
rust/tests/session_test.rs(fake server over duplex streams; asend_event_from_agenthelper stampsagentIdon the envelope):send_and_wait_ignores_sub_agent_error_and_idle: child announced viasubagent.started, then childassistant.message+session.error+session.idleleave the wait pending and are still delivered to a subscriber; a later childassistant.messagedoes not displace the root reply; rootassistant.message+session.idleresolve it with the root content. Fails onmain.send_and_wait_ignores_sub_agent_idle_then_resolves_on_root_error: child idle leaves the wait pending; rootsession.errorfails it with the root message. Fails onmain.send_and_wait_ignores_sub_agent_known_from_a_later_lifecycle_event: a child known only fromsubagent.failedstill cannot fail the wait with its trailingsession.error.send_and_wait_resolves_on_events_from_an_unannounced_agent_id_once_a_sub_agent_is_known: forward-compatibility pin for the second regime.resumed_send_and_wait_ignores_stamped_events_before_any_sub_agent_is_known: a session created throughresume_sessionreceives a stampedsession.errorfrom a child it never saw announced; the wait stays pending and a rootsession.idleresolves it.session.errorwithoutagentIdis already covered by the existingsend_and_wait_returns_error_on_session_error.rust/src/session.rsunit tests:root_agent_events_are_unstamped_or_unknown_once_a_sub_agent_is_known,sub_agents_are_registered_only_from_lifecycle_events.Verification
First commit (26ad11c), run locally on Windows with the pinned
1.94.0toolchain:cargo test(lib,session_test,prepared_session_test,jsonrpc_test,integration_test)integration_test)cargo clippywith the CI flagslocal-runtime)cargo fmt --checkSecond commit (b746998, the two-regime gate and the resumed-session test):
rustfmt --checkclean; localcargo test/clippyfor this commit are still pending and will be posted in a comment. The Rust CI matrix on this PR covers it.The
e2etest target (needs the pinned CLI runtime and the test harness) was not run locally; CI covers it.Notes for reviewers
rust/src/generated/**is untouched.🤖 Generated with Claude Code