Skip to content

fix(rust): only root-agent events complete send_and_wait - #2755

Closed
Lukaze wants to merge 3 commits into
mainfrom
fix/rust-send-and-wait-root-agent-gate
Closed

Lukaze wants to merge 3 commits into
mainfrom
fix/rust-send-and-wait-root-agent-gate

Conversation

@Lukaze

@Lukaze Lukaze commented Sep 23, 2026

Copy link
Copy Markdown

Fixes #2750

Problem

Session::send_and_wait returned 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 envelope agentId set to the child's id, and session.error, session.idle and assistant.message are among them. The plain waiter in handle_notification never looked at agent_id, 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.

Note that StructuredOutputState::observe (the schema-bearing send_and_wait / send_and_wait_typed path) already ignored events with a non-empty agentId; 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_agent records the envelope agentId of every subagent.started / subagent.configured / subagent.completed / subagent.failed event in a HashSet<String> owned by the session's event-loop task (no lock: handle_notification is awaited inline). Ids are never removed, since a child's final events can trail its completion event.
  • is_root_agent_event decides whether an event may drive the waiter. Events with no agentId or an empty one are always the root agent's. A non-empty agentId is 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_notification is now guarded by is_root_agent_event, so child-tagged assistant.message / session.idle / session.error events no longer touch the waiter. They are still broadcast to Session::subscribe subscribers and still reach the rest of the dispatch code. The autopilot-continuation idle exclusion is unchanged.

Why the two regimes:

  • The conservative first regime covers resumed sessions. A resumed Session starts 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.
  • The set rule in the second regime is forward compatibility. The runtime omits agentId on 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; a send_event_from_agent helper stamps agentId on the envelope):

  • send_and_wait_ignores_sub_agent_error_and_idle: child announced via subagent.started, then child assistant.message + session.error + session.idle leave the wait pending and are still delivered to a subscriber; a later child assistant.message does not displace the root reply; root assistant.message + session.idle resolve it with the root content. Fails on main.
  • send_and_wait_ignores_sub_agent_idle_then_resolves_on_root_error: child idle leaves the wait pending; root session.error fails it with the root message. Fails on main.
  • send_and_wait_ignores_sub_agent_known_from_a_later_lifecycle_event: a child known only from subagent.failed still cannot fail the wait with its trailing session.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 through resume_session receives a stamped session.error from a child it never saw announced; the wait stays pending and a root session.idle resolves it.
  • Root session.error without agentId is already covered by the existing send_and_wait_returns_error_on_session_error.

rust/src/session.rs unit tests: root_agent_events_are_unstamped_or_unknown_once_a_sub_agent_is_known, sub_agents_are_registered_only_from_lifecycle_events.

Verification

cargo test --no-default-features --features test-support,derive --lib --test session_test --test prepared_session_test --test jsonrpc_test --test integration_test
cargo clippy --all-targets --no-default-features --features test-support,local-runtime,derive -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type
cargo +nightly-2026-04-14 fmt --all -- --config-path .rustfmt.nightly.toml --check

First commit (26ad11c), run locally on Windows with the pinned 1.94.0 toolchain:

check result
cargo test (lib, session_test, prepared_session_test, jsonrpc_test, integration_test) 249 + 155 + 26 + 11 passed, 0 failed (3 ignored in integration_test)
cargo clippy with the CI flags exit 0 (only the pre-existing build-script warning about local-runtime)
nightly cargo fmt --check exit 0

Second commit (b746998, the two-regime gate and the resumed-session test): rustfmt --check clean; local cargo test / clippy for this commit are still pending and will be posted in a comment. The Rust CI matrix on this PR covers it.

The e2e test target (needs the pinned CLI runtime and the test harness) was not run locally; CI covers it.

