acpmux: a chat whose records together pass one message still opens (attach reply fits)

Merged
TE
5d ago
+314-49

Leo, 2026-10-31: "this is completely hardlocked bc of message size". His chat showed "message too large to send: 16907300 bytes, the limit is 15728640" with Retry, and every Retry failed the same way.

What was wrong

That error was not the send. It was the chat's open: _acpmux/attach returns a page of records and the turnOpener (the prompt of the turn the page starts inside). Each may keep one record of up to paging::MAX_RECORD_BYTES (14 MiB), and nothing bounded the two together. A large prompt (inline images) and a large record in the page made a 16.9 MB reply. server/mod.rs turns any reply over rpc::MAX_MESSAGE_BYTES (15 MiB) into message.too_large, so the pane's handshake failed at every open and every Retry, and the chat never opened again.

Fix

  • hub::fit_reply (hub/paging.rs): when a reply of records would pass one message, the largest records' msg become the existing omitted stub ({"_meta":{"acpmux":{"omitted":{"bytes","reason":"too_large"}}}}), largest first, until the reply is under MAX_MESSAGE_BYTES - 1 MiB. Every record keeps its seq, kind, dir and time. It covers events and turnOpener.
  • _acpmux/attach runs its reply through it.
  • A record's envelope fits too: a record given up as too large also drops a kind past 1 KiB (an agent names its own sessionUpdate, which becomes the kind).
  • If the reply still does not fit, the session fields go, largest first, each to its empty value and named in session._meta.acpmux.omitted. First the detail's own fields (configOptions, models, availableCommands, rulesJson, pending, ...; copied whole from the agent), then any other field (a title, tags), and last the chat's identity fields. Only sessionId, acpmux's own id, always stays, because an agent's own id reaches sessionReceipt. The byte count is exact, and a value is only replaced by a smaller one.
  • record_bytes, which every page uses for its maxBytes, now counts the record as written, escapes and all. It used the kind's length, and a kind of 169 NULs is 1 KiB of JSON, so 14,000 such records passed as a 4 MiB page and were over 15 MiB.
  • native_history's own copy of that loop now calls the shared one; its tests still cover it.

#28685 (zlib frames) does not cover this: MAX_MESSAGE_BYTES still bounds the JSON text there.

Follow-ups, separate PRs: the pane stops retrying a message.too_large and says which attachment to take out; the composer sends attachments by reference (prompt-uploads.md) instead of inline base64.

Tests

Red first (the test alone at 4f6678c3c75), then the fix:

cargo test -p acpmux --test hub_integration an_attach_page_whose_records_together_pass_one_message_still_opens

  • base + test (4f6678c3c75, run 41160310328): FAILED, the chat opens: "message too large to send: 18878940 bytes, the limit is 15728640", the same refusal as Leo's chat.

  • head 4197118dc90 (run 41160921241): ok, 1 passed; cargo test -p acpmux --lib native_history: 9 passed.

  • head f3a14738538 (after the review's finding): --test hub_integration an_attach_page: 2 passed; --lib -- paging native_history: 17 passed, including a_reply_fits_one_message_whatever_holds_its_bytes (a 9 MiB configOptions plus a 5 MiB models: the record is stubbed, then configOptions goes, and models stays).

  • head 938619a0503 (after four review rounds): --lib -- paging native_history: 17 passed, including a_reply_fits_one_message_whatever_holds_its_bytes with a 15 MiB title, a 15 MiB agent session id in sessionReceipt, a 15 MiB event kind, and a NUL kind's record_bytes. --test hub_integration prompt_supersede 3 runs: 8 passed each. At 1cf35acabd3 (the commit before) the whole cargo test -p acpmux --lib passed 2495, failing only that test's receipt assertion, which 938619a corrects. The whole --test hub_integration passed 623, failing only prompt_supersede::a_prompt_queued_before_a_restart_is_still_replaced (restart queue order, no pages), which passed in all three reruns.

Review (codex gpt-6-astra): SHIP at 938619a05039e4d8928cd6fe0fd90bb84a4481e8. Earlier rounds blocked on the session detail (4197118), summary fields (f3a1473), sessionReceipt (e10cf5b), an event's kind (ae68a05) and envelopes counted by length (d350d6b), each fixed in the next commit.

Reds: cmux-next checks fails only on Tier routing (tests/test_cmux_next_base_tier.py: test_a_feat_cmux_next_push_defers_the_base_tier, test_a_push_whose_scheme_compile_is_the_dev_landing_build_keeps_it, test_coalescing_off_or_another_branch_runs_the_base_tier_on_the_push), and the same three error at the base tip d20e58e9359c. The panic ratchet's RED hits are all in crates this PR does not touch (cmux-automations, cmux-tui-core, cmux-chats-mirror). In acpmux it reports only the warn-only tracked lints (arithmetic, casts).

(The first test commit bd6a252dab4 sized its prompt so one record already passed 14 MiB; 4f6678c3c75 fixed the premise.)

Observed / Not run

  • Observed: the cmux-ci runs above (Linux, 32 vCPU).
  • Not run: Leo's own chat (its machine can't be reached from here); a fleet NEXT build opening a chat with a 20 MB attachment is the follow-up proof.

Changelog

Fixed: a chat holding very large messages opens again instead of failing with "message too large to send" on every Retry.

Agent session: agent session

šŸ¤– Generated with Claude Code