Is onFinish called when stream is aborted?

HEAD
c123363Version Packages (#12611)
This post might have stale content, as HEAD is 241 commits ahead.

When using streamText and toUIMessageStream with onFinish, is onFinish called when the stream is aborted (e.g. AbortController.abort()) before the model finishes generating? Or is onFinish only called when the stream completes normally?

Avatar of anthropic/claude-opus-4.6
Claude Opus 4.6
commented

Now I have a thorough understanding. Let me summarize the findings.


##Answer

The behavior differs between streamText's onFinish and toUIMessageStream's onFinish, and it also depends on how the stream is interrupted. Here's the breakdown from the source code:

###toUIMessageStream's onFinish: ✅ Called on abort

In handle-ui-message-stream-finish.ts, the onFinish callback is invoked from both flush() and cancel() on the final TransformStream (lines 144–160):

// @ts-expect-error cancel is still new and missing from types
async cancel() {
  await callOnFinish();
},

async flush() {
  await callOnFinish();
},

There's also a finishCalled guard (line 101) ensuring it's only called once. This means:

ScenarioonFinish called?isAborted value
AbortController.abort() — abort chunk flows through stream✅ Yes (via flush)true
reader.cancel() — e.g. browser closes connection✅ Yes (via cancel)false
Normal completion✅ Yes (via flush)false

When AbortController.abort() fires, the resilient stream in stream-text.ts (lines 1065–1076) enqueues an { type: 'abort' } chunk and then closes the stream normally with controller.close(). That abort chunk flows through handleUIMessageStreamFinish, which sets isAborted = true (line 72), and then flush triggers callOnFinish with isAborted: true.

This is confirmed by the test at line 3460:

it('should call onFinish when stream is aborted via AbortController', async () => {
  // ...
  expect(onFinishCallback).toHaveBeenCalledTimes(1);
  expect(callArgs.isAborted).toBe(true);
});

###streamText's own onFinish (the top-level parameter): ⚠️ Depends

The streamText-level onFinish lives in the event processor's flush handler (stream-text.ts lines 957–1050). It has a critical guard:

async flush(controller) {
  if (recordedSteps.length === 0) {
    // rejects promises with abort reason, returns early
    // onFinish is NOT called
    return;
  }
  // ...
  await onFinish?.({ ... }); // called only if steps were recorded
}
  • If the abort happens before any step completes (i.e., before a finish-step event): recordedSteps is empty → onFinish is NOT called, and the delayed promises (finishReason, usage, steps) are rejected with the abort reason.
  • If the abort happens after at least one step completed: onFinish IS called with the data from the completed steps.

###streamText's onAbort: The dedicated abort callback

For the streamText-level abort handling, there's a separate onAbort callback (line 1066):

function abort() {
  onAbort?.({ steps: recordedSteps });
  controller.enqueue({ type: 'abort', ... });
  controller.close();
}

This is always called when the abort signal fires, giving you access to whatever steps completed before the abort.

###Also: Breaking iteration / reader.cancel() does NOT call streamText's onFinish

The tests explicitly confirm (lines 3556-3639):

it('should NOT call onFinish when for-await loop breaks early', ...);
it('should NOT call onFinish when reader.cancel() is called', ...);

However, toUIMessageStream's onFinish IS still called on reader.cancel() (thanks to the cancel() handler), though with isAborted: false.

###Summary

CallbackAbortController.abort()reader.cancel() / breakNormal completion
toUIMessageStream({ onFinish })✅ Called, isAborted: true✅ Called, isAborted: false✅ Called, isAborted: false
streamText({ onFinish })❌ Not called (if no steps completed) / ✅ Called (if steps completed)❌ Not called✅ Called
streamText({ onAbort })✅ Always called❌ Not called❌ Not called

END OF POST

Is onFinish called when stream is aborted? — vercel/ai