Notes for reviewers

  • The structured-output path keeps its existing absent-or-empty check, matching the equivalent gates in the Node, Python, Go and .NET SDKs; the plain waiter behaves identically until a sub-agent has been observed.
  • rust/src/generated/** is untouched.

🤖 Generated with Claude Code

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>
@Lukaze
Lukaze requested a review from a team as a code owner September 23, 2026 03:21
Copilot AI balanced review requested due to automatic review settings September 23, 2026 03:21

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 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 Medium severity

Open (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.

Comment thread rust/src/session.rs Outdated
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),
@github-actions

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>
@Lukaze

Lukaze commented Sep 23, 2026

Copy link
Copy Markdown
Author

Pushed b746998: the gate now has two regimes. While no sub-agent has been observed on the session, every non-empty agentId is treated as a sub-agent's, which covers resumed sessions (empty set, no history replay, a surviving background child must not resolve the wait). Once a sub-agent is known, the observed-set rule applies as before. New test: resumed_send_and_wait_ignores_stamped_events_before_any_sub_agent_is_known; the forward-compat test now registers a child first. rustfmt --check is clean; local cargo test/clippy for this commit are pending and I will post them here. CI covers it in the meantime.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot 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.

Generated by SDK Consistency Review Agent for #2755 · copilot · sonnet50 · 49.5 AIC · ⌖ 11.9 AIC · ⊞ 8.1K

Comment thread rust/src/session.rs
!observed_sub_agents.is_empty() && !observed_sub_agents.contains(agent_id)
}
}
}

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.

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>
@github-actions

Copy link
Copy Markdown
Contributor

Cross-SDK consistency review

This PR only touches the Rust SDK (rust/src/session.rs, rust/tests/session_test.rs), fixing a real bug (#2750): when the CLI re-emits a sub-agent's events on the parent session stream (stamped with agentId), the plain Session::send_and_wait waiter could incorrectly be resolved/rejected by a child's assistant.message / session.idle / session.error. The fix adds is_root_agent_event/register_sub_agent so only root-agent events (no agentId, or an agentId not in the observed sub-agent set) drive the waiter.

Finding: the same bug class appears to exist, unfixed, in the plain sendAndWait/SendAndWait of every other SDK. In each case the structured-output variant already ignores events carrying a non-empty agentId, but the plain variant does not filter on agentId at all:

SDK Structured variant filters agentId? Plain variant filters agentId?
Go go/structured_output.go:83 (event.AgentID != nil && *event.AgentID != "") go/session.go:503 SendAndWait — handler switches on AssistantMessageData/SessionIdleData/SessionErrorData with no AgentId check
.NET dotnet/src/Session.StructuredOutput.cs:137-158 (string.IsNullOrEmpty(...AgentId)) dotnet/src/Session.cs:385 SendAndWaitAsyncHandler has no AgentId check
Node.js/TS nodejs/src/session.ts:1301 (if (event.agentId) return; in sendAndWaitForStructuredMessage) nodejs/src/session.ts:1187 sendAndWait — its on(...) handler has no agentId check
Python python/copilot/session.py:2054 (if completion.done() or event.agent_id: return in _wait_for_structured_message) python/copilot/session.py:1849 send_and_waithandler matches on data type only, no agent_id check
Java java/sdk/.../CopilotSession.java:825 (event.getAgentId() != null && !event.getAgentId().isEmpty(), in the sendAndWaitStructured State.event) java/sdk/.../CopilotSession.java:603 sendAndWait(MessageOptions, long) — its handler has no AgentId check

Notably, Rust's own sendAndWaitStructured/structured path already used the simple "non-empty agentId ⇒ ignore" check (see rust/src/session.rs:224) before this PR. This PR goes further for the plain path by also handling the resumed-session edge case (no sub-agent history to learn from) via the observed-set regime described in the new is_root_agent_event doc comment — a nuance the other languages' structured-output filters don't yet need to consider, but the base "ignore sub-agent-stamped events in the plain wait" behavior is missing everywhere except Rust after this PR.

Suggestion

Consider filing/tracking follow-up work to bring Go, .NET, Node.js, Python, and Java's plain sendAndWait in line with Rust's fix here (and with their own structured-output variants), so a background/delegated sub-agent's failure or idle doesn't silently resolve or incorrectly fail a caller's plain send_and_wait/SendAndWait in those languages. This PR itself is self-consistent (Rust-only bug fix with matching tests) and doesn't need to be blocked on that — flagging for a follow-up.

Generated by SDK Consistency Review Agent for #2755 · copilot · sonnet50 · 57.8 AIC · ⌖ 11.7 AIC · ⊞ 8.1K ·

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.

send_and_wait resolves on a sub-agent's session.idle / session.error

3 participants