acpmux: leave superseded tool output out of a chat's open page

TE
7d ago
+811-15

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 status and content;
    • its content is text alone;
    • its params._meta is 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 _meta or any other _meta is returned as logged. So mergeToolItem, showsNothing and the subagent folds read the same records.

  • Such an update loses its content when a newer update of the same call replaces it. It keeps a _meta.acpmux.supersededContent note (bytes). An explicit content: null counts 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's startedAt from the first record it sees.

  • Calls are scoped to a prompt and to an owner. A mux user_message or turn_started resets them, even when the page's kinds leave 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 content as 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

metricbeforeafterdeltaharness or run link
prompts in the open page (synthetic Codex chat, 20 turns)03+3open_page_perf, run 41035475355
open page records / bytes (synthetic)25 / 110 KB (snapshots of one command)13 / 106 KB (whole finished calls)n/asame
pages / bytes to show 10 prompts (synthetic)43 / 10.4 MB2 / 353 KB−95% / −97%same
time to show 10 prompts, median of 5 (synthetic)17.5 ms12.5 ms−29%same
events_page open page alone, median of 5 (synthetic)0.1 ms3.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 prompts389 / 99.9 MB61 / 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 prompts6,360 / 1,484 MB285 / 83.7 MB−96% / −94%same
real Claude Code chat (1.4 GB)unchangedunchanged0%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, in claude.stdin, and in the isReplay echo in claude.user (about 1.15 GB together).
  • The tools list in claude.system.init and session_info_update._meta.claude repeats 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._meta of any other shape.
    • A stubbed record supersedes nothing.

    The test fold models mergeToolItem: content replaces, rawInput and _meta keys 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-fast at head and at the merge base, compared by test name. The failures at head also fail on base, except members of the durable_agents *_under_mdm_off family, 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