a task the agent woke up to starts a turn

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
This commit is contained in:
2026-08-25 06:37:21 +00:00
co-authored by Claude Opus 5
parent b5c91a81c5
commit 0096392cde
7 changed files with 360 additions and 48 deletions
@@ -2,7 +2,7 @@
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: open
status: done
created: 2026-08-25
date_source: stated
order: 4
@@ -35,3 +35,47 @@ of exactly this class of bug in comments and tests), `TurnFold.tsx:88` (renders
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.