Cancellation

There is no abort() API. Cancelling is appending, and the running consumer finds out through a signal.

Cancelling is appending

// in the browser: push from useSession
push({ type: 'cancelled', payload: { lastSeenIndex: index } })

A user presses stop on a streaming AI response. That's an event: same route, same auth, same log as every other event. Two things then happen, at two speeds.

The running consumer stops

server/chat.ts
import { consumer, createServer } from 'experimental-a2/server'
import { chat } from '@/contracts'

const generate = consumer({
  contract: chat,
  name: 'generate',
  types: ['generate'],
  abortOn: ['cancelled'],
}).run(async (ctx) => {
  // your abortable work; hand it ctx.signal:
  // await streamText({ signal: ctx.signal, ... })
})

export const chatServer = createServer({
  contract: chat,
  consumers: [generate],
})

abortOn names the events that interrupt the run. When a cancelled event lands, or had already landed before the run started, ctx.signal fires. Consumers without abortOn hold no subscription.

When one session runs several generations over its lifetime, match the instance with a predicate:

// server/chat.ts, the generate consumer, targeted by instance:
import { consumer } from 'experimental-a2/server'
import { chat } from '@/contracts'

const generate = consumer({
  contract: chat,
  name: 'generate',
  types: ['generate'],
  abortOn: {
    // only the cancel that names this run fires the signal
    cancelled: (event, trigger) => event.payload.of === trigger.id,
  },
}).run(async (ctx) => {
  // ...
})

The pushing side already knows the id it's cancelling: it's in the hook's events feed.

When a run loses its claim

Every consumer's signal, with or without abortOn, also fires when the run loses its claim: CLAIM_EXPIRED when the lease lapses without renewal (decided locally, so it works while the store is unreachable), or SUPERSEDED_ATTEMPT when a recovery run has claimed the event. Either way, a successor is taking over: stop external work. A superseded run can no longer write to the log or complete; passing ctx.signal to your external calls is what stops it racing its successor outside A2.

Pass ctx.signal to your I/O and let the abort propagate. That handles every case:

  • A propagated lease abort surrenders the attempt: no completion, and normal recovery. Like a crash, it costs the event one attempt of its failure budget.
  • A user cancellation (abortOn) is the one to catch and return normally from. The stop is the outcome, so the completion counts.
  • Return normally after a lease abort only when the work is done. A lapsed run that finishes before any successor claims the event still completes.
  • Any other throw is a real failure and spends the retry budget.

Clean up after the run

A consumer of cancelled can run while the interrupted run is still unwinding: the signal is the preemption channel, the event is the durable record. When cleanup must wait, take both event types in one consumer with a string lane. The cancelled event still fires the signal on append, but runs only after earlier work in the lane:

server/chat-with-cleanup.ts
import { consumer, createServer } from 'experimental-a2/server'
import { chat } from '@/contracts'

const generation = consumer({
  contract: chat,
  name: 'generation',
  types: ['generate', 'cancelled'],
  lane: 'generation',
  abortOn: ['cancelled'],
}).run(async (ctx) => {
  if (ctx.event.type === 'generate') {
    // your abortable work; hand it ctx.signal:
    // await streamText({ signal: ctx.signal, ... })
    return
  }
  // your cleanup, after generate has returned
})

export const chatServer = createServer({
  contract: chat,
  consumers: [generation],
})

The view cuts

reducer.ts
case 'chunk':
  return state.cancelled
    ? state
    : { ...state, chunks: [...state.chunks, { index: event.index, delta: event.payload.delta }] }
case 'cancelled':
  return {
    ...state,
    cancelled: true,
    chunks: state.chunks.filter((c) => c.index <= event.payload.lastSeenIndex),
  }

Chunks in flight during the cancel round trip still land after cancelled. They're true history; keeping them out of the view is the reducer's job.

lastSeenIndex is the client's stream frontier, which it already tracks. The cut is exact and clock-free: chunks the user never saw are removed, so the optimistic view and the authoritative refold agree. Because the client pushes cancelled optimistically, its own fold cuts instantly, and stragglers hit state.cancelled and vanish.