Resuming a compressed session says everything twice: inside the summary carrier


You compress a long conversation, quit, and come back with --resume the next morning. The first half looks right. Somewhere around the compaction point, a model reply starts repeating — not a sentence, the whole passage, word for word, twice. The scrollback doubles in length and you start wondering whether the context blew up and the message really is stored twice. Then you count rows in state.db and there are fewer than you expected.

That duplicate was fixed on October 20, 2026 (PR #142187). It is worth a post of its own because it exposes a structure you normally never see: the summary carrier.

Compaction wraps that reply once

In-place compaction does not delete old messages. It keeps a protected tail — the most recent turn, intact — and pairs it with a compaction summary inside a new row. That new row is the carrier. It keeps the original’s message_uid but not its content: its text is “the previous turn, then the summary”.

The design is sound. The uid keeps “which message is this” stable across compaction, and rewind, export and dedupe all lean on it. The problem was in the display layer.

Why it rendered twice

Since #140971, resuming a compressed conversation shows the whole history — which is exactly what users asked for, instead of just the summary. So the display read returns two sets of rows: the archived originals from before compaction, and the new carrier. The carrier shares the message_uid but wraps the summary in its body, and the display layer could not tell it was a container for the same passage. It rendered both rows, so the original text appeared once and the copy inside the carrier appeared again.

The official TUI end-to-end test put a number on it: the resume/compress E2E reported 200 words rendered more than once across /resume, --resume and /compress. That kind of assertion is worth more than a screenshot — it states plainly that 200 words were drawn more than once.

The fix: the carrier borrows the original’s display identity

The patch does not touch storage. It unifies how display identity is computed: _dedupe_display_generations in hermes_state_messages.py gains one rule — a summary carrier shares the display identity of the earlier row with its message_uid, and the newest copy wins, so the older copy stops rendering separately.

The function’s own docstring is explicit: collapse compaction generations so each logical message appears once (the protected tail is copied into every generation — same role, content and timestamp, different active flag and id); prefer the live copy, then the newest. And it is one definition shared by every display projection, not a separate implementation per surface.

The fix is deliberately narrow: only the carrier. A pruned tool stub keeps its archived full output visible, and test_display_dedupe_pruned_tool_payload pins that down — an earlier, unscoped uid match broke it.

A companion commit (c8aa61b7883) applied the same rule to exports and the transcript list, so hermes sessions export no longer emits a carried reply twice either.

Did any data go missing?

No. The whole bug was about display. The validation used the failing E2E’s own state.db: before the fix, the display read returned 15 rows including repeats; after, 14 rows with none. Fewer rows is the proof that the extra copy was a rendering artifact, not stored data.

A useful self-check: when you suspect duplication, compare the number of rows in the database with the number of messages the interface shows. If the database is normal but the view doubles, it is rendering. If both double, something really wrote twice. The wider test run behind the patch (tests/hermes_state, gateway resume and recovery RPCs, tests/tui_gateway) reported 4328 passed, 0 failed.

One neighbouring problem remains

An issue in the same area is still open: when compaction merges a user message into a preceding “model switch” notice, that user message disappears from the transcript. It is not the same bug — one shows a copy too many, the other shows a message too few — and this fix did not absorb it. If a user message vanishes after compaction, do not apply the conclusion above.

To understand compaction and context budgets properly, read how compaction completes in a single call and the compaction prompt rebuild. Session history, export and resume commands are in the hermes sessions reference, and if your goal is keeping long sessions inside a budget, context token optimization is the relevant one.

Can you use it today?

This fix is one of the items unblocking v0.22.3, so it lives on main only and is not in a published release (the latest is v0.22.2). Once you are on a build that contains it, /resume, --resume and /compress should each show a carried reply once; if it still repeats, confirm the version first, then look at the display layer.

The short version

The summary carrier is the container compaction leaves behind: it inherits the original’s uid, but its body is “previous turn plus summary”. A display layer that identifies messages by uid alone, without recognizing carriers, draws the same passage twice. The fix lends the carrier the original’s display identity and scopes the rule to carriers only, so pruned tool output still shows its archived content. Keep the diagnostic rule in mind: view doubled, database not — display problem; both doubled — data problem.