Cancellation

There is no abort() API. Cancelling is appending, and the running handler 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 handler stops

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

export const chatServer = createServer({
  contract: chat,
  handlers: {
    generate: {
      abortOn: ['cancelled'],
      handler: async (ctx) => {
        // your abortable work; hand it ctx.signal:
        // await streamText({ signal: ctx.signal, ... })
      },
    },
  },
})

abortOn names the events that interrupt the handler. When a cancelled event lands, or had already landed before the handler started, ctx.signal fires. Handlers 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 entry, targeted by instance:
generate: {
  abortOn: {
    // only the cancel that names this run fires the signal
    cancelled: (event, trigger) => event.payload.of === trigger.id,
  },
  handler: 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 handler'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 handler

A cancelled handler can run while the interrupted one is still unwinding: the signal is the preemption channel, the event is the durable record. When cleanup must wait, put both event types in one lane. The event still fires the signal on append, but its own handler waits for earlier work in the lane:

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

export const chatServer = createServer({
  contract: chat,
  handlers: {
    generate: {
      lane: 'generation',
      abortOn: ['cancelled'],
      handler: async (ctx) => {
        // your abortable work; hand it ctx.signal:
        // await streamText({ signal: ctx.signal, ... })
      },
    },
    cancelled: {
      lane: 'generation',
      handler: async () => {
        // your cleanup, after generate has returned
      },
    },
  },
})

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.