Skip to content

fix(workflow): drop orphan UI chunks after non-zero startIndex reconnect#15543

Open
VaguelySerious wants to merge 2 commits into
mainfrom
peter/issue-1835-client-orphan-filter
Open

fix(workflow): drop orphan UI chunks after non-zero startIndex reconnect#15543
VaguelySerious wants to merge 2 commits into
mainfrom
peter/issue-1835-client-orphan-filter

Conversation

@VaguelySerious
Copy link
Copy Markdown
Member

Summary

When WorkflowChatTransport resumes with a non-zero initialStartIndex (positive or negative), the resolved chunk index can land in the middle of an open text-* / reasoning-* / tool-input-* part. AI SDK's client enforces the *-start → *-delta → *-end grammar and throws on the orphan delta — breaking the chat.

This PR adds a one-pass filter on the resumed stream that drops chunks referencing a part whose start chunk wasn't in the resumed window, and emits a one-time warning pointing at docs on server-side rewinding. The filter only activates when useExplicitStartIndex is true — startIndex: 0 replays from the beginning and is always safe.

Reported and reproduced in vercel/workflow#1835. This is the port of the corresponding fix in vercel/workflow#2082 to @ai-sdk/workflow.

Changes

  • packages/workflow/src/workflow-chat-transport.ts: add createOrphanFilter() and wire into the reconnect iterator.
  • packages/workflow/src/workflow-chat-transport.test.ts: 6 new tests covering orphan reasoning / text / tool-input chunks, the pass-through path, positive-startIndex activation, and the startIndex-0 skip path.
  • .changeset/orphan-ui-chunks-on-resume.md: patch bump for @ai-sdk/workflow.

Test plan

  • pnpm test:node passes (125 tests, 6 new)
  • pnpm test:edge passes (125 tests, 6 new)
  • pnpm type-check passes
  • pnpm check (lint/format) clean
  • CI green

When `WorkflowChatTransport` resumes with a non-zero `initialStartIndex`,
the resolved chunk can land in the middle of an open `text-*` / `reasoning-*`
/ `tool-input-*` part. AI SDK's client enforces the start→delta→end grammar
and throws on the orphan delta, breaking the chat.

Add a one-pass filter on the resumed stream that drops chunks referencing
a part whose start chunk wasn't in the resumed window, and warn once with
a pointer to docs on server-side rewinding. Filter only activates for
non-zero startIndex — startIndex 0 replays from the beginning and is always
safe.

Refs: vercel/workflow#1835
Ports vercel/workflow#2082 to @ai-sdk/workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/workflow/src/workflow-chat-transport.ts
…ol-output-denied` and `tool-approval-request` chunk types, causing `UIMessageStreamError` crashes when resuming a stream mid-part.

This commit fixes the issue reported at packages/workflow/src/workflow-chat-transport.ts:76

**Bug explanation:**

The `createOrphanFilter()` function in `workflow-chat-transport.ts` is designed to prevent crashes when a stream resume lands mid-part — it drops chunks that reference a `toolCallId` whose `tool-input-start` was emitted before the resume window.

The filter correctly handles these tool chunk types against `seenStartedToolCallIds`:
- `tool-input-delta`
- `tool-input-available`
- `tool-input-error`
- `tool-output-available`
- `tool-output-error`

But it's missing two chunk types that also use `toolCallId`:
- `tool-approval-request` — calls `getToolInvocation(chunk.toolCallId)` at line 705 of `process-ui-message-stream.ts`
- `tool-output-denied` — calls `getToolInvocation(chunk.toolCallId)` at line 742 of `process-ui-message-stream.ts`

Both types have a `toolCallId` field (confirmed in `ui-message-chunks.ts` schema and type definitions). When `getToolInvocation()` is called with a `toolCallId` that has no matching tool invocation (because the `tool-input-start` was before the resume window), it throws a `UIMessageStreamError` with message `No tool invocation found for tool call ID`. This is exactly the crash scenario the orphan filter was designed to prevent.

This would manifest when:
1. A tool call involves an approval flow (using `tool-approval-request`) or denial (`tool-output-denied`)
2. The stream is interrupted and resumed
3. The resume position lands after the `tool-input-start` but before/at the `tool-approval-request` or `tool-output-denied` chunk

**Fix explanation:**

Added `case 'tool-approval-request':` and `case 'tool-output-denied':` to the switch statement's tool-related branch that checks `seenStartedToolCallIds`. These fall through to the same logic as the other tool chunk types — if the `toolCallId` was seen in a `tool-input-start`, the chunk is kept; otherwise it's dropped with a warning. This is consistent with how all other `toolCallId`-based chunks are handled.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant