@ai-sdk/workflow 0.0.0-6b196531-20260710185421

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,534 @@
1
+ import {
2
+ parseJsonEventStream,
3
+ uiMessageChunkSchema,
4
+ type ChatRequestOptions,
5
+ type ChatTransport,
6
+ type PrepareReconnectToStreamRequest,
7
+ type PrepareSendMessagesRequest,
8
+ type UIMessage,
9
+ type UIMessageChunk,
10
+ } from 'ai';
11
+ import {
12
+ convertAsyncIteratorToReadableStream,
13
+ getErrorMessage,
14
+ } from '@ai-sdk/provider-utils';
15
+ import { createAsyncIterableStream } from 'ai/internal';
16
+ import { normalizeUIMessageStreamParts } from './normalize-ui-message-stream.js';
17
+
18
+ /**
19
+ * Tracks `*-start` chunks the client has accepted so we can drop deltas/ends
20
+ * that refer to a part whose start was emitted before the resume cursor.
21
+ *
22
+ * AI SDK's UI stream processor throws on `text-delta`/`reasoning-delta`/
23
+ * `tool-input-delta` (and the matching `*-end`) when the start chunk for that
24
+ * id was never observed, and on tool output/approval chunks when no tool part
25
+ * exists for the call id. A negative `startIndex` on a flat chunk stream can
26
+ * easily land mid-part, so without this guard the client crashes on resume.
27
+ *
28
+ * A tool part is established by `tool-input-start` OR by a self-contained
29
+ * `tool-input-available`/`tool-input-error` chunk (the AI SDK creates the
30
+ * part from those directly), so all three mark the call id as seen.
31
+ *
32
+ * This is a best-effort safety net — it preserves only the parts that the
33
+ * resumed window includes a `*-start` for. Server-side rewinding to a step
34
+ * boundary is the proper fix when you want the full message preserved.
35
+ */
36
+ type OrphanFilter = {
37
+ shouldDrop: (chunk: UIMessageChunk) => boolean;
38
+ };
39
+
40
+ function createOrphanFilter(): OrphanFilter {
41
+ const seenStartedIds = new Set<string>();
42
+ const seenStartedToolCallIds = new Set<string>();
43
+ let warnedOnce = false;
44
+
45
+ function warnOnce(orphanKind: string, orphanRef: string) {
46
+ if (warnedOnce) return;
47
+ warnedOnce = true;
48
+ console.warn(
49
+ '[WorkflowChatTransport] Dropping orphan UI chunk ' +
50
+ `(${orphanKind} for id "${orphanRef}") on resume — ` +
51
+ 'the resume position landed mid-part. The dropped chunk(s) ' +
52
+ "reference a part whose start chunk wasn't in the resumed " +
53
+ 'window. To preserve the full message, configure your ' +
54
+ 'stream endpoint to rewind to a step boundary before ' +
55
+ 'returning the readable. See: ' +
56
+ 'https://workflow.dev/docs/ai/resumable-streams#mid-part-resumes',
57
+ );
58
+ }
59
+
60
+ function shouldDrop(chunk: UIMessageChunk): boolean {
61
+ switch (chunk.type) {
62
+ case 'text-start':
63
+ case 'reasoning-start':
64
+ seenStartedIds.add(chunk.id);
65
+ return false;
66
+ case 'tool-input-start':
67
+ // `tool-input-available` / `tool-input-error` are self-contained: the
68
+ // AI SDK creates the tool part from them directly (non-streamed tool
69
+ // calls are emitted as a bare `tool-input-available`), so they must
70
+ // never be dropped. They also carry the full input, so they recover a
71
+ // tool call whose `tool-input-start` fell outside the resumed window.
72
+ case 'tool-input-available':
73
+ case 'tool-input-error':
74
+ seenStartedToolCallIds.add(chunk.toolCallId);
75
+ return false;
76
+ case 'text-delta':
77
+ case 'text-end':
78
+ case 'reasoning-delta':
79
+ case 'reasoning-end':
80
+ if (seenStartedIds.has(chunk.id)) return false;
81
+ warnOnce(chunk.type, chunk.id);
82
+ return true;
83
+ case 'tool-input-delta':
84
+ case 'tool-approval-request':
85
+ case 'tool-output-available':
86
+ case 'tool-output-error':
87
+ case 'tool-output-denied':
88
+ if (seenStartedToolCallIds.has(chunk.toolCallId)) return false;
89
+ warnOnce(chunk.type, chunk.toolCallId);
90
+ return true;
91
+ default:
92
+ return false;
93
+ }
94
+ }
95
+
96
+ return { shouldDrop };
97
+ }
98
+
99
+ export interface SendMessagesOptions<UI_MESSAGE extends UIMessage> {
100
+ trigger: 'submit-message' | 'regenerate-message';
101
+ chatId: string;
102
+ messageId?: string;
103
+ messages: UI_MESSAGE[];
104
+ abortSignal?: AbortSignal;
105
+ }
106
+
107
+ export interface ReconnectToStreamOptions {
108
+ chatId: string;
109
+ abortSignal?: AbortSignal;
110
+ /**
111
+ * Override the `startIndex` for this reconnection.
112
+ * Negative values read from the end of the stream.
113
+ * When omitted, falls back to the constructor's `initialStartIndex`.
114
+ */
115
+ startIndex?: number;
116
+ }
117
+
118
+ type OnChatSendMessage<UI_MESSAGE extends UIMessage> = (
119
+ response: Response,
120
+ options: SendMessagesOptions<UI_MESSAGE>,
121
+ ) => void | Promise<void>;
122
+
123
+ type OnChatEnd = ({
124
+ chatId,
125
+ chunkIndex,
126
+ }: {
127
+ chatId: string;
128
+ chunkIndex: number;
129
+ }) => void | Promise<void>;
130
+
131
+ /**
132
+ * Configuration options for the WorkflowChatTransport.
133
+ *
134
+ * @template UI_MESSAGE - The type of UI messages being sent and received,
135
+ * must extend the UIMessage interface from the AI SDK.
136
+ */
137
+ export interface WorkflowChatTransportOptions<UI_MESSAGE extends UIMessage> {
138
+ /**
139
+ * API endpoint for chat requests
140
+ * Defaults to /api/chat if not provided
141
+ */
142
+ api?: string;
143
+
144
+ /**
145
+ * Custom fetch implementation to use for HTTP requests.
146
+ * Defaults to the global fetch function if not provided.
147
+ */
148
+ fetch?: typeof fetch;
149
+
150
+ /**
151
+ * Callback invoked after successfully sending messages to the chat endpoint.
152
+ * Useful for tracking chat history and inspecting response headers.
153
+ *
154
+ * @param response - The HTTP response object from the chat endpoint
155
+ * @param options - The original options passed to sendMessages
156
+ */
157
+ onChatSendMessage?: OnChatSendMessage<UI_MESSAGE>;
158
+
159
+ /**
160
+ * Callback invoked when a chat stream ends (receives a "finish" chunk).
161
+ * Useful for cleanup operations or state updates.
162
+ *
163
+ * @param chatId - The ID of the chat that ended
164
+ * @param chunkIndex - The total number of chunks received
165
+ */
166
+ onChatEnd?: OnChatEnd;
167
+
168
+ /**
169
+ * Maximum number of consecutive errors allowed during reconnection attempts.
170
+ * Defaults to 3 if not provided.
171
+ */
172
+ maxConsecutiveErrors?: number;
173
+
174
+ /**
175
+ * Default `startIndex` to use when reconnecting to a stream without a known
176
+ * chunk position (i.e. the initial reconnection, not a retry).
177
+ * Negative values read from the end of the stream (e.g. `-10` fetches the
178
+ * last 10 chunks), which is useful for resuming a chat UI after a page
179
+ * refresh without replaying the full conversation.
180
+ *
181
+ * Can be overridden per-call via `ReconnectToStreamOptions.startIndex`.
182
+ *
183
+ * Defaults to `0` (replay from the beginning).
184
+ */
185
+ initialStartIndex?: number;
186
+
187
+ /**
188
+ * Function to prepare the request for sending messages.
189
+ * Allows customizing the API endpoint, headers, credentials, and body.
190
+ */
191
+ prepareSendMessagesRequest?: PrepareSendMessagesRequest<UI_MESSAGE>;
192
+
193
+ /**
194
+ * Function to prepare the request for reconnecting to a stream.
195
+ * Allows customizing the API endpoint, headers, and credentials.
196
+ */
197
+ prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest;
198
+ }
199
+
200
+ /**
201
+ * A transport implementation for managing chat workflows with support for
202
+ * streaming responses and automatic reconnection to interrupted streams.
203
+ *
204
+ * This class implements the ChatTransport interface from the AI SDK and provides
205
+ * reliable message streaming with automatic recovery from network interruptions
206
+ * or function timeouts.
207
+ *
208
+ * @template UI_MESSAGE - The type of UI messages being sent and received,
209
+ * must extend the UIMessage interface from the AI SDK.
210
+ *
211
+ * @implements {ChatTransport<UI_MESSAGE>}
212
+ */
213
+ export class WorkflowChatTransport<
214
+ UI_MESSAGE extends UIMessage,
215
+ > implements ChatTransport<UI_MESSAGE> {
216
+ private readonly api: string;
217
+ private readonly fetch: typeof fetch;
218
+ private readonly onChatSendMessage?: OnChatSendMessage<UI_MESSAGE>;
219
+ private readonly onChatEnd?: OnChatEnd;
220
+ private readonly maxConsecutiveErrors: number;
221
+ private readonly initialStartIndex: number;
222
+ private readonly prepareSendMessagesRequest?: PrepareSendMessagesRequest<UI_MESSAGE>;
223
+ private readonly prepareReconnectToStreamRequest?: PrepareReconnectToStreamRequest;
224
+
225
+ /**
226
+ * Creates a new WorkflowChatTransport instance.
227
+ *
228
+ * @param options - Configuration options for the transport
229
+ * @param options.api - API endpoint for chat requests (defaults to '/api/chat')
230
+ * @param options.fetch - Custom fetch implementation (defaults to global fetch)
231
+ * @param options.onChatSendMessage - Callback after sending messages
232
+ * @param options.onChatEnd - Callback when chat stream ends
233
+ * @param options.maxConsecutiveErrors - Maximum consecutive errors for reconnection
234
+ * @param options.prepareSendMessagesRequest - Function to prepare send messages request
235
+ * @param options.prepareReconnectToStreamRequest - Function to prepare reconnect request
236
+ */
237
+ constructor(options: WorkflowChatTransportOptions<UI_MESSAGE> = {}) {
238
+ this.api = options.api ?? '/api/chat';
239
+ this.fetch = options.fetch ?? fetch.bind(globalThis);
240
+ this.onChatSendMessage = options.onChatSendMessage;
241
+ this.onChatEnd = options.onChatEnd;
242
+ this.maxConsecutiveErrors = options.maxConsecutiveErrors ?? 3;
243
+ this.initialStartIndex = options.initialStartIndex ?? 0;
244
+ this.prepareSendMessagesRequest = options.prepareSendMessagesRequest;
245
+ this.prepareReconnectToStreamRequest =
246
+ options.prepareReconnectToStreamRequest;
247
+ }
248
+
249
+ /**
250
+ * Sends messages to the chat endpoint and returns a stream of response chunks.
251
+ *
252
+ * This method handles the entire chat lifecycle including:
253
+ * - Sending messages to the /api/chat endpoint
254
+ * - Streaming response chunks
255
+ * - Automatic reconnection if the stream is interrupted
256
+ *
257
+ * @param options - Options for sending messages
258
+ * @param options.trigger - The type of message submission ('submit-message' or 'regenerate-message')
259
+ * @param options.chatId - Unique identifier for this chat session
260
+ * @param options.messageId - Optional message ID for tracking specific messages
261
+ * @param options.messages - Array of UI messages to send
262
+ * @param options.abortSignal - Optional AbortSignal to cancel the request
263
+ *
264
+ * @returns A ReadableStream of UIMessageChunk objects containing the response
265
+ * @throws Error if the fetch request fails or returns a non-OK status
266
+ */
267
+ async sendMessages(
268
+ options: SendMessagesOptions<UI_MESSAGE> & ChatRequestOptions,
269
+ ): Promise<ReadableStream<UIMessageChunk>> {
270
+ return convertAsyncIteratorToReadableStream(
271
+ normalizeUIMessageStreamParts(this.sendMessagesIterator(options)),
272
+ );
273
+ }
274
+
275
+ private async *sendMessagesIterator(
276
+ options: SendMessagesOptions<UI_MESSAGE> & ChatRequestOptions,
277
+ ): AsyncGenerator<UIMessageChunk> {
278
+ const { chatId, messages, abortSignal, trigger, messageId } = options;
279
+
280
+ // We keep track of if the "finish" chunk is received to determine
281
+ // if we need to reconnect, and keep track of the chunk index to resume from.
282
+ let gotFinish = false;
283
+ let chunkIndex = 0;
284
+
285
+ // Prepare the request using the configurator if provided
286
+ const requestConfig = this.prepareSendMessagesRequest
287
+ ? await this.prepareSendMessagesRequest({
288
+ id: chatId,
289
+ messages,
290
+ requestMetadata: options.metadata,
291
+ body: options.body,
292
+ credentials: undefined,
293
+ headers: options.headers,
294
+ api: this.api,
295
+ trigger,
296
+ messageId,
297
+ })
298
+ : undefined;
299
+
300
+ const url = requestConfig?.api ?? this.api;
301
+ const response = await this.fetch(url, {
302
+ method: 'POST',
303
+ body: JSON.stringify(
304
+ requestConfig?.body ?? { messages, ...options.body },
305
+ ),
306
+ headers: requestConfig?.headers,
307
+ credentials: requestConfig?.credentials,
308
+ signal: abortSignal,
309
+ });
310
+
311
+ if (!response.ok || !response.body) {
312
+ throw new Error(
313
+ `Failed to fetch chat: ${response.status} ${await response.text()}`,
314
+ );
315
+ }
316
+
317
+ const workflowRunId = response.headers.get('x-workflow-run-id');
318
+ if (!workflowRunId) {
319
+ throw new Error(
320
+ 'Workflow run ID not found in "x-workflow-run-id" response header',
321
+ );
322
+ }
323
+
324
+ // Notify the caller that the chat POST request was sent.
325
+ // This is useful for tracking the chat history on the client
326
+ // side and allows for inspecting response headers.
327
+ await this.onChatSendMessage?.(response, options);
328
+
329
+ // Flush the initial stream until the end or an error occurs
330
+ try {
331
+ const chunkStream = parseJsonEventStream({
332
+ stream: response.body,
333
+ schema: uiMessageChunkSchema,
334
+ });
335
+ for await (const chunk of createAsyncIterableStream(chunkStream)) {
336
+ if (!chunk.success) {
337
+ throw chunk.error;
338
+ }
339
+
340
+ chunkIndex++;
341
+
342
+ yield chunk.value;
343
+
344
+ if (chunk.value.type === 'finish') {
345
+ gotFinish = true;
346
+ }
347
+ }
348
+ } catch (error) {
349
+ console.error('Error in chat POST stream', error);
350
+ }
351
+
352
+ if (gotFinish) {
353
+ await this.onFinish(gotFinish, { chatId, chunkIndex });
354
+ } else {
355
+ // If the initial POST request did not include the "finish" chunk,
356
+ // we need to reconnect to the stream. This could indicate that a
357
+ // network error occurred or the Vercel Function timed out.
358
+ yield* this.reconnectToStreamIterator(options, workflowRunId, chunkIndex);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Reconnects to an existing chat stream that was previously interrupted.
364
+ *
365
+ * This method is useful for resuming a chat session after network issues,
366
+ * page refreshes, or Vercel Function timeouts.
367
+ *
368
+ * @param options - Options for reconnecting to the stream
369
+ * @param options.chatId - The chat ID to reconnect to
370
+ *
371
+ * @returns A ReadableStream of UIMessageChunk objects
372
+ * @throws Error if the reconnection request fails or returns a non-OK status
373
+ */
374
+ async reconnectToStream(
375
+ options: ReconnectToStreamOptions & ChatRequestOptions,
376
+ ): Promise<ReadableStream<UIMessageChunk> | null> {
377
+ const reconnectIterator = normalizeUIMessageStreamParts(
378
+ this.reconnectToStreamIterator(options),
379
+ );
380
+ return convertAsyncIteratorToReadableStream(reconnectIterator);
381
+ }
382
+
383
+ private async *reconnectToStreamIterator(
384
+ options: ReconnectToStreamOptions & ChatRequestOptions,
385
+ workflowRunId?: string,
386
+ initialChunkIndex = 0,
387
+ ): AsyncGenerator<UIMessageChunk> {
388
+ let chunkIndex = initialChunkIndex;
389
+
390
+ // When called from the public reconnectToStream (initialChunkIndex === 0),
391
+ // honour the caller's startIndex (or the constructor default) for the
392
+ // first request. This enables negative values so the client can read only
393
+ // the tail of the stream (e.g. the last 10 chunks) instead of replaying
394
+ // everything. After the first request, fall back to the running chunkIndex
395
+ // so that retries resume from the correct position.
396
+ const explicitStartIndex = options.startIndex ?? this.initialStartIndex;
397
+ let useExplicitStartIndex =
398
+ initialChunkIndex === 0 && explicitStartIndex !== 0;
399
+
400
+ const defaultApi = `${this.api}/${encodeURIComponent(workflowRunId ?? options.chatId)}/stream`;
401
+
402
+ // Prepare the request using the configurator if provided
403
+ const requestConfig = this.prepareReconnectToStreamRequest
404
+ ? await this.prepareReconnectToStreamRequest({
405
+ id: options.chatId,
406
+ requestMetadata: options.metadata,
407
+ body: undefined,
408
+ credentials: undefined,
409
+ headers: undefined,
410
+ api: defaultApi,
411
+ })
412
+ : undefined;
413
+
414
+ const baseUrl = requestConfig?.api ?? defaultApi;
415
+
416
+ let gotFinish = false;
417
+ let consecutiveErrors = 0;
418
+ // When a negative startIndex is used but the tail-index header is absent,
419
+ // retries fall back to startIndex 0 (replay everything) instead of using
420
+ // the incremental chunkIndex which would be wrong.
421
+ let replayFromStart = false;
422
+
423
+ // When resuming with a negative startIndex, the resolved chunk can land in
424
+ // the middle of a `*-start` / `*-delta` / `*-end` sequence, which crashes
425
+ // the AI SDK UI stream processor. The orphan filter drops chunks whose
426
+ // start chunk was emitted before the resume window. Only activated for
427
+ // negative resumes — non-negative startIndex is the caller's explicit
428
+ // choice and we trust them. See: https://github.com/vercel/workflow/issues/1835
429
+ const orphanFilter =
430
+ useExplicitStartIndex && explicitStartIndex < 0
431
+ ? createOrphanFilter()
432
+ : null;
433
+
434
+ while (!gotFinish) {
435
+ const startIndex = useExplicitStartIndex
436
+ ? explicitStartIndex
437
+ : replayFromStart
438
+ ? 0
439
+ : chunkIndex;
440
+
441
+ const url = `${baseUrl}?startIndex=${startIndex}`;
442
+ const response = await this.fetch(url, {
443
+ headers: requestConfig?.headers,
444
+ credentials: requestConfig?.credentials,
445
+ signal: options.abortSignal,
446
+ });
447
+
448
+ if (!response.ok || !response.body) {
449
+ throw new Error(
450
+ `Failed to fetch chat: ${response.status} ${await response.text()}`,
451
+ );
452
+ }
453
+
454
+ // When using a negative startIndex, the server resolves it to an
455
+ // absolute position. The reconnection endpoint should return the tail
456
+ // index so we can compute the resolved position for subsequent retries.
457
+ if (useExplicitStartIndex && explicitStartIndex > 0) {
458
+ // Positive startIndex: the first request starts at this absolute
459
+ // position, so set chunkIndex to match so subsequent retries
460
+ // resume from (explicitStartIndex + chunks received).
461
+ chunkIndex = explicitStartIndex;
462
+ } else if (useExplicitStartIndex && explicitStartIndex < 0) {
463
+ const tailIndexHeader = response.headers.get(
464
+ 'x-workflow-stream-tail-index',
465
+ );
466
+ const tailIndex =
467
+ tailIndexHeader !== null ? parseInt(tailIndexHeader, 10) : NaN;
468
+
469
+ if (!Number.isNaN(tailIndex)) {
470
+ // Resolve: e.g. tailIndex=499, startIndex=-20 → 500 + (-20) = 480
471
+ chunkIndex = Math.max(0, tailIndex + 1 + explicitStartIndex);
472
+ } else {
473
+ // Header missing or unparseable — fall back to replaying from the
474
+ // beginning so retries don't resume from a wrong position.
475
+ console.warn(
476
+ '[WorkflowChatTransport] Negative initialStartIndex is configured ' +
477
+ `(${explicitStartIndex}) but the reconnection endpoint did not ` +
478
+ 'return a valid "x-workflow-stream-tail-index" header. Retries ' +
479
+ 'will replay the stream from the beginning. See: ' +
480
+ 'https://workflow.dev/docs/ai/resumable-streams#resuming-from-the-end-of-the-stream',
481
+ );
482
+ replayFromStart = true;
483
+ }
484
+ }
485
+ useExplicitStartIndex = false;
486
+
487
+ try {
488
+ const chunkStream = parseJsonEventStream({
489
+ stream: response.body,
490
+ schema: uiMessageChunkSchema,
491
+ });
492
+ for await (const chunk of createAsyncIterableStream(chunkStream)) {
493
+ if (!chunk.success) {
494
+ throw chunk.error;
495
+ }
496
+
497
+ chunkIndex++;
498
+
499
+ if (orphanFilter?.shouldDrop(chunk.value)) continue;
500
+
501
+ yield chunk.value;
502
+
503
+ if (chunk.value.type === 'finish') {
504
+ gotFinish = true;
505
+ }
506
+ }
507
+ // Reset consecutive error count only after successful stream parsing
508
+ consecutiveErrors = 0;
509
+ } catch (error) {
510
+ console.error('Error in chat GET reconnectToStream', error);
511
+ consecutiveErrors++;
512
+
513
+ if (consecutiveErrors >= this.maxConsecutiveErrors) {
514
+ throw new Error(
515
+ `Failed to reconnect after ${this.maxConsecutiveErrors} consecutive errors. Last error: ${getErrorMessage(error)}`,
516
+ );
517
+ }
518
+ }
519
+ }
520
+
521
+ await this.onFinish(gotFinish, { chatId: options.chatId, chunkIndex });
522
+ }
523
+
524
+ private async onFinish(
525
+ gotFinish: boolean,
526
+ { chatId, chunkIndex }: { chatId: string; chunkIndex: number },
527
+ ) {
528
+ if (gotFinish) {
529
+ await this.onChatEnd?.({ chatId, chunkIndex });
530
+ } else {
531
+ throw new Error('No finish chunk received');
532
+ }
533
+ }
534
+ }