Skip to content

fix(ai): Call onFinish when message ui stream is cancelled in toUIMessageStream - #7902

Merged
gr2m merged 37 commits into
vercel:mainfrom
remorses:fix-onfinish-abort
Aug 25, 2025
Merged

gr2m merged 37 commits into
vercel:mainfrom
remorses:fix-onfinish-abort

Conversation

@remorses

@remorses remorses commented Aug 8, 2025 •

Copy link
Copy Markdown
Contributor

Fix #7469
Fix #7900

Call onFinish when abort controller aborts, via the TransformStream cancel method: https://developer.mozilla.org/en-US/docs/Web/API/TransformStream#browser_compatibility

That method is currently only available in Node and Deno

@remorses remorses changed the title Fix onFinish when request is aborted in toUIMessageStream fix(ai): Call onFinish when request is aborted in toUIMessageStream Aug 8, 2025
@remorses
remorses marked this pull request as ready for review August 8, 2025 16:19
@remorses
remorses marked this pull request as draft August 8, 2025 16:24
@grant0417

Copy link
Copy Markdown
Contributor

Anything blocking here? Also hitting this issue and would love if this was merged.

@remorses

Copy link
Copy Markdown
Contributor Author

I am not sure this PR solves the problem and it's a bit difficult to test.

The problem here is that ai sdk currently uses a type: "abort" chunk to detect aborts but it looks like this chunk cannot be sent in case of a client abort because the stream can no longer be written to after the abort happens.

Fixing this may require a big change, updating how aborts are detected in a lot of places

@remorses
remorses marked this pull request as ready for review August 12, 2025 10:52
Copilot AI review requested due to automatic review settings August 12, 2025 10:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Fixes a bug where onFinish callback wasn't being called when a UI message stream is cancelled or aborted, ensuring partial messages are properly persisted during browser navigation or request cancellation.

  • Added cancel method to transform stream to handle aborted requests
  • Extracted onFinish logic into reusable callOnFinish function with duplicate call prevention
  • Added comprehensive test coverage for stream cancellation scenarios

Reviewed Changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
handle-ui-message-stream-finish.ts Implements cancel handler and refactors onFinish logic to prevent duplicate calls
handle-ui-message-stream-finish.test.ts Adds test cases for reader cancellation and fetch abort scenarios
fix-onfinish-abort.md Documents the bug fix in changeset for release notes

@remorses

remorses commented Aug 12, 2025 •

Copy link
Copy Markdown
Contributor Author

I found a little documented field on TransformStream that should fix the problem @lgrammel

The tests I added fail without the cancel fix

@lgrammel

Copy link
Copy Markdown
Collaborator

I want to understand why this is a problem to begin with. I changed the abort behavior in streamText to not abort a stream any more; instead abort gets turned into regular stream behavior with an abort chunk to prevent special cases like this one.

Can you explain or even better share reproduction code for how such a situation can even occur?

@remorses

remorses commented Aug 13, 2025 •

Copy link
Copy Markdown
Contributor Author

The problem was not strictly related to the abort controller but interrupting the async generator.

While writing this comment I also found another issue which was related to consuming the stream with await for and doing an early break.

I updated and added a new test file with the reproduction which do not pass without the fix and use the full streamText function to make sure they reproduce the real problem.

Basically the smallest repro is this code:

const result = streamText({
  model,
  prompt: 'Generate text',
});

const uiStream = result.toUIMessageStream({
  onFinish: onFinishCallback,
  generateMessageId: () => 'msg-async-iter',
});

// Consume only first few chunks using async iteration, then break
let chunkCount = 0;

for await (const chunk of uiStream) {
  chunkCount++;

  if (chunkCount >= 5) {
    break; // This was not triggering the stream flush(). Instead it just stops consuming the stream and onfinish is never called
  }
}
// onFinish is never called

This code will cause the generator to stop and the stream to stop being consumed, the problem was in createAsyncIterableStream which does not pass a return method in the async iterator. return is necessary to cleanup resources when a await for loop breaks

I updated the code to cancel the stream when the async generator breaks. With the added cancel method in the TransformStream onFinish will be called.

Adding cancel in TransofrmStream now also handles the case when the user cancels the UIMessageStreamResponse, which is another problem that some users could face, some frameworks probably cancel responses when the request abort controller aborts to prevent memory leaks. Smallest repro is this code:

const res = result.toUIMessageStreamResponse()
res.body?.cancel()

lgrammel added a commit that referenced this pull request Aug 25, 2025
## Background

Async iterators can be exited early, e.g. by breaking out of the for
loop. When that happened, the async iterator stream was not correctly
closed.

## Summary

* implement `return` and `throw` async iterable methods to support
loop-breaking behavior
* add jsdoc

## Future Work

* Investigate/review other parts of #7902

## Related Issues

Build on #7902

---------

Co-authored-by: remorses <[email protected]>
@remorses

Copy link
Copy Markdown
Contributor Author

I fixed the conflicts with upstream, ready to review

message: `incomplete tool input is not supported: ${part.toolCallId}`,
});
} else {
if (part.state !== 'input-streaming') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are the changes in this file and chat.ts above related to the issue? can these changes be separated?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without this change you cannot resume an aborted message if you abort during a tool call. because when the client sends the messages back to the ai route it can contain unfinished tool calls

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder how to best handle/document this. right now from what i understand input-streaming would be dropped automatically, correct?

@lgrammel

Copy link
Copy Markdown
Collaborator

streamText has an onFinish and an onAbort callback. The onFinish callback receives usage information that is not available / incorrect in the case of abort. Therefore only onAbort should be called.

Is there a bug where onAbort is not called? onFinish is not called by design atm.

(this is slightly different for the ui message stream onFinish so there may be more here, just reviewed the first test part)

@remorses

Copy link
Copy Markdown
Contributor Author

Are you referring to the test I added in the last commit right?

I added this test to make sure I was understanding when StreamText onFinish runs and this was the wanted behaviour. Notice this test is not about aborts but errors. I tried running this same test in main and it still passes so it is unrelated to my changes.

Do you want me to remove that test?

@lgrammel

Copy link
Copy Markdown
Collaborator

@remorses maybe i got confused by that test. if it is valuable, would it be possible to move it to a separate pr and integrate it into stream-text.test.ts?

@remorses

remorses commented Aug 25, 2025 •

Copy link
Copy Markdown
Contributor Author

Sure. I can confirm onFinish is still not called during aborts, I can add test for it

@lgrammel

Copy link
Copy Markdown
Collaborator

@remorses ideally move as much as possible into separate prs. overload is real so i appreciate minimal prs focussed on 1 problem.

import { MockLanguageModelV2 } from '../test/mock-language-model-v2';
import { streamText } from './stream-text';

describe('streamText onFinish with abort', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you integrate this test into stream-text test and in particular into the ui message stream section (since it is specifically for toUIMessageStream)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moved to existing file

convertReadableStreamToArray,
} from '@ai-sdk/provider-utils/test';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove whole import line

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@lgrammel
lgrammel requested a review from gr2m August 25, 2025 15:48
@gr2m gr2m self-assigned this Aug 25, 2025

@gr2m gr2m left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great PR, thank you Tommy 💐

@gr2m

gr2m commented Aug 25, 2025

Copy link
Copy Markdown
Collaborator

@remorses could you look into the merge conflict? Sorry for that, we can look into it ourselves but it might take a bit longer

@remorses

Copy link
Copy Markdown
Contributor Author

Fixed the merge conflict

@gr2m
gr2m merged commit f8f3682 into vercel:main Aug 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

5 participants