fix(chat): a turn that ended while its work runs on shows an hourglass and Send, not a live turn

ST
10h ago
+935-16

Summary

A finished turn that turn check read as working kept the chat in live-turn mode: the spinner's fallback word ("Making way… · 49s"), Stop, and queue mode, while the person typed their next message. The reply that triggered it reported on another session and offered a follow-up ("I can nudge it… or tell you when its turn ends").

Diagnosis from the box's journal: agent.finished (seq 82707), then turn check's agent.ready with choice: "working" 2 s later. No tool event came between the Stop and the next prompt, so it was the classifier (A). A stray tool event after Stop (B) is real too, though: the journal holds 559 tool events after a Stop. The ones followed by nothing until the next prompt come from background subagents, whose hooks carry the parent's session_id.

 turn check prompt
+  a status report on another session or worker + an offer to follow it up → done
+  waiting on another session → working only when the agent says it will act when it reports back

 Claude adapter: PostToolUse
+  agent_id set (a subagent's tool) → data.helper = true

 ledger: agent.started{signal: tool}
+  helper && no turn open
+    a permission the helper asked for after Stop → finished again (wait ends)
+    otherwise → ignored, and not published (Redundant)
   the agent's own tool with no turn open → running (unchanged: a prompt
     Claude Code dequeues itself fires no UserPromptSubmit, e.g. seq 19552)

 chat pane (sessionState() unchanged everywhere else)
+  chatState(s): onItsWork(s) ? "finished" : sessionState(s)
+  footer: on-its-work item → ⧗ "Waiting on its work" + ON_ITS_WORK tooltip, no timer
+  reply box: replyMode(state) → Send, no Stop

onItsWork and ON_ITS_WORK match #183's definitions line for line, so whichever PR merges second has an easy conflict. #183 also changes the same turn-check prompt, so whichever lands second needs to merge both prompts and re-record internal/box/testdata/turncheck (the command is below).

Evidence

Turn check, the incident turn replayed on a real box (turn-check-replay.sh from #183, exact prompt and reply):

  • Before (installed v0.3.30-s.6, which already has #183's handoff wording): agent_state=finished unfinished=True, journal agent.ready choice=working.
  • After (the same box on a build with this branch, v0.3.30-s.8): agent_state=finished unfinished=False, twice; turn check publishes nothing.
  • Helper after Stop, same box, through berthd hook: Stop → finished; a PostToolUse with agent_id → still finished, and it isn't journaled; the agent's own PostToolUse → running.

Recorded AI Gateway answers (TestTurnCheckReplays). Each recording is tied to its exact request by a sha256, so a prompt change fails the test until it is recorded again. CI never calls the gateway.

turnorigin/main promptthis prompt
other session's status + offer (the incident, names changed)working 0.94done 0.68–0.74 over 4 runs
waits on another session, acts when it reportsworking 0.94working 0.99
running deploy / background re-check—working 1.00 / 0.95
waits on a reply / scheduled check-inparked 0.68–0.75parked 0.66–0.78 / 0.92

Recording again: BERTH_TURNCHECK_RECORD_KEY_FILE=<AI Gateway key file> go test ./internal/box -run TestTurnCheckReplays.

Helper tool events (turns_test.go):

  • TestAHelpersToolAfterStopLeavesTheTurnFinished fails on main ("a helper's tool use after the turn ended would be published") and passes here.
  • TestAHelpersPermissionAfterStopEndsWithItsTool: Stop → the helper asks permission (waiting, prompts held) → the helper's tool runs → finished and ready, with no turn made. Turn check's soft wait stays.

Chat, real Chromium on the mock (verify-berthd scenario chat-turn-state, no console errors):

finished + unfinished: ⧗ Waiting on its work, tooltip, Send, no Stoplive turn: spinner word + timer, Stop, Queue
on its worklive turn

Chat on a real box (scenario chat-turn-state-live: a dev box of this branch, real Claude Code, turn check on). Claude starts sleep 600 in the background and ends its turn; the box lists it finished + unfinished. Then a foreground sleep 45:

real turn, background task still runningreal live turn
live on its worklive turn real

Asserted: the row text Waiting on its work, the tooltip = the dashboard card's, a Send button with no Queue and no Stop …; and in a live turn /…\s*· \d+s/, Stop Claude and Queue. The same is in app/e2e/chat.spec.ts.

Checks run locally (fork PR CI waits for a maintainer):

  • go vet ./... passes.
  • go test -race ./... passes except internal/leadline/api TestDetach, which fails the same way on origin/main (TestServicesAreCounted failed once under load and passed on rerun).
  • typecheck:plugins, check:titles/type/grid/csp/themes, pnpm test (578), and build.
  • Full Playwright suite: 517 of 525 pass. The 8 that fail also fail on origin/main: a11y database-review, database :227, keyboard :173, sidebar-fleet ×2, sidebar-quiet, team-setup :331, and chat-feed :55, which is flaky on both (2/25 on main, 3/25 here).
  • Codex review + adversarial review: one finding (a helper's permission after Stop would stay waiting), fixed in c8b6de0c; the re-review was clean.

Merge Danger

Door: two-way

Prompt text, a ledger guard on events marked helper, and chat UI. No data written. Revert to undo.

Blast Radius: small

  • Turn check now reads a pure status report on another session as done, so that session shows Done instead of Working.
  • A background subagent's tool use after Stop no longer shows the session running.
  • Hook-driven Claude only: the structured (chat) driver already handled helpers by parent_tool_use_id.
  • The sidebar and tab spinner for finished + unfinished are untouched (#183 covers them).

🤖 Generated with Claude Code