AgenticGENERALSPEC-EVOLVING

Stopping It, and Trying Again Safely

Cancellation is two-sided and retry is not free: a run that already called a tool has already changed something.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What does stop actually stop, and what is safe to do again?

The user intent

Someone can see the answer is going the wrong way. They want to stop it now, change the question, and try again — without wondering what the first attempt already did.

The obvious build

Call abort() on the fetch and clear the message. The request is cancelled, so nothing happened.

Why it breaks

Aborting the client request stops you receiving bytes. It does not stop the server, which keeps running the model and paying for tokens until told otherwise.

How it breaks in a real browser
  • Aborting the client request stops you receiving bytes. It does not stop the server, which keeps running the model and paying for tokens until told otherwise.
  • A tool the run already called has already had its effect. Aborting the stream does not un-send an email.
  • Clearing the partial text throws away output the user watched being produced and may have wanted.
  • Retrying the prompt re-runs the whole run, including the tool that already succeeded, so the effect happens twice (Retries, and the Duplicate Order).
  • The abort surfaces as an error and gets reported to the user as a failure, when it was their own deliberate action.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • AbortController cancels the client side of a fetch and rejects the in-flight promise with an AbortError — which is a control-flow signal, not a fault, and must not be rendered as one (Cancelling a Request Nobody Is Waiting For).
  • The server only learns of the cancellation if the transport propagates it, or if you tell it explicitly. For a long-running run the reliable pattern is an explicit cancel call carrying the run id.
  • A run is a sequence of effects. Cancellation partitions it into what already happened and what will not — and only the backend knows where that line fell (Showing What the System Is Doing).
  • Retry safety is idempotency, exactly as it is everywhere else: an operation is safe to repeat if repeating it produces the same end state (Retries, and the Duplicate Order).

What this makes the browser do

And which of it is avoidable.

  • Tearing down the stream reader and releasing the buffered text.
  • Releasing the transcript entry if it is discarded — or retaining it if kept, which is usually the better product decision.
  • Cleaning up per-run listeners and timers; a cancelled run that leaves its handlers attached is an ordinary leak (Memory Leaks).

Two sides, and the line between them

The mental model that makes this tractable is that a run has a line through it at the moment of cancellation. Everything before the line happened. Everything after will not. The client does not know where the line fell — only the backend does — which is why the UI's honest position after a stop is "here is what was produced, and here is what completed", not "nothing happened".

What a stop does and does not undo
immediate UI responseexplicit, not impliedcannot be undonenever happenUser presses stopFrontend aborts readPOST /runs/:id/cancelAgent backendTool already ran — effect standsRemaining steps skipped
UserLLMAgentToolDataDecisionHumanGuardrail
Cancelling both sides, and not lying about it
1const controller = new AbortController()
2
3async function stop(runId: string) {
4 controller.abort() // stop reading now
5 setStatus('cancelling')
6 try {
7 // tell the server, so it stops working and stops billing
8 await fetch(`/api/runs/${runId}/cancel`, { method: 'POST' })
9 } catch {
10 // best effort — the local abort already happened
11 } finally {
12 setStatus('ended') // partial text kept, labelled
13 announce('Response stopped')
14 }
15}
16
17try {
18 await readStream(controller.signal)
19} catch (e) {
20 // a deliberate stop is not a failure and must not be reported as one
21 if ((e as Error).name !== 'AbortError') reportError(e)
22}

Two things people leave out: the explicit server-side cancel, and the AbortError guard that keeps user-initiated stops out of error tracking.

What is safe to run again

Retry is where cancellation stops being a UI question. The run may have sent an email, created a record, or charged something. Re-running the same prompt re-enters the same loop, and unless the effectful call carries an idempotency key the backend has no way to recognise the second attempt as the same intent.

This is not a new problem and it does not need a new solution — it is the same idempotency question the rest of this atlas already answers. What is new is only that the caller deciding to repeat the operation is a model rather than your code, which makes the key harder to place and more important to have.

The user stopped it. What should retry do?

What did the cancelled run already do?

Nothing effectful — only reads

when The run searched, retrieved and generated, with no writes.

cost None. Retry freely; this is the common case and should be the default path.

An effectful tool completed

when Something was sent, created or changed before the stop.

cost Retry must carry the same idempotency key, or the user must be told what will repeat and asked (Retries, and the Duplicate Order).

An effectful tool was in flight

when The stop landed mid-call and the outcome is genuinely unknown.

cost You cannot resolve this in the client. Show it as unknown, and give the user a way to check the actual state rather than guessing.

Retry with an edited prompt

when The user is changing the question, not repeating it.

cost A new run with a new id. Do not reuse the previous run's idempotency keys — this is a different intent.

