fix(spa): don't mark a finished turn interrupted when Stop lands after done - #1366
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
cancelChatRequestnow checks whether the server'sdoneframe has already arrived before it treats a Stop as an interruption. If it has, the Stop:onclosewould have run on a new session;user_stoppedinterrupt 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, wheresession_titlecan arrive afterdone. 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 setsisStreamCompleteondone, but it also sets it insetError, 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:reset();done, so adonefrom a superseded stream id is ignored;Errorstill learns the turn ended.isStreamCompleteFor()had no callers, so it is removed. The internalisStreamCompletesignal 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.tsgets a new block, "Stop racing the end of the turn". These tests use a realfetchEventSourceagainst a body that stays open:done, before close: no/interruptPOST, nosetLastTurnInterrupted, transport aborted,endStreamingand loading cleared.user_stopped, still marks the turn interrupted locally, transport aborted.doneon a new session: the title fallback runs and there is no aggregates re-fetch.stream-parser.service.spec.tsgets ahasReceivedDoneblock covering:donesets it;donefrom a superseded stream is ignored;doneafter a parse error still sets it.npx ng test --watch=falseran 324 files and 4080 tests.Not in this PR
The page-hide
navigated_awayattribution has a similar gap betweendoneand close, becausestreamingSessionIds()still lists the session untilonclose. The backend narrows it: it recordsnavigated_awayonly while the single-flight lease is held. The lease is released in the stream generator'sfinally, though, so a page-hide inside that gap may still be recorded. That is left for a separate change.🤖 Generated with Claude Code