Refreshing a chat lost the agent's answers: the turn folded to a seam and printed "Turn finished · no closing message" over a turn that had plainly answered. Reported against a commander, where it happened on most refreshes. The durable log was innocent. A refresh renders from Claude's .jsonl, and the harness delivers a finished background task as the agent's next user message — a <task-notification> envelope. The parser dropped it so it would not replay as a raw XML bubble you appear to have typed, and dropped a turn boundary with it, because turn-rows derives turns from the message list. The woken turn merged into the one before it, so that turn's closing prose became mid-turn work and folded, and the merged segment ended on a tool call, which earned the marker. Measured on the reported session: 30 messages, one user message, one fold hiding seven answers, the marker reproduced at 21 of 30 refresh points. The envelope now rebuilds into the same task row a live task:notification produces, carrying startsTurn. buildRows makes that a boundary and buildTurn exposes it as the turn's opener. Separately, the marker is withheld when a segment ends on a tool call we never saw come back — still running, or cut off holding it. A completed trailing tool call keeps its marker, so the test pinning that live behaviour is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
82 lines
5.5 KiB
Markdown
82 lines
5.5 KiB
Markdown
---
|
|
id: known-bugs/refreshing-a-finished-chat-turn-loses-output
|
|
title: 'Refreshing a finished chat turn loses output — replay collapses to a fold and the closing message vanishes'
|
|
theme: known-bugs
|
|
status: done
|
|
created: 2026-08-25
|
|
date_source: stated
|
|
order: 4
|
|
---
|
|
|
|
**Refreshing a finished chat turn loses output.** Reported by the owner 2026-08-25, with a screenshot:
|
|
a Command HQ chat (the "Voice Memos" commander, first turn), refreshed after the turn completed, shows
|
|
the prompt, then one collapsed seam reading `6 tool calls · 3 messages · 3 failed`, then
|
|
`TURN FINISHED · NO CLOSING MESSAGE`. The turn's actual output — including AskUserQuestion activity the
|
|
prompt explicitly requested — is gone from view. The header confirms how wrong the fold is:
|
|
`0 turns · 0 tok · $0` on a session that plainly ran six tool calls. He reports this happens "a lot of
|
|
times" on refresh, not once.
|
|
|
|
Two distinct wrongs, and they may have different owners:
|
|
|
|
1. **The collapse itself is (he recalls) a deliberate decision** — fold past tool calls on replay. If
|
|
so, the decision needs revisiting where it hides MESSAGES, not just tool chatter: "3 messages" inside
|
|
a collapsed seam means prose the agent wrote is behind a click, and a first-time reader concludes the
|
|
turn produced nothing.
|
|
2. **"No closing message" on a turn that had one.** The fold's own code says that banner was built for
|
|
turns that genuinely ended without an answer, and its comments record it once mis-fired for streams
|
|
(`turn-rows.ts:178`, `types.ts:38` — "printed 'Turn finished · no closing message' under work that
|
|
was still going"). A replay reaching that state on a turn that DID close means the replay source is
|
|
missing the final message — so look at what the fold is FED on refresh, not at the fold: the event
|
|
log the page replays (`chat_session_events` → `turn-rows.ts` folding) versus what the live socket
|
|
streamed. `3 failed` inside the seam is a second clue about what the stored events actually hold.
|
|
|
|
Where to look, verified in the tree: `Chat/components/turn-rows.ts` (the folding, with its own history
|
|
of exactly this class of bug in comments and tests), `TurnFold.tsx:88` (renders the banner), and
|
|
whatever writes the durable events the replay reads — the claude sidecar has owned `chat_session_events`
|
|
writes since the isolation work. Note `0 turns · 0 tok · $0` suggests the commander's turn ledger also
|
|
saw none of it, which points the same direction: the durable record, not the renderer.
|
|
|
|
---
|
|
|
|
**Fixed 2026-08-25. The durable record was innocent, and so was the fold.** A refresh never reads
|
|
`chat_session_events` at all: `GET /chat/sessions/:id` parses Claude's `.jsonl`, and `attach` sets the
|
|
replay cursor to the head precisely so the durable log is NOT re-delivered on top of it. Both symptoms came
|
|
from one line in the transcript parser.
|
|
|
|
The harness delivers a finished background task to the agent by writing it as the agent's next **user**
|
|
message — a `<task-notification>` envelope. `claude-sessions.ts` dropped that message, so it would not
|
|
replay as a raw XML bubble on your side of the conversation. Dropping it also dropped a turn boundary,
|
|
because `turn-rows` derives turns from the message list and a turn starts at a user message. So the woken
|
|
turn merged into the one before it, and everything that followed:
|
|
|
|
- the earlier turn's closing prose stopped being the turn's trailing run and became mid-turn work, which
|
|
**folds** — that is the "3 messages" behind a click, prose the agent wrote, hidden;
|
|
- the merged segment then ended on a tool call, so the exposed tail was empty and
|
|
`TURN FINISHED · NO CLOSING MESSAGE` was stamped over a turn that had answered.
|
|
|
|
A commander wakes on these, so its whole life replayed as **one** turn. Measured on the reported session
|
|
(`331fee09`, Voice Memos): 30 parsed messages, **one** user message, one fold hiding **seven** answers, and
|
|
the banner reproduced at 21 of the 30 points a refresh could land on — which is the "a lot of times".
|
|
|
|
The fix is in three parts. The parser rebuilds the envelope into the same `task` row a live
|
|
`task:notification` produces, carrying `startsTurn: true` (entities decoded; a malformed envelope with no
|
|
task id is still dropped). `buildRows` treats such a row as a turn boundary and `buildTurn` exposes it as
|
|
the turn's opener, the way it exposes your question. And the marker is withheld when the segment ends on a
|
|
tool call **we never saw come back** — that turn is still running or was cut off, and a reload renders with
|
|
`isGenerating` false until the socket attaches. A COMPLETED trailing tool call keeps its marker; the
|
|
existing test pinning that live behaviour passes unchanged.
|
|
|
|
Verified by replaying the real transcript through the real parser and row builder: two turns, each ending
|
|
with its own closing message on screen, no banner. 13 tests added
|
|
(`turn-rows.test.ts`, `task-notification-replay.test.ts` — the latter drives the real loader, because the
|
|
drop and the boundary live one call apart and that is how they came apart).
|
|
|
|
**The two clues in the report above were both red herrings, worth recording.** The durable log is not the
|
|
replay source. And `0 turns · 0 tok · $0` was transient: `command_hq_turns` now holds **2** turns and
|
|
$0.92 for this commander — which independently confirms the session really is two turns while the replay
|
|
was drawing one.
|
|
|
|
**Still open, and a product decision rather than a defect:** point 1 above. Within a correctly-bounded
|
|
turn, mid-turn prose still folds; only the trailing run is exposed. That is the documented intent
|
|
("question, summary, answer"), and it is untouched here.
|