fix(chat): a turn that ended while its work runs on shows an hourglass and Send, not a live turn
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, journalagent.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 withagent_id→ stillfinished, 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.
| turn | origin/main prompt | this prompt |
|---|---|---|
| other session's status + offer (the incident, names changed) | working 0.94 | done 0.68–0.74 over 4 runs |
| waits on another session, acts when it reports | working 0.94 | working 0.99 |
| running deploy / background re-check | — | working 1.00 / 0.95 |
| waits on a reply / scheduled check-in | parked 0.68–0.75 | parked 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):
TestAHelpersToolAfterStopLeavesTheTurnFinishedfails 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 Stop | live turn: spinner word + timer, Stop, Queue |
|---|---|
![]() | ![]() |
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 running | real live turn |
|---|---|
![]() | ![]() |
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 exceptinternal/leadline/api TestDetach, which fails the same way on origin/main (TestServicesAreCountedfailed 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



