a turn this tab did not start is still a turn in progress

"Turn finished · no closing message" kept appearing over an agent that was
plainly still working, with output resuming a moment later.

isGenerating was set in exactly three places: sendPrompt, and the two reconnect
handshakes. So it answered "am I waiting for a reply I asked for", never "is
this conversation working". Every other way a turn begins left it false — a
commander waking on its own schedule, a queued prompt the server delivered,
another tab driving the same session, or a turn that started before this tab
was looking. The events still render, but buildRows reads isGenerating to
decide whether the last turn is finished, so a live turn was `complete` and the
moment it sat on a completed tool call with nothing said after it, the banner
fired. Same gap left the composer enabled mid-turn, and a message typed then
was not marked injected — which splits the live turn and prints the banner a
second way, the exact failure injected exists to prevent.

session:claude now sets it: the harness's own turn-start signal, and the only
one that arrives before any work does. tool:start sets it too, because
session:claude is not durable and a socket connecting mid-turn never receives
one, while a tool cannot start outside a turn.

Two candidates were measured rather than argued. A genuinely silent turn ending
earns the banner honestly, but 667 of 673 recorded turns end with
assistant:text immediately before result, so that is ~1% and cannot be it. And
a stale sync:live is real but rare: correlating every attach in the officer log
against the agent's own later output, 15 of 549 "not generating" attaches were
contradicted and one provably so. That residue is left alone — the tool:start
fallback now corrects it on the next event — and noted on the refresh card,
which is the same seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
This commit is contained in:
2026-08-25 15:27:49 +00:00
co-authored by Claude Opus 5
parent f56b9de0d6
commit 6b8cf848ff
4 changed files with 122 additions and 39 deletions
+23 -4
View File
@@ -409,10 +409,26 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
break;
case 'session:claude':
// The turn has only just begun and the conversation is already addressable. Everything that makes
// a refresh survivable hangs off this: the URL is what the reattach handshake sends, so writing it
// here rather than at `result` is the difference between "refresh mid-turn and lose the turn" and
// "refresh mid-turn and watch it carry on".
/*
A turn is starting, and THIS TAB MAY NOT BE THE ONE THAT STARTED IT.
`isGenerating` used to be set in exactly three places — `sendPrompt`, and the two reconnect
handshakes — so it answered "am I waiting for a reply I asked for", not "is this conversation
working". Every other way a turn begins left it false: a commander waking on its own schedule, a
queued prompt the server delivered, another tab or another device driving the same session.
The tab still rendered the work, because the events arrive either way. But `buildRows` reads
`isGenerating` to decide whether the last turn is finished, so with it false a live turn is
`complete`, and the moment it sat on a completed tool call with nothing said after it the reader
got "Turn finished · no closing message" — over an agent that carried on a second later. It also
left the composer enabled mid-turn, and a message typed then was not marked `injected`, which
splits the live turn: the precise failure `injected` exists to prevent (see types.ts).
Marked here rather than on the first tool call because this is the harness's own turn-start
signal, and it is the only one that arrives before any work does.
*/
setIsGenerating(true);
setHasStarted(true);
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
@@ -449,6 +465,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
}
case 'tool:start': {
// Belt and braces for a turn whose `session:claude` this tab missed — an event that is NOT durable,
// so a socket that connects mid-turn never receives one. A tool cannot start outside a turn.
setIsGenerating(true);
toolCallsInTurnRef.current = true;
const call: ChatMessage = {
role: 'tool',