acpmux: leave superseded tool output out of a chat's open page
Leo, 2026-10-29: "i also like the way it just loads in the subagent stuff instantly :(" / "as in i want ours to load fast too :(" / "you're looking in the session transcripts in what cmux next is reading?" / "like the gui rendering the tui which comes from the daemon".
What was wrong
codex-acp sends a running command's whole output so far as the content of every tool_call_update. The increment rides beside it in _meta.terminal_output_delta. So the log grows with the square of a command's output. One real Codex chat had 27,564 tool_call_update records (150 MB) for 219 calls, and 123 MB of that was content a later update of the same call replaced.
A chat opens from the newest page of its log (_acpmux/attach, limit 400, maxBytes 128 KiB from the web client). Each snapshot counted against the byte bound, so the open page held a few snapshots of the last command. The client then paged older history (256 KiB per page) and often never reached a prompt.
What changes (serve path only)
New store/superseded.rs, used by hub/paging.rs for a page read newest first when the caller names maxBytes (the app's open and its older pages). In ACP an update's content replaces the call's content (mergeToolItem, transcript.rs), and no reader folds terminal_output_delta.
-
Only an update that carries nothing but output is touched:
- its fields, besides the deltas, are only
statusandcontent; - its content is text alone;
- its
params._metais absent, or holds only the native-subagent owner.
Any update with a title, kind, input, raw output, locations, a diff, an image, a Codex subagent
_metaor any other_metais returned as logged. SomergeToolItem,showsNothingand the subagent folds read the same records. - its fields, besides the deltas, are only
-
Such an update loses its
contentwhen a newer update of the same call replaces it. It keeps a_meta.acpmux.supersededContentnote (bytes). An explicitcontent: nullcounts as content. -
Such an update is left out when newer updates of the call carry all its fields again and it does not end the call. One exception: the oldest update of a call that the page reads stays, until an older record of the call (an older update, or its
tool_call) arrives. The client takes a call'sstartedAtfrom the first record it sees. -
Calls are scoped to a prompt and to an owner. A mux
user_messageorturn_startedresets them, even when the page'skindsleave those records out: a call id names a call within one prompt (cx-jzhka). A native subagent's calls (params._meta.acpmux.subagent) are kept apart from the chat's. -
A record too big for a message (stubbed by
fit) supersedes nothing. -
Unchanged: the log on disk, the live stream, forward reads, unbounded reads (export), fork and lane reports. No client change is needed, since the client already reads absent
contentas unchanged.
This is split from the first version of this PR, which also rewrote sealed segments on disk. That part changed what a fork or export pinned mid-prompt reads. A lossless disk design is a follow-up: record-level delta encoding, or compaction only behind readers' pinned seqs.
Performance
| metric | before | after | delta | harness or run link |
|---|---|---|---|---|
| prompts in the open page (synthetic Codex chat, 20 turns) | 0 | 3 | +3 | open_page_perf, run 41035475355 |
| open page records / bytes (synthetic) | 25 / 110 KB (snapshots of one command) | 13 / 106 KB (whole finished calls) | n/a | same |
| pages / bytes to show 10 prompts (synthetic) | 43 / 10.4 MB | 2 / 353 KB | −95% / −97% | same |
| time to show 10 prompts, median of 5 (synthetic) | 17.5 ms | 12.5 ms | −29% | same |
events_page open page alone, median of 5 (synthetic) | 0.1 ms | 3.7 ms | +3.6 ms (scans past the superseded updates) | same |
| real live Codex chat (1.5 GB log): pages / bytes to show its last 2 prompts | 389 / 99.9 MB | 61 / 18.3 MB | −84% / −82% | dry run of the same rules, bytes only, on a fleet Mac |
| same chat: pages / bytes to show its last 10 prompts | 6,360 / 1,484 MB | 285 / 83.7 MB | −96% / −94% | same |
| real Claude Code chat (1.4 GB) | unchanged | unchanged | 0% | dry run (its records are not output-only) |
The synthetic harness has 20 commands × 400 updates, built release on Linux CI:
- 1 update in 8 re-sends the whole output so far (34 KB at the end), as on the real log.
- Every update carries its increment.
Claude Code logs are big for other reasons this PR does not touch. In one 2.1 GB log:
- Prompt images are stored inline three times: in
session/prompt, inclaude.stdin, and in theisReplayecho inclaude.user(about 1.15 GB together). - The tools list in
claude.system.initandsession_info_update._meta.clauderepeats every turn (about 360 MB).
In another log (1.4 GB), raw claude.assistant / claude.user lines that repeat the ACP updates take about 640 MB. These are follow-ups.
Tests
-
store/superseded_tests.rs:- A read keeps each call as it ends, with only its start and end.
- A read without a call's start keeps the call's oldest update, without its content.
- Updates that carry more than output stay as logged (title, rawInput, diff, Codex subagent
_meta). - A call id reused across prompts.
content: null.- Native subagent scoping, and a
params._metaof any other shape. - A stubbed record supersedes nothing.
The test fold models
mergeToolItem: content replaces, rawInput and_metakeys merge, and it tracks startedAt and endedAt. -
hub/paging_tests.rs:- On base, the open page holds fewer than 2 of 6 prompts. With this change it holds all 6, and every call folds as the whole log does, the running one included.
- A page that starts inside a running command folds as the records after its start do.
- A page of
kinds: ["tool_call_update"]still resets at prompts. - Unbounded and forward reads are unchanged.
-
open_page_perf(#[ignore]) is the harness for the table. -
Full
cargo test -p acpmux --no-fail-fastat head and at the merge base, compared by test name. The failures at head also fail on base, except members of thedurable_agents*_under_mdm_offfamily, which fail in turn across runs on base too. Those were rerun on their own.
Risk
Low to medium. Only what a byte-bounded backward page returns changes, and only for output-only tool updates whose output a newer update of the same call replaces.
🤖 Generated with Claude Code
Agent session: agent session