Cancel AI assistant in-flight run - #4563
Draft
nelsonkopliku wants to merge 16 commits into
Draft
Conversation
nelsonkopliku
force-pushed
the
cancel-run-on-new-chat
branch
from
August 3, 2026 07:40
dac8d78 to
66487ac
Compare
nelsonkopliku
force-pushed
the
cancel-run-on-new-chat
branch
from
August 3, 2026 08:07
66487ac to
9860d38
Compare
nelsonkopliku
force-pushed
the
cancel-run-on-new-chat
branch
2 times, most recently
from
August 3, 2026 10:43
1289ae8 to
95c558d
Compare
nelsonkopliku
force-pushed
the
cancel-run-on-new-chat
branch
2 times, most recently
from
August 5, 2026 15:09
5af280b to
b82f21c
Compare
nelsonkopliku
force-pushed
the
cancel-run-on-new-chat
branch
2 times, most recently
from
August 7, 2026 14:20
50322f0 to
61e3c4c
Compare
cancel_run now cancels the in-flight run and leaves the thread's agent standing, so the same thread can be prompted again. The old behaviour — cancel and terminate — moves to a new abandon_thread event, which is what "New chat" sends.
Stop in the composer becomes the only way to end a run; "New chat" is reachable only when the thread is idle. This restores #4581, which this branch had reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cancelActiveRun now no-ops when nothing is in flight: it is Stop's path only, and Stop is unreachable without a run. The provider gets its own abandonThread push for the thread it is walking away from.
New chat's threadID has a second writer: a cross-tab ai_configuration_created can mint one while the launcher is closed, with a run genuinely still streaming. The server tears the agent down outright for abandon_thread, so no RUN_FINISHED/RUN_ERROR ever comes back — the client has to settle its own run, the same contract cancelActiveRun() already honours. Without it, isRunning wedges true forever on that path.
The Stop button goes through ComposerPrimitive.Cancel, which reaches the transport via the runtime's onCancel. It is never disabled: a run can outlive the connection or the AI configuration, and neither should strand the user mid-answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AG-UI run aggregator stamps a cancelled run's message as incomplete/cancelled, which is the only signal distinguishing "the user hit Stop" from "the answer ended". reason: 'error' is left to MessageError. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…time ComposerPrimitive.Cancel (-> runtime.thread.cancelRun()) and a message-status- driven StoppedNotice both sit on confirmed @assistant-ui library defects with no supported extension point: cancelRun() resyncs a stale message snapshot that wedges thread.isRunning permanently, and react-ag-ui's subscriber dispatches a synthesized RUN_FINISHED on any settle (including cancel) that clobbers incomplete/cancelled before anything can read it. Add StoppedRunProvider, which owns stopRun/isStopped from our own state and threads them down as props the same way isRunning already is. The composer's Stop button becomes a plain Button calling onStop directly, and StoppedNotice now gates on isStopped + s.message.isLast instead of message status. This replaces the design of tasks 4 and 5 (83fc181, 8830bc5) without reverting them.
Three integration tests over the real agent, runtime and rendering path, covering what Stop has to do and what it must not do: - stopping mid-stream pushes a single cancel_run carrying the run's pinned ids, keeps the prompt and the partial answer on screen, marks the answer as stopped, and hands the composer back empty - stopping before the first token behaves identically, with no server event involved at all - the thread outlives the stop: the next prompt is a new run on the same thread_id All three fail against the pre-rework code, where the runtime's own cancel path left the composer wedged on Stop and the marker unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isStopped clears from a passive effect, which cannot run before the next run's placeholder commits. That leaves one committed render where the new, empty assistant answer is isLast, isStopped is still true from the previous run, and StoppedNotice shows "Response stopped." on it. Pass isStopped && !isRunning instead so the render itself withholds the marker while a run is in flight.
Three review findings, one commit: - The shipped "Response stopped." marker was a single thread-scoped boolean, cleared the instant the next run started, and gated on message.isLast — so it vanished from the cut-off answer as soon as the user sent another prompt. The approved spec requires it to persist on that message for the rest of the conversation. Replace the boolean with a Set of stopped message ids keyed off the thread's own messages, and delete the machinery that only existed to compensate for it: the isLast guard, the clearing effect, the isStopped prop chain through MessageBubble/AssistantThread/AIAssistant, and StoppedNotice's isLast read. - Pin the closed-launcher-mid-run path end to end. abandonThread() settling the local run (task 3) was previously guarded only by a transport unit test asserting subscriber.complete() was called. Add an integration test that opens the assistant, starts a run, closes the launcher, lets a cross-tab ai_configuration_created abandon the thread, and reopens — asserting the composer recovers to "Send message" and "New chat" is enabled. Confirmed it fails (composer stuck on "Stop generating") by temporarily dropping the _settleActiveRun() call from abandonThread(). - cancelActiveRun() now reports whether it actually settled a run (false when nothing was in flight), and StoppedRunProvider.stopRun gates marking a message on that — a click landing after RUN_FINISHED but before the composer swaps Stop→Send can no longer label a completed answer as stopped. Fixed a stale comment left over from the runtime-onCancel design this branch already moved away from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The final review left two test-side notes after the marker went per-message: - AssistantChatProvider.test.jsx stubbed useAuiState to a constant, so StoppedRunProvider's marking branch was dead in that suite and the mock's comment still described the deleted isRunning read. The stub is now selector-driven and StopTrigger reports whether the stop marked the thread's last answer, which pins the transport verdict travelling back through the provider's stop handler. - MessageBubble.test.jsx had a test named for a mid-run guarantee that rendered without a provider, so it only exercised the default context. It now stops a later message and asserts this one stays unmarked, which is the per-message guarantee its name claims. The dead isLast in the mock state is gone. Mutation-proved: dropping the return from handleStop fails the first new test; making isMessageStopped ignore its argument fails the second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isRunning describes the thread, not a message, so every assistant bubble receives the same value. AgentProgressIndicator told the live placeholder apart from the rest of the history by checking that the message carried no text yet — which held only because a run that reached RUN_FINISHED always left text behind. Stop broke that assumption. A run stopped before its first token leaves a permanently text-less assistant message in the conversation, so the next prompt turned the spinner back on under the answer tagged 'Response stopped.' as well as under the new one. Gate on the message being the thread's last, which is the same identity the library computes for isLast and the same one StoppedRunProvider uses to decide which answer a stop marks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Stop button is now assistant-ui's own cancel primitive rather than a plain button wired to the transport, and the "Response stopped." marker reads the message's own `incomplete/cancelled` status rather than a Set of message positions we maintain ourselves. `StoppedRunProvider` and the `onStop` prop threaded through AIAssistant → AssistantThread → PromptComposer are gone with it. What makes this possible is the seam `AbstractAgent` leaves for transports: `AgUiThreadRuntimeCore.cancel()` calls `agent.abortRun()` before aborting its own controller, and the base implementation is an empty no-op only `HttpAgent` overrides. `WebSocketAIAgent.abortRun()` supplies the websocket half — it pushes `cancel_run` with the run's pinned ids and errors the run Observable with an `AbortError`, the shape the ag-ui adapter reads as RUN_CANCELLED. That error is deferred twice, and both hops are load-bearing: a microtask to land after the runtime's own abort (an error raised before it is treated as a genuine failure and re-thrown as an unhandled rejection) and past the synchronous remainder of the external store's `cancelRun()`, then a timer that lands behind the message resync `cancelRun()` schedules, whose pre-cancel snapshot would otherwise overwrite the cancelled status. A run stopped before its first token now unwinds the turn: the empty exchange is dropped and the prompt goes back into the composer. That is the library's behaviour for a cancel with nothing streamed yet, and it is a deliberate change of position from the original design — nothing is lost, and there is no blank bubble left to mark.
nelsonkopliku
force-pushed
the
cancel-run-on-new-chat
branch
from
August 10, 2026 07:47
ded7dd9 to
e4870ac
Compare
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.
Clicking "New chat" mid-stream swapped threadID and cleared the messages, but nothing settled the run: AgUiThreadRuntimeCore kept isRunningFlag set and WebSocketAIAgent kept the old subscriber alive until the server sent RUN_FINISHED. The new thread therefore rendered an optimistic empty assistant message with a spinner and hid the send button, so the user could not prompt until the abandoned answer finished.
Frontend:
import({messages: []})and then cancels a run that is still in flight.import()rather thanreset()because the external-store runtime'sreset()leaves its own message repository untouched; clear-before-cancel becausecancelRun()re-applies a pre-cancel snapshot a macrotask later.onCancelthat routes to the transport, so the abort signal fires before the subscriber settles and the abandoned run stays silent instead of surfacing as a failure.cancelActiveRun()(pushescancel_run, completes the subscriber) and ignores RUN_STARTED/FINISHED/ERROR whose runId belongs to a run that is no longer active.Backend:
cancel_run: cancels the agent's current run and clears:loading. That flag is channel-scoped, so without clearing it the first prompt of the new chat would hit the double-send guard and get dropped with no reply at all.catch :exit.Description
Fixes #
How was this tested?
Documentation changes
Yes / No
Additional information