fix(harness): bounded retry + honest turn end for empty final steps - #324
Conversation
…ec cases 1-4, 6, 7) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ning-only-step test (spec case 5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty_response' turn end A step with no text and no tool calls yet an orderly finishReason used to end the turn as a silent end_turn — experienced as ~3min of nothing after a tool result (spec 2026-08-21-empty-final-step-turn-recovery-design.md). Now: one silent re-run (history untouched, both attempts billed), then an honest 'empty_response' stop. finishReason-gated so 'length'/'content-filter' keep today's exact behavior. No new events or IPC; one console.error for diagnosis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ec case 9) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ive empty steps
The empty-step retry changes the harness contract: one scripted {} step no
longer ends a turn (it gets silently re-run and consumes the next script).
Eval-runner and specialist-nudge tests that staged 'model simply stopped'
double their empty step to keep their premise; the specialist test now also
pins the layered recovery (step-level retry first, turn-level nudge second).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…treamed nothing (spec decision 4) Review-driven addition: content-creating actions are what mint assistant turns, so a fully-contentless empty_response turn had nothing to attach its honest footer to — the worst-case shape of the bug stayed silent. Reducer now creates the turn on abnormal-stopReason turn-complete; the bubble renders a footer-only row for it. end_turn/interrupt behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…list beside mapStopReason (+tool-calls), structured log
Three review findings on the retry ladder:
- Whitespace-only steps ('\n\n') were pushed to history (truthiness gate) yet
classified empty (trim gate) — the retry then re-sent a conversation ending
in a dangling whitespace assistant message. One shared predicate now feeds
both gates.
- The orderly-finish list lived 1,480 lines from mapStopReason (drift risk)
and excluded 'tool-calls' — the empty shape a stream takes when every
announced call is dropped as malformed, likeliest on small local models;
it previously ended the turn with the raw passthrough reason 'tool-calls'.
Now ORDERLY_EMPTY_FINISHES, defined next to mapStopReason.
- The retry's only diagnostic was console.error, which reaches nobody in a
packaged build; now the structured log() that writes ~/.claude/desktop.log.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL
…d; one stamp site; event-time timestamp Review findings: the mint branch broke TRANSCRIPT_TURN_COMPLETE's absorb contract — the watcher re-emits turn-complete and re-dock replay re-delivers it, while content actions ARE uuid-deduped, so every replay arrived with currentTurnId null and appended a fresh ghost turn + timeline row, unbounded. Abnormal completions now record their uuid in seenUuids (both the stamp and the mint path — a live max_tokens turn must not re-mint as a ghost on replay) and the mint checks it. The duplicated metadata-stamp literal (which had already diverged: 'model: action.model' vs '?? turn.model') is collapsed to one stamp site, and a minted turn takes the EVENT's timestamp instead of Date.now() so a replayed footer row doesn't display the re-dock time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL
…tView/BubbleFeed gates for abnormal segment-less turns The PR's user-visible fix never rendered: both timeline render sites drop segment-less turns before AssistantTurnBubble mounts, and every shipped test mounted the bubble directly — so all tests passed while the real app still showed the exact unexplained silence the PR exists to fix. One shared abnormalStopReason() predicate (exported from AssistantTurnBubble) now drives the bubble's two footer gates AND both timeline gates; a new test crosses the ChatView boundary (state in → footer out) so this can't silently regress. The footer-only row also gains the timestamp trailer the bubble path already had, and the copy states the verified fact: 'The model returned an empty response twice. Retrying may help.' Visible side effect (flagged for Destin): a turn interrupted while still 'preparing' a tool call — segment-less after preparing-card reaping — now renders its 'Interrupted.' footer instead of vanishing entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL
…egment-less turns exist The doc enumerated stopReason as a closed set and documented turns as minted only by content actions — the invariant the empty-step-recovery PR inverts. Records the new mint rule, its uuid idempotency, and the shared abnormalStopReason() gate predicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL
…pty-step retry; one predicate for mint AND render gates Independent review of the first fix round found: - The empty 'tool-calls' shape (announced call, dropped as malformed) almost always leaves a 'Preparing…' card on screen, and the empty-step retry — unlike its manual-Retry and stall-retry siblings — never withdrew it: the step re-runs inside the same turn, so endTurn's reaping never fires and the orphan spins beside the retry's own cards until turn end. StepResult now carries pendingPreparing (started-but-never-completed call ids) out of the stream, and the retry withdraws them before re-running. Pinned by test. - abnormalStopReason was hand-inlined in the reducer (drift risk between the mint gate and the render gates). It now lives in chat-types.ts — shared by the reducer, the bubble's footer gates, and, via shouldRenderAssistantTurn, the ChatView/BubbleFeed timeline gates, which are now mirrored by construction instead of by comment. - The interrupt path's partial-text push was the last emptiness site still on truthiness; it now trims like the others (a whitespace-only partial is no partial at all). Known residual (deliberate, documented in the PR): rebuildHistory coalesces a whitespace-only step's persisted deltas into the retry step's text on resume — a cosmetic live-vs-rebuilt divergence, smaller than before this branch, left for a follow-up rather than touching the parity arbiter here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL
Deep review + fixes (2026-08-21, third session)Two independent adversarial review rounds ran against this branch; every finding was verified against the code before fixing, and each fix round was itself independently re-reviewed before pushing. Five new commits ( The big oneThe PR's user-visible fix was dead code. Both timeline render sites ( Also fixed
Visible side effects to be aware of (deliberate, flagged)
Known residuals (documented in workspace ROADMAP, deliberately not fixed here)
Verification: 🤖 Generated with Claude Code |
…ep-recovery references The workspace archived the spec when PR #324 merged; comment-only change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL
What
Native turns were silently ending mid-task when the model's final step came back with zero content (no text, no tool calls) under a valid
stopfinish — experienced as ~3 minutes of nothing after a tool result, then an unexplained stop requiring a manual "continue" (observed 3x live onstealth/ox-alpha, 2026-08-20/21; also reproduced onqwen3.8-max).Now:
stopReason: 'empty_response'and a visible footer: "The model returned an empty response. Retrying may help."'length'/'content-filter'empties keep today's exactmax_tokens/refusal behavior — the retry never masks a real stop reason.What it deliberately does NOT touch
Stall clocks, the park guard,
mapStopReason, the frozen emit surface (no new event types or IPC — the state rides the existingturn-completepayload),TRANSCRIPT_INTERRUPT, Kotlin (nativestopReasonis opaque passthrough — verified).Docs
youcoded-dev/docs/active/specs/2026-08-21-empty-final-step-turn-recovery-design.md(reviewed + ratified; includes prior-art comparison with opencode v1.18.21's sibling unknown-finish fix)youcoded-dev/docs/active/plans/2026-08-21-empty-final-step-turn-recovery-plan.md(independently reviewed; verdict approve-with-fixes, findings folded in)Testing
TDD throughout — 15 new tests written failing-first: the retry ladder (cases 1–4), reasoning-only steps classified empty (case 5, new
reasoningChunkshelper), the finishReason gate (case 6), interrupt-wins (case 7), specialist-child settle (8a/8b), footer copy (case 9), suite-stability (case 10), and the fully-silent-turn reducer/bubble pair (case 11). Downstream eval-runner/specialist suites that staged "model simply stopped" with one empty step doubled it to keep their premise under the new contract.harness-stall-watchdog.test.tsuntouched and green;verify.shgreen (tsc, vitest, knip, eslint, ast-grep); full desktop suite 5,995 passed.Known open item: the footer-only row (decision 4) has not been visually inspected — it renders only in the rare all-empty failure state; styling can be tweaked post-merge if needed.
🤖 Generated with Claude Code