Agent chat: batched acpmux sends (shell function, loop, chain, MCP messages) draw one sent-message row per recipient

Merged
TE
5d ago
+1219-40

Summary

Leo (2026-10-30): "these should be properly rendered too/acpmux batch send tools given to you rendered consistently with other stuff"

Coordinators batch-send through the shell, for example s(){ acpmux send --no-wait --from X "$1" "$(cat "$2")" 2>&1 | tail -1; }; s a a.md; s b b.md. The transcript drew that as a raw Shell block, because the single-send reader (acpmuxSend, #25045) only takes a line that sends exactly one message. This PR draws such calls as the existing sent-message rows (SentMessageLine, #25047), one per recipient, under the existing "Messaged N agents" group (MessageGroupRow, #27864). It adds no new component.

  • Shell batches (acpmuxSendBatch in acpmuxSends.ts), with these shapes:

    • a shell function, where each call's $1…$9 fill in its words (s(){ … } and function s { … });
    • a for NAME in WORDS; do … done loop, where $NAME/${NAME} takes each word in turn;
    • ;/&& chains of sends.

    Other work in the line is counted ("and N more commands"), never hidden. Anything not fully known stays a command row: while read, for x in $(…), a target that is still an expansion, a send inside bash -c/reach/$(…), an unclosed loop or function, or more than 64 sends.

  • File-sourced bodies: when the words come from "$(cat path)", "$(<path)" or - < path, the row shows the recipient followed by the file name (in the code font) where the first line would go. This now applies to single sends too.

  • Per-message state in a batch: each --no-wait message reads its own line of output (queued / steered / acpmux: no_session: …). Otherwise every message takes the call's state.

  • acpmux MCP send batch / file / tag shapes (#28671, acpmuxMcpSends.ts): each messages[] item is merged over the top-level defaults. A file shows its path, and a tag item becomes one row per session it reached, taken from results[]. The first shape {to, text} keeps its existing MessageCard.

  • Raw stays reachable: every message row opens to its body plus Raw. Raw shows the whole shell command (the function and every call) or the MCP request/response.

  • MCP result handling (checked against #28671's acpmux_mcp_send.rs, with its owner's notes): a row is labelled by to (target is a routing key), how it landed comes from delivery (next_step / queued; status: queued only means still in the outbox), held and refused rows read "Couldn't send", and the tool's text lines (Sent to X: / On its way to X / Not sent to X: / Not sent (messages[i]):, then N of M sent.) are read when the harness records only content[0].text. Each recipient gets its own row identity (a tag item's sessions share an item index).

  • No repeated control column: inside a "Messaged N agents" card, a message row's chevron shows only while that row is hovered, focused or open. The group line keeps its own chevron, and the slot stays so words never move. This applies to every message group (it is CSS on .cv-msggroup__entry).

  • The live line ("Sending message to X") names the first message of a batch that is still going out.

Tools for agents (the "tools given to you" part)

The batch-send MCP tool already exists: acpmux send with messages / file / tag (#28671, merged). That is the fix that lets coordinators stop going through the shell. This PR renders both it and the legacy shell form.

Performance

Parsing is memoized per tool-call snapshot (acpmuxSendCalls: a WeakMap keyed on the snapshot, revalidated on command/status/output/exitCode/background). A streamed token on another call never re-parses this call's command line. acpmuxSendBatch.test.ts covers this: across 50 streamed output tokens on a neighbouring call, the batch call is parsed 0 times (only the changing call is read, once per token). The batch reader runs only when the single-send reader returns nothing and the line contains acp.

metricbeforeafterdeltaharness
re-parses of an unchanged send call per streamed token elsewhere in the turn1 (acpmuxSendCall ran in every toolGroups pass)0-1 per call per tokenacpmuxSendBatch.test.ts "memoized per call snapshot", parse counter

Risk

Low to medium. The change is confined to the transcript's send rows. The main risk is a shell line wrongly read as a batch, which would hide work behind a message row. To guard against that, the reader fails closed on every uncertain construct (see the tests), counts other work, and keeps the whole command one click away behind Raw.

Checks

Red at the first head: cmux-next fast gates (format only; fixed). The Blacksmith python failures (RunsOnFallbackTests, SigningHooksInStepWithGlaedaTests) are in workflow guard tests that this PR does not touch.

Tests

  • acpmuxSendBatch.test.ts: single, batched function, for loop (one line and multi-line), ;/&& chain, the non-acpmux shell cases and refused shapes, per-message output states, grouping into one messages group, memoization, and the MCP batch/tag/file shapes.
  • SentMessageLine.test.tsx: rows for a batch (recipient plus file name, each message opens on its own, Raw shows the full shell body); a file-sourced single send names its file.
  • acpmuxSends.test.ts: updated for the new file field.

Before / after

I viewed the after images from the gallery advisory (Linux Chromium, dark and light). Before: the same calls drew a raw Shell block. After: one row per recipient under the group line, file names in the code font, and the refused message standing alone as "Couldn't send to …".

Gallery: agent-pane.acpmux-batch-send/batches, agent-pane.acpmux-batch-send/batch-raw, agent-pane.acpmux-batch-send/batch-sending, agent-pane.acpmux-batch-send/look-guide, agent-pane.acpmux-batch-send/look-list, agent-pane.sent-message-rows/sent

The new gallery entry agent-pane.acpmux-batch-send has the default (card) plus three looks: guide, list and flat. The gallery advisory renders before and after for each, in dark and light.

🤖 Generated with Claude Code

Agent session: agent session