Skip to content

Fix spurious 'didn't finish' double-response on completed turns#219

Merged
pnewsam merged 1 commit into
stagingfrom
fix/spurious-continuation-nudge
Jun 23, 2026
Merged

Fix spurious 'didn't finish' double-response on completed turns#219
pnewsam merged 1 commit into
stagingfrom
fix/spurious-continuation-nudge

Conversation

@torrmal

@torrmal torrmal commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Symptom

On new tasks (both Anton and Hermes), a second, unprompted assistant bubble sometimes appears right after a normal reply: "Looks like that turn didn't finish. Want me to take another swing at it?"

Diagnosis

That bubble is not a real second turn — it's a client-side continuation nudge (CONTINUE_PROMPTS, App.jsx), meant for when you return to a conversation whose turn died while the app/server was down. reconcileTaskMessages was inferring "interrupted" from weak heuristics:

  • a stripped _streaming row (hadStreaming), or
  • a trailing user message,

both of which transiently hold right as a turn completes. The in-flight poll's finished-refetch made it worse by reconciling with both guards off (reconcileTaskMessages(enriched, false, false)), so a freshly-completed turn got nudged. New tasks hit it most: a new conversation enters and leaves the in-flight set exactly at completion.

Why this contradicts the architecture

Streaming is detached — the producer runs as a background task writing to a per-turn StreamBuffer that survives client disconnect/reopen and always ends with exactly one terminal record (completed/cancelled/error/interrupted/restart). A client disconnect or a normal completion is therefore not an interruption; a returning client just re-tails the buffer. The nudge heuristic ignored that terminal signal.

Fix — key off the terminal signal, not heuristics

  • reduceServerEvents exposes status; hydrateMessagesFromServerEvents tags finished turns _turnComplete (their persisted events carried response.completed/failed).
  • reconcileTaskMessages nudges only when the last turn is genuinely incomplete — a trailing user message with no reply, or an assistant turn with no terminal and no content. The hadStreaming-alone trigger is removed.
  • The in-flight finished-refetch passes allowContinuation=false — a producer leaving the in-flight list means it finished.

Auto-resume is unchanged: a still-running producer is already re-tailed on open via reconnectInFlight, so a genuinely running task resumes streaming with no nudge. Genuine interruptions (producer died, nothing persisted) still get the nudge.

Frontend-only; harness-agnostic (fixes both Anton and Hermes). tsc clean.

🤖 Generated with Claude Code

The second/unprompted 'Looks like that turn didn't finish…' bubble was a
client-side continuation nudge (CONTINUE_PROMPTS), not a real second
agent turn. reconcileTaskMessages inferred 'interrupted' from weak
heuristics — a stripped _streaming row (hadStreaming) or a trailing user
message — which transiently hold right as a turn completes. The in-flight
poll's finished-refetch made it worse by reconciling with both guards off
(false,false), so a freshly-completed turn got nudged → the double
response, on new tasks especially (a new conversation enters and leaves
the in-flight set exactly at completion).

This contradicts the detached-buffer design: a turn that finishes writes
a terminal record (response.completed/failed) and a returning client just
re-tails the buffer — a disconnect or normal completion is not an
interruption. So key the nudge off that terminal signal instead:

- reduceServerEvents exposes status; hydrateMessagesFromServerEvents tags
  finished turns _turnComplete (events carried response.completed/failed).
- reconcileTaskMessages nudges ONLY when the last turn is genuinely
  incomplete (trailing user with no reply, or an assistant turn with no
  terminal AND no content). Drops the hadStreaming-alone trigger.
- The in-flight finished-refetch passes allowContinuation=false — a
  producer leaving the in-flight list means it finished.

Auto-resume for a still-running producer is unchanged (reconnectInFlight
already re-tails the buffer on open). Harness-agnostic, so this fixes
both Anton and Hermes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@torrmal torrmal requested a review from pnewsam June 23, 2026 00:56
@pnewsam pnewsam merged commit 37e9fff into staging Jun 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants