Agent pane: another agent's "sender -> receiver:" message is its card, not the person's bubble

Merged
TE
13d ago
+481-11

Leo: "this needs to be rendered better... it is now right"

Leo-ask: 51cd858ac0b0e899 (relayed by claude-sr2, render agent messages)

What was wrong

In claude-sr2's chat, summary-panel's report showed up as a plain grey user bubble, so it looked as if Leo had typed it. The raw summary-panel -> claude-sr2: prefix was still in the text.

The real record, from claude-sr2's acpmux event log (2026-10-22 04:22Z), is a user_message:

{"client": "acpmux-peer#01a161", "steer": true, "promptId": "…", "turnId": "…",
 "text": "summary-panel -> claude-sr2: Confirmed. On feat-cmux-next today, …"}

It has no _meta.provenance and no fromAgent. When acpmux send is relayed from another machine (acpmux-peer#…) or run at a shell (acpmux-cli#…), it records nothing about the sender. The only provenance acpmux sets for another session is session_message, and only when the sending connection proves it runs inside a session on this daemon. incomingPrompt already recognised [name] … and From name: … on that path, but not the arrow form that agents use.

Wire formats checked

How it arrivesWhat the pane getsRead by
cmux agent messageagent_message provenance and the rendered [cmux agent message] from … envelopealready handled (renderedBody)
acpmux send inside a session on this daemonsession_message / subsession_message provenancealready handled
Batched agent messages (busy chat)agent_digest provenance and [agent message i of N] from X linesalready handled
acpmux send at a shell, or relayed from a peeronly client: acpmux-cli#… / acpmux-peer#… and the text[name] and From name: already handled. X -> Y: is new
SendMessage between Claude Code sessions<cross-session-message> in an adopted chat's prompt (client: "adopt")already handled. Watched chats (client: "watch", hub/watch.rs) now get the same reading
cmux-forumannouncement provenance and context blocks: rules, not messagesnot touched

The fix (parser only)

  • incomingPrompt(text, msg, context?). context gives this chat's names and isChat(name) for chats in the session list. direct.ts passes the summary's name/shortName, and matches listed chats by name, short name, or the bare name of a peer chat listed as <peer>/<name>.

  • X -> Y: body (also →, and no spaces around the arrow) is an agent's message only if two things hold:

    • The record carries nothing from the person's own pane: the client is acpmux-cli#/acpmux-peer#, or the chat is adopted or watched.
    • X is a listed chat, or Y is this chat (case-insensitive) and X reads as an agent's name (summary-panel, coord-deputy; not leo).

    The card then names X, drops the address line, and renders the body as Markdown. The chip opens the sender through the existing useSenderChat.

  • When a sender is listed only after its prompt was read (a later list page, a new chat), the rows are read again once. This is bounded: it happens only when a newly listed chat names a sender that an addressed prompt was still waiting on. Until the summary arrives (a kept tail's replay), this chat's own name comes from its listed entry.

  • When provenance already names the sender, a leading sender -> receiver: line is dropped from the body, the same way [name] and From name: are, but only when the sender looks like an agent name (coord-deputy, summary-panel).

  • Client watch is read the same as adopt, so a watched chat's Claude Code envelopes, reminders and task notifications no longer show raw.

Why a strict prefix fallback and not metadata

The message carries no metadata that names an agent, and the same client prefix also covers the person typing at the acpmux TUI. So the text is all there is. The fallback only fires when the record isn't from the pane, the arrow is the first thing in the message, both names are single words, a colon and a body follow, and the message is sent by a listed chat or is addressed to this chat by an agent-like name. A user who types foo -> bar: … in the pane is never matched, because the client gate rejects it. At the TUI it doesn't match either: neither name is this chat or a listed one. Prose such as then a -> b: see doesn't match because it isn't at the start, and a-->b: is not an address. Without a context (a caller that knows no chats), the arrow form never matches.

The real fix for metadata is acpmux recording a session_message provenance for peer-relayed sends (see follow-ups).

What is left: the person, at the acpmux TUI or a shell, typing <hyphenated-name> -> <this chat's name>: … or naming a listed chat as the sender would get a card. The Codex review called this transport-not-authorship. It is accepted here because the record has nothing else to go on. The alternative is today's behaviour, where every peer-relayed agent message shows as the person's bubble. The acpmux follow-up below removes the guess.

Tests

  • incomingMessage.test.ts:
    • positive cases: ->, →, no spaces, body on the next line, cli and peer clients, a listed sender, adopt and watch;
    • negatives: pane clients; foo -> bar: and a -> b: see from the TUI; the arrow mid-sentence; multi-word names; no colon; no body; a URL; no context; an adopted typed prompt behind a reminder;
    • the provenance path drops the address line only for agent-like names;
    • a watched chat's <cross-session-message>.
  • test/agent-pane-addressed-message.test.ts: runs the recorded steer through AcpmuxDirectClient, with this chat named claude-sr2 and huge-blue/summary-panel in the list. It fails on base (the row is a bubble with the raw prefix) and passes here. A second test lists the sender after the prompt was read (session_changed) and checks that the prompt becomes its card. It fails if the re-read is removed.
  • Run on huge-blue: 174 related webview tests pass. tsc shows no errors in the changed files; the remaining errors are in base files (generated strings, composer-row-layout, home-cloud-rows). vp fmt and vp lint are clean.

Before / after

Gallery entry agent-pane.addressed-message / relayed runs the real incomingPrompt on the recorded text. Playwright Chromium on huge-blue, dark. "Before" is the same fixture with base incomingMessage.ts.

Before (base parser)After
addressed message beforeaddressed message after

Before: summary-panel's report is a grey bubble on the right that starts with summary-panel -> claude-sr2:. After: it is summary-panel's card, with the chip and no address line. The person's own foo -> bar: stays their bubble in both.

Follow-ups (not in this PR)

  • acpmux: give a peer-relayed acpmux send a session_message provenance (server/peer_forward.rs already forwards fromAgent in one case). The pane could then stop relying on the text.

  • acpmux sidebar line (hub/prompt_line.rs) for watched chats:

    • begin_watched_turn in hub/watch.rs sets last_prompt from the raw text without calling prompt_line;
    • prompt_line reads envelopes only for adopt;
    • prompt_line does not drop Claude Code's "Another Claude session sent a message:" line before the envelope.

    It is Rust and a different surface, so it is left out here.

Risk: medium. This is a behaviour change in how prompts are classified; the review is from a different model (Codex, xhigh).

Agent session: Air-Blue

🤖 Generated with Claude Code