Run Cancellation and Abort Signal Propagation#
Overview#
Run cancellation is a cross-cutting concern shared across @assistant-ui/core, react-ag-ui, react-langgraph, and react-google-adk. The canonical mechanism is one AbortController per run, whose signal is forwarded to the underlying agent or stream. When cancellation is requested, the controller is aborted; cleanup listeners finalize run state and (in AG-UI) dispatch a RUN_CANCELLED event.
Entry points by package:
| Package | Entry Point |
|---|---|
@assistant-ui/core | LocalThreadRuntimeCore.cancelRun() (in local-thread-runtime-core.ts) |
react-ag-ui | AgUiThreadRuntimeCore.cancel() |
react-langgraph | cancelActiveRun in useLangGraphRuntime.ts |
react-google-adk | useAdkRuntime.ts via abort controller replacement |
AbortController Lifecycle and the Identity Guard#
All runtimes follow the same three-step pattern :
- Create — a fresh
AbortControlleris created at the start of each run and stored inthis.abortController. Starting a new run implicitly cancels the previous one by aborting its controller first. - Forward — the controller's signal is passed to the agent call or stream so the underlying HTTP request or async iterator can react .
- Identity-guarded cleanup — the
finallyblock clearsthis.abortControlleronly when the local controller reference still matches the field . Without this guard, a late-settling replaced run'sfinallyblock would null out the reference belonging to the replacement run, permanently making it uncancellable .
In AgUiThreadRuntimeCore, a separate activeRunAgent field tracks which agent instance started the live run , because updateOptions() can swap this.agent mid-run. cancel() calls abortRun() on the activeRunAgent — not necessarily this.agent — inside a try, with the local abort guaranteed in finally so a throw from user subclass code can't strand the thread in a running state .
AG-UI: RUN_CANCELLED Event and Resume Stream Special Cases#
In AgUiThreadRuntimeCore.startRun(), an "abort" listener is registered on the signal immediately after the controller is created . On abort, it:
- Dispatches
{ type: "RUN_CANCELLED" }through the run aggregator, marking the in-flight assistant message as cancelled. - Calls
finishRun(abortController)— the identity-guarded cleanup. - Invokes the optional
onCancelcallback.
Post-RUN_FINISHED race (PR #8282): After an AG-UI run sends RUN_FINISHED, the thread keeps running until the HTTP response body closes, so the Stop button remains briefly visible. If the user presses Stop in that window, RUN_CANCELLED would overwrite an already-complete answer. The fix in run-aggregator.ts makes RUN_CANCELLED a no-op when the current status is already complete .
Resume stream special case (PRs #8282, #8326): When replaying a persisted run via resumeStream, RUN_CANCELLED is bypassed to avoid wiping replayed content. Instead, cancelRun is locally overridden to flip only the message status to { type: "incomplete", reason: "cancelled" } . A subsequent fix further refines this: if the replayed answer already reached complete, the cancel is a no-op, preserving the finished content .
AG-UI: Unfinished Client-Side Tool Calls#
Auto-cancel on new run start: maybeAutoCancelPendingToolCalls() runs before every append, reload, or steerAway call. If the last assistant message is in requires-action / tool-calls state and autoCancelPendingToolCalls is not explicitly false, every unresolved tool call receives a synthetic error result ("Tool call cancelled by user") via cancelUnresolvedToolCalls(). This prevents the thread from getting stuck waiting on a frontend tool call that will never arrive .
Deferred resume after tool results: A tool result can arrive while a run is still draining (after RUN_FINISHED but before the stream closes). maybeResumeAfterToolResults() stores the message ID in pendingResumeMessageId instead of immediately starting a new run . After startRun's finally block exits, the pending resume starts only if the signal was not aborted , guaranteeing no two runs ever overlap.
Core and AI SDK: Cancelled Status Propagation#
Core (LocalRuntime) — PR #5090: An adapter that returns cleanly after abort (rather than throwing an AbortError) previously left the message as complete. The fix finalizes against abortSignal.aborted: if the message is still running and the signal is aborted, the status is set to { type: "incomplete", reason: "cancelled" } .
AI SDK — PR #6761: The AI SDK cancel path consumed the expected AbortError, but no cancellation state reached external-message conversion, so stopped responses appeared as complete. The fix records cancelled message IDs per-run in useAISDKRuntime and passes them through ExternalMessageConverterMetadata.cancelledMessageIds, where getAutoStatus resolves them as incomplete / cancelled .
AI SDK — PR #6832 (closed): Stopping during submitted state (request out, no chunk yet) left no assistant message at all. The proposed fix synthesizes an empty cancelled assistant message after stop() settles, unless a first chunk races the abort .
LangGraph: Hung-Stream Cancellation#
Two sequential PRs harden cancelRun() against streams that ignore the abort signal :
- PR #5525 — In-loop guard: The chunk-consumption loop breaks immediately on
abortController.signal.aborted, even when the underlying stream continues to yield . - PR #5543 —
abortableIterablewrapper: A stream that ignores its abort signal and stops yielding parks thefor awaitloop, leavingisRunningstuck. The wrapper races everynext()call against the abort signal; when abort wins, it resolves as{ done: true }and callsiterator.return?.()without awaiting .openAbortableIterable()additionally handles hangs during stream opening by racing the initialization promise against the abort signal.
Both utilities live in packages/core/src/utils/abortable-iterable.ts and are shared by both LangGraph and ADK .
Google ADK: Superseded Run Handling#
ADK uses a run-replacement model (newer run supersedes older) rather than LangGraph's serial queue. Two fixes address race conditions :
- PR #6836 — Stale-event and stale-error guards: Late events from a cancelled stream could replace messages in the new run, or incorrectly set
isRunning = false. Abort controller identity checks inuseAdkMessages.tsdrop superseded events; arunGenerationRefcounter inuseAdkRuntime.tsensures only the newest send can settleisRunning. - PR #6933 — Proactive stream teardown: The replacement path previously overwrote the abort controller without aborting it, leaving the old stream running. The fix explicitly aborts the previous controller before starting the replacement, and wraps stream acquisition/iteration with
openAbortableIterable()andabortableIterable()from core .
ADK vs. LangGraph at a glance:
react-langgraph | react-google-adk | |
|---|---|---|
| Run queuing | Serial queue | Run replacement (supersede) |
| Stale-event guard | abortableIterable wrapper | Identity check + runGenerationRef counter |
| Running-state fence | Queue serialization | runGenerationRef counter |