Skip to content

feat(unstable): link session message views and keep identity off chunks - #2465

Merged
benbrandt merged 3 commits into
mainfrom
rfd-session-message-references
Oct 23, 2026
Merged

benbrandt merged 3 commits into
mainfrom
rfd-session-message-references

Conversation

@benbrandt

@benbrandt benbrandt commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Summary

The subagents RFD reports a message between sessions as two independent views: the sender's outgoing session_message and the recipient's incoming one. Message IDs are local to each transcript, and Clients must not link views by equal IDs, so a Client has no way to go from one view to the other. This revision lets each view name the other, adds the fields to the unstable schema, and keeps a message's identity off its chunks.

RFD (docs/rfds/subagents.mdx)

  • senderMessageId on the incoming view names the messageId of the outgoing view in the sender's transcript.
  • recipientMessageId on the outgoing view names the messageId of the incoming view in the recipient's transcript.
  • Both are optional and nullable and follow the participant-ID rules: omitted or null means not supplied and keeps a known value, and a known value doesn't change. Either can arrive in a later chunk or update, so an Agent can start streaming a view before it knows the other one's ID.
  • When both views carry a reference they must name each other; a Client that sees conflicting references treats the views as unlinked. A reference doesn't mean the recipient finished processing the message, and it doesn't establish ownership.
  • Message IDs stay transcript-local, and the rule against linking by equal IDs stays.
  • Chunks carry only content. The participant IDs move off session_message_chunk, and the references are only on session_message, as message-level fields are for agent_message, whose chunks also carry only content. An Agent that learns a message's identity while streaming sends a metadata-only upsert. Before this, the participant IDs had two sources that could disagree, and a chunk patched message state even though its _meta doesn't. I found no recorded reason for putting them on chunks (feat(unstable): Intial RFD and schema for subagents #1992 and its review), and no implementation sends them there: claude-agent-acp only sends upserts. A new FAQ entry, "Why do chunks carry only content?", records this.
  • Both fields exist so that either side can find its counterpart directly, including when the other transcript isn't loaded or rendered.
  • The FAQ records the rejected alternatives: identical IDs (the ID is fixed at the first update, so an Agent would have to hold a view back until it knows the link), and references to the delegating tool call (session messages exist so delegations don't have to be modelled as tool calls).
  • Adds a validation scenario and a revision-history entry.

Schema

  • sender_message_id and recipient_message_id on SessionMessage, in v1 and v2, behind unstable_subagents, with builders.
  • SessionMessageChunk loses sender_session_id and recipient_session_id (and their builders) in v1 and v2. This is a breaking change to unstable types: identity fields sent on a chunk are now ignored. A malformed value is treated as not supplied (DefaultOnError), as for the participant IDs.
  • Regenerated schema.unstable.json for v1 and v2 and the draft schema docs. The draft prompt-turn (v1) and prompt-lifecycle (v2) pages mention the references, and their example now names the participants with an upsert before streaming chunks.

Users

Testing

  • New round-trip test in v1 and v2: both references serialize and decode, and null or malformed references mean not supplied.
  • The session-message tests now check that identity fields on a chunk aren't part of it, and supply late participants and references with upserts.
  • npm run generate and npm run check (clippy, fmt, prettier, spellcheck, cargo test --all-features) pass.

Add optional senderMessageId on the incoming view and recipientMessageId on
the outgoing view, so a Client can go from either view to the other without
inferring a link from equal message IDs. Either reference can be supplied
after the view starts streaming.
SessionMessage and SessionMessageChunk gain optional senderMessageId and
recipientMessageId in v1 and v2, following the participant-ID rules: omitted
or null means not supplied and retains a known value, and a malformed value
is treated as not supplied.
benbrandt added a commit to agentclientprotocol/claude-agent-acp that referenced this pull request Oct 23, 2026
## Summary

For a client on the subagents RFD (every ACP v2 client, and v1 clients
that declare `subagents`), session messages now replace the Agent/Task
call that launches a child. The parent no longer sees that tool call.
AIR's form is unchanged.

- **Prompt.** The launch's prompt is a `session_message` in both
transcripts: the parent's outgoing view and the child's incoming one.
Both use the launch's tool use id, and each names the other with
`recipientMessageId` / `senderMessageId` from
agentclientprotocol/agent-client-protocol#2465.
- **Answer.** A foreground child's answer, from the launch's result, is
the child's `session_message` to the parent (`<launch id>:reply`). It
uses the result as `AgentReporter` renders it, so the model-facing
`agentId: …` line and `<usage>` block are gone. A failure or
cancellation comes from the child's `idle` stop reason, which it already
reported. A background launch's result gets no reply.
- **Hand-back mode.** Claude Code (seen with 2.1.303) can have the child
hand its report back with a `SubagentHandback` call; the Agent result
then only says that the report "was delivered to you as a message". That
call is reported as the child's message to the parent, in both
transcripts and linked the same way, and the launch's reply is skipped
for that run. A failed hand-back delivered nothing, so it stays a tool
call of the child.
- **Unexposed work.** A launch whose child is never exposed is shown as
a tool call after all, with its final status
(`unexposedControlFallback`, the former `failedControlFallback`).
- **Hidden launches by id.** The adapter's own report that closes an
unfinished call (for example at a forced cancel) carries no `toolName`,
so the runtime tracks hidden launches by id to keep that report from
reaching the client for a call it never saw.
- **Replay** sends the same messages from the child's transcript: the
launch's prompt, the reply, and hand-backs. A launch without Claude
Code's metadata file keeps its tool call and the missing-history notice.
Replay can't tell a child's runs apart, so any hand-back in its
transcript suppresses its launch's reply.
- **SendMessage** calls stay tool calls of their sender, and the child
gets the incoming view without references, as before.
- README's subagent section describes the new behavior.

Do not merge before agentclientprotocol/agent-client-protocol#2465
settles the field names: a merge to `main` publishes a preview right
away. Clients on the published schema ignore the two fields (no
`deny_unknown_fields` in the Rust schema; the scenario validation with
the SDK's zod schema passes).

## Testing

- `npm run build`, `npm run check` and `npm run test:run` pass (57
files, 4 skipped as before).
- The six v2 `subagent-*.jsonl` scenario snapshots changed: the launch's
tool-call updates are gone, the parent gets the outgoing view, the
child's view carries `senderMessageId`, and foreground results became
`:reply` messages. AIR snapshots are unchanged.
- New unit tests cover hidden launches, nested launches, replies, failed
and background launches, unexposed launches with both final statuses,
the close report without a tool name, and hand-back success and failure,
live and on replay.
- A real run: the built adapter driven over v2 stdio with Claude Code,
one foreground subagent asked to answer PONG, then `session/resume` with
replay in a fresh process. Live and replay both showed the linked prompt
(parent → child), the linked hand-back "PONG" (child → parent), and no
Agent tool call.
A session_message_chunk now carries only content, as agent_message_chunk
does. The participant IDs and the new message references live only on
session_message upserts; an Agent that learns them while streaming sends a
metadata-only upsert. This gives each field one source and keeps chunks from
patching message state, which their _meta already does not.
@benbrandt benbrandt changed the title feat(unstable): link both views of a session message feat(unstable): link session message views and keep identity off chunks Oct 23, 2026
opensource-fork-sync Bot pushed a commit to cla7aye15I4nd/opensource-zed-industries__zed that referenced this pull request Oct 23, 2026
…es#66028)

The RFD reports messages between sessions, such as a parent delegating
work to its subagent, as `session_message` and `session_message_chunk`
updates. Each transcript gets its own view: the parent's outgoing
message, the subagent's incoming one. Zed dropped both.

- **Transcript entry.** Session messages are a new
`AgentThreadEntry::SessionMessage`, kept separate from user and
assistant messages. They reuse the keyed-message path: an upsert
replaces content, a chunk appends, and a message can start with a chunk.
Participants come only from `session_message` upserts: one that an
upsert omits keeps its earlier value, and a known participant cannot
change. Chunks carry only content, so the participant fields that schema
1.12.0 still has on chunks are ignored, following the RFD revision in
agentclientprotocol/agent-client-protocol#2465. v1 replaces message
metadata where v2 patches it by key.
- **Row.** It reads "To <recipient>" or "From <sender>". A subagent is
named with its parent's title for it, as on its permission card, and the
root session as "Main Agent". The name opens that session's thread; a
session the conversation never learned about shows as "Unavailable
Session" without a link.
- **v1 stays off.** Like `subagent_update`, v1 session messages are only
applied when Zed advertised the `subagents` client capability, which it
does not do yet.
- **Resume replay test.** A separate commit adds
`v2_resume_replay_delivers_subagent_history`. It confirms that a
subagent's history replayed during a v2 resume reaches the subagent.
zed-industries#65976's description said this wasn't handled; resume registers the
parent before sending `session/resume`, so it works.

Follow-up: the same RFD revision adds optional `senderMessageId` and
`recipientMessageId` so each view can name the other. Once a schema
release includes them, the row will jump to the linked entry instead of
opening the thread, and per-delegation subagent cards can build on the
same links.

Release Notes:

- N/A
@benbrandt
benbrandt marked this pull request as ready for review October 23, 2026 19:19
@benbrandt
benbrandt requested a review from a team as a code owner October 23, 2026 19:19
@benbrandt
benbrandt merged commit 788a916 into main Oct 23, 2026
14 checks passed
@benbrandt
benbrandt deleted the rfd-session-message-references branch October 23, 2026 19:19
@acp-release-bot acp-release-bot Bot mentioned this pull request Oct 23, 2026
buvidk1234 pushed a commit to buvidk1234/zed that referenced this pull request Oct 24, 2026
ACP subagents now get the same inline card as native subagents. The
anchor is the parent's outgoing session message to the subagent, which
claude-agent-acp now reports when it launches one
(agentclientprotocol/claude-agent-acp#1405). The card replaces that
message's "To …" row.

- **One card per subagent for now.** The first message the parent sends
to one of its own subagents becomes the card; later messages to the same
subagent stay rows. Until session messages can name each other
(agentclientprotocol/agent-client-protocol#2465), nothing links a
message to its part of the subagent's transcript or to its outcome, so
the card covers the whole subagent. claude-agent-acp sends one such
message per subagent, at the launch.
- **Status** comes from the subagent's reported state:

  | Reported state | Card |
  |---|---|
  | running, requires action | Running |
| idle, with no stop reason, `end_turn`, or one this version doesn't
know | Completed |
  | idle, `cancelled` | Canceled |
| idle, `error`, `refusal`, `max_tokens`, `max_turn_requests` | Failed,
with the error text |
  | no state yet, `unknown` | Unknown (new status) |

- **Title:** the parent's label for the subagent wins over the
subagent's own title, as on the permission row.
- **Stop** is offered only while the subagent runs and only when its
parent advertises `cancel` for it. The subagent's own titlebar now
follows the same rule. It also showed a failed or canceled ACP subagent
with a green check, because it read the outcome from a native tool call
that ACP subagents don't have.
- **Expansion** is keyed by a new `SubagentCardId` (a tool call or a
session message), so cards no longer need a tool-call ID.
- **Thread search** skips a message drawn as a card, since the card
doesn't show its text.
- **Redraw fix:** `update_subagent_association` only notified the
subagent's thread, so views that read the association from the parent's
side (the card, the permission row's label) didn't redraw when it
changed. It now emits `AcpThreadEvent::SubagentAssociationUpdated`.

Release Notes:

- N/A
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.

1 participant