Skip to content

fix(spa): don't mark a finished turn interrupted when Stop lands after done - #1366

Merged
philmerrell merged 1 commit into
developfrom
fix/stop-after-done-no-interrupt
Sep 27, 2026
Merged

philmerrell merged 1 commit into
developfrom
fix/stop-after-done-no-interrupt

Conversation

@philmerrell

@philmerrell philmerrell commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

What

cancelChatRequest now checks whether the server's done frame has already arrived before it treats a Stop as an interruption. If it has, the Stop:

  • aborts the transport and tears down local streaming state, as before;
  • runs the title fallback that the skipped onclose would have run on a new session;
  • returns before the user_stopped interrupt POST, setLastTurnInterrupted, and the interrupted-turn cost re-fetch.

Why

Loading, and so the Stop button, clears only when the transport closes, and that close can come after done. The gap is widest on a first turn, where session_title can arrive after done. A Stop in that gap wrote a false "interrupted" marker onto a turn that had finished. That also put a false interruption note on the session's next prompt. This is the same family of bug as the earlier false-"interrupted" marker issue. This change is from the 2026-09-25 kaizen review ("Guard Stop against a turn that already finished").

Why not isStreamCompleteFor()

The proposal suggested reusing isStreamCompleteFor(). It isn't reliable for this check. The parser sets isStreamComplete on done, but it also sets it in setError, which runs on any client-side parse error. After a parse error the server's turn keeps running, so a Stop at that point is a real interruption and must still be signalled.

This PR adds StreamParserService.hasReceivedDone(sessionId) instead. It is a per-stream flag that:

  • starts false on every reset();
  • is set only by this stream's done, so a done from a superseded stream id is ignored;
  • is recorded before the state gate, so a parser already in Error still learns the turn ended.

isStreamCompleteFor() had no callers, so it is removed. The internal isStreamComplete signal still drives rendering.

A small stopLocally() helper now holds the abort/endStreaming/loading teardown shared by the preview, finished-turn and interrupt paths.

Tests

  • chat-http.service.spec.ts gets a new block, "Stop racing the end of the turn". These tests use a real fetchEventSource against a body that stays open:
    • Stop after done, before close: no /interrupt POST, no setLastTurnInterrupted, transport aborted, endStreaming and loading cleared.
    • Stop mid-stream: still POSTs user_stopped, still marks the turn interrupted locally, transport aborted.
    • Stop after done on a new session: the title fallback runs and there is no aggregates re-fetch.
  • stream-parser.service.spec.ts gets a hasReceivedDone block covering:
    • done sets it;
    • a new stream resets it;
    • it is scoped per session;
    • a done from a superseded stream is ignored;
    • a parse error alone does not set it;
    • a done after a parse error still sets it.
  • The full SPA suite passes: npx ng test --watch=false ran 324 files and 4080 tests.

Not in this PR

The page-hide navigated_away attribution has a similar gap between done and close, because streamingSessionIds() still lists the session until onclose. The backend narrows it: it records navigated_away only while the single-flight lease is held. The lease is released in the stream generator's finally, though, so a page-hide inside that gap may still be recorded. That is left for a separate change.

🤖 Generated with Claude Code

…r done

Loading (and so the Stop button) only clears on the transport's close,
which can trail the server's `done` frame -- widest on a first turn, where
`session_title` may arrive after `done`. A Stop in that window fired the
`user_stopped` interrupt POST and set the local interrupted flag on a turn
that had already completed.

cancelChatRequest now checks StreamParserService.hasReceivedDone(): once
this stream's `done` has arrived it aborts the transport and tears down
local streaming state, runs the title fallback the skipped onclose would
have, and returns before any interrupt signal or cost re-fetch.

hasReceivedDone is a new per-stream flag recorded before the parser's
state gate. The unused isStreamCompleteFor() is removed instead of reused:
a client parse error also sets it while the server turn keeps running, and
a Stop then is a real interruption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 2a5d983 into develop Sep 27, 2026
7 checks passed
@philmerrell
philmerrell deleted the fix/stop-after-done-no-interrupt branch September 27, 2026 17:09
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.

1 participant