Cancellation and retry failures
TriggerSymptomCauseResponse
Stop pressedServer keeps generating for another minuteOnly the client aborted; nothing told the backendExplicit cancel endpoint carrying the run id.
Stop pressedError toast saying the request failedAbortError treated as a faultGuard on AbortError and render a stopped state instead (Loading, Error, Empty — The States You Did Not Render).
Cancel acknowledgement lostUI stuck in "Stopping…"Waiting indefinitely on a response that will not arriveLocal timeout into a terminal state; the local abort already succeeded.
Retry after a completed sendThe user receives two emailsNo idempotency key on the effectful tool callKey effectful calls per logical operation; the backend deduplicates.
Stop then immediate resubmitTwo answers interleaved in one messageBoth runs writing to the same transcript entryKey transcript entries by run id and ignore events from superseded runs.

How to build it

Most important first.

  • Make stop always available and always reachable, including by keyboard, for the entire duration of the run (Keyboard Operability).
  • Cancel both sides: abort locally for immediate UI response, and tell the server so it stops working.
  • Keep the partial output by default and label it as stopped. Discarding what the user watched appear is a surprising loss.
  • Never render AbortError as a failure. A deliberate stop is a successful outcome of a different kind.
  • Make retry explicit about effects: if the run already performed an action, say so and let the user choose whether to repeat it.
  • Give each run an id and each effectful tool call an idempotency key, so a repeat is recognisable as a repeat rather than a new request (Retries, and the Duplicate Order).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • The stop control needs a persistent, unambiguous accessible name — "Stop generating" — and must never be the last thing in the tab order or hidden behind a hover state. It is the control a user most urgently needs when something is going wrong.
  • Cancellation must be announced: "Response stopped". Silence leaves a screen-reader user unsure whether the stop registered, and pressing it repeatedly is the natural response (Live Regions and Announcement).
  • When the run ends by cancellation, do not move focus. The user pressed a button; leave them where they are and announce the outcome (Focus Management).
  • If a retry replaces content in place, announce that new content has arrived rather than relying on it visibly changing.
  • A confirmation prompt for repeating an effectful action is a dialog with all the obligations of one — focus trapped, Escape closes, focus restored (Accessible Component Patterns).

What can go wrong

Failure modes
  • A stop that only stops the UI, leaving the server generating for another minute at full cost.
  • A stop that leaves the interface in cancelling forever because the acknowledgement never arrived and nothing timed it out.
  • Retry duplicating a side effect the first attempt completed.
  • Rapid stop-and-resubmit producing two live runs that interleave into the same transcript entry.
  • An abort reported to error tracking as an exception, burying real errors in noise from users pressing stop (Frontend Error Tracking).
What can arrive out of order
  • A cancel can arrive while a tool call is in flight, so the tool completes after the run is marked cancelled — the effect exists and the UI says it did not.
  • A retry can race the first attempt's still-pending effect, producing the action twice without an idempotency key.
  • Stop followed immediately by resubmit can produce two live runs writing into the same transcript entry unless runs are keyed (Out-of-Order Responses).
Security
Misreads
  • "Abort cancels the operation." It cancels your side of it. The server keeps going unless told.
  • "Nothing happened, so retry is free." A run that called a tool already changed something. Retry safety is a property of the operation, not of the stream.
  • "AbortError means something went wrong." It means the user pressed stop.
  • "Cancel and retry are UI concerns." They are correctness concerns that happen to have buttons.

Measuring it, and what changes in the field

How you would see this
  • Cancellation rate, and where in a run it happens — a cluster after a particular tool is a signal about that tool, not about users.
  • Retry rate after cancellation, which distinguishes "wrong answer" from "too slow".
  • Server-side work performed after a client cancel, which is the direct cost of not propagating the stop.
  • Abort errors leaking into error tracking, which should be zero (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a flaky network, cancellation acknowledgements are themselves unreliable, so the UI needs a local timeout rather than waiting indefinitely.
  • When a run is expensive, propagating cancellation is a direct and measurable cost saving as well as a UX improvement.
  • With several runs in flight across tabs, cancelling one must not affect another — run identity has to be real (Auth Across Tabs).
What this costs
  • Explicit server-side cancellation costs an extra endpoint and an extra round trip, and saves real compute on every stop.
  • Keeping partial output preserves the user's work and clutters the transcript with incomplete answers — labelling solves most of it.
  • Confirming before repeating an effectful action adds friction exactly where friction is warranted.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThat aborting a client request does not undo a server-side effect follows from where the effect happened, so it holds for every transport, vendor and framework.
  • SPEC-EVOLVINGWhether a provider propagates a dropped connection as a cancellation, and whether it stops billing when it does, varies between vendors and changes. Do not rely on transport-level abort reaching the model; send an explicit cancel and treat the connection drop as a hint.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencycancellation
Domains that do not exist yet
  • Distributed Systems — a cancel racing an in-flight effect is the classic uncertain-outcome problem, and the honest interface answer is to show it as uncertain rather than to guess.