@ai-sdk/workflow 1.0.15 → 1.0.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/workflow",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "WorkflowAgent for building AI agents with AI SDK",
5
5
  "license": "Apache-2.0",
6
6
  "main": "./dist/index.js",
@@ -29,13 +29,14 @@
29
29
  "ajv": "^8.20.0",
30
30
  "@ai-sdk/provider": "4.0.2",
31
31
  "@ai-sdk/provider-utils": "5.0.5",
32
- "ai": "7.0.15"
32
+ "ai": "7.0.17"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "22.19.19",
36
36
  "@workflow/vitest": "4.0.1",
37
37
  "tsup": "^8.5.1",
38
38
  "typescript": "5.8.3",
39
+ "vitest": "4.1.6",
39
40
  "workflow": "4.2.4",
40
41
  "zod": "3.25.76",
41
42
  "@vercel/ai-tsconfig": "0.0.0"
@@ -2,7 +2,6 @@ import type {
2
2
  LanguageModelV4CallOptions,
3
3
  LanguageModelV4Prompt,
4
4
  } from '@ai-sdk/provider';
5
- import type { Context } from '@ai-sdk/provider-utils';
6
5
  import {
7
6
  experimental_streamLanguageModelCall as streamModelCall,
8
7
  gateway,
@@ -11,7 +10,6 @@ import {
11
10
  type LanguageModel,
12
11
  type LanguageModelUsage,
13
12
  type ModelMessage,
14
- type StepResult,
15
13
  type StopCondition,
16
14
  type ToolCallRepairFunction,
17
15
  type ToolChoice,
@@ -57,13 +55,6 @@ export interface DoStreamStepOptions {
57
55
  includeRawChunks?: boolean;
58
56
  repairToolCall?: ToolCallRepairFunction<ToolSet>;
59
57
  responseFormat?: LanguageModelV4CallOptions['responseFormat'];
60
- runtimeContext?: Context;
61
- toolsContext?: Record<string, Context | undefined>;
62
- /**
63
- * The step number for the returned StepResult. Defaults to 0 for direct
64
- * callers; stream iterators pass their current index. See #15151.
65
- */
66
- stepNumber?: number;
67
58
  }
68
59
 
69
60
  /**
@@ -91,6 +82,23 @@ export interface StreamFinish {
91
82
  providerMetadata?: Record<string, unknown>;
92
83
  }
93
84
 
85
+ /**
86
+ * Minimal aggregates needed to reconstruct a `StepResult` outside the step
87
+ * boundary. By returning only these fields (instead of a fully-populated
88
+ * StepResult plus the raw `chunks[]` array), the durable event log doesn't
89
+ * carry StepResult's redundant copies — `content`, the duplicate
90
+ * `toolCalls`/`dynamicToolCalls` lists, `reasoningText`, the always-empty
91
+ * `*ToolResults` arrays, and the per-chunk `chunks[]` snapshot the iterator
92
+ * never reads. The caller reconstructs the full StepResult via
93
+ * `buildStepResult`.
94
+ */
95
+ export interface DoStreamStepRawResult {
96
+ text: string;
97
+ reasoning: Array<{ text: string }>;
98
+ responseMetadata?: { id?: string; timestamp?: Date; modelId?: string };
99
+ warnings?: unknown[];
100
+ }
101
+
94
102
  export async function doStreamStep(
95
103
  conversationPrompt: LanguageModelV4Prompt,
96
104
  modelInit: LanguageModel,
@@ -100,8 +108,7 @@ export async function doStreamStep(
100
108
  ): Promise<{
101
109
  toolCalls: ParsedToolCall[];
102
110
  finish: StreamFinish | undefined;
103
- step: StepResult<ToolSet, any>;
104
- chunks?: unknown[];
111
+ raw: DoStreamStepRawResult;
105
112
  providerExecutedToolResults: Map<string, ProviderExecutedToolResult>;
106
113
  }> {
107
114
  'use step';
@@ -155,10 +162,9 @@ export async function doStreamStep(
155
162
  >();
156
163
  let finish: StreamFinish | undefined;
157
164
 
158
- // Aggregation for StepResult
165
+ // Minimal aggregation — only what buildStepResult needs outside the step.
159
166
  let text = '';
160
167
  const reasoningParts: Array<{ text: string }> = [];
161
- const chunks: unknown[] = [];
162
168
  let responseMetadata:
163
169
  | { id?: string; timestamp?: Date; modelId?: string }
164
170
  | undefined;
@@ -169,14 +175,6 @@ export async function doStreamStep(
169
175
 
170
176
  try {
171
177
  for await (const part of modelStream) {
172
- if (
173
- part.type !== 'model-call-start' &&
174
- part.type !== 'model-call-end' &&
175
- part.type !== 'model-call-response-metadata'
176
- ) {
177
- chunks.push(part);
178
- }
179
-
180
178
  switch (part.type) {
181
179
  case 'text-delta':
182
180
  text += part.text;
@@ -253,109 +251,15 @@ export async function doStreamStep(
253
251
  writer?.releaseLock();
254
252
  }
255
253
 
256
- // Build StepResult
257
- const reasoningText = reasoningParts.map(r => r.text).join('') || undefined;
258
-
259
- const step: StepResult<ToolSet, any> = {
260
- callId: 'workflow-agent',
261
- stepNumber: options?.stepNumber ?? 0,
262
- model: {
263
- provider: responseMetadata?.modelId?.split(':')[0] ?? 'unknown',
264
- modelId: responseMetadata?.modelId ?? 'unknown',
265
- },
266
- functionId: undefined,
267
- metadata: undefined,
268
- runtimeContext: options?.runtimeContext ?? {},
269
- toolsContext: options?.toolsContext ?? {},
270
- content: [
271
- ...(text ? [{ type: 'text' as const, text }] : []),
272
- ...toolCalls
273
- .filter(tc => !tc.invalid)
274
- .map(tc => ({
275
- type: 'tool-call' as const,
276
- toolCallId: tc.toolCallId,
277
- toolName: tc.toolName,
278
- input: tc.input,
279
- ...(tc.dynamic ? { dynamic: true as const } : {}),
280
- })),
281
- ],
282
- text,
283
- reasoning: reasoningParts.map(r => ({
284
- type: 'reasoning' as const,
285
- text: r.text,
286
- })),
287
- reasoningText,
288
- files: [],
289
- sources: [],
290
- toolCalls: toolCalls
291
- .filter(tc => !tc.invalid)
292
- .map(tc => ({
293
- type: 'tool-call' as const,
294
- toolCallId: tc.toolCallId,
295
- toolName: tc.toolName,
296
- input: tc.input,
297
- ...(tc.dynamic ? { dynamic: true as const } : {}),
298
- })),
299
- staticToolCalls: [],
300
- dynamicToolCalls: toolCalls
301
- .filter(tc => !tc.invalid && tc.dynamic)
302
- .map(tc => ({
303
- type: 'tool-call' as const,
304
- toolCallId: tc.toolCallId,
305
- toolName: tc.toolName,
306
- input: tc.input,
307
- dynamic: true as const,
308
- })),
309
- toolResults: [],
310
- staticToolResults: [],
311
- dynamicToolResults: [],
312
- finishReason: finish?.finishReason ?? 'other',
313
- rawFinishReason: finish?.rawFinishReason,
314
- usage:
315
- finish?.usage ??
316
- ({
317
- inputTokens: 0,
318
- inputTokenDetails: {
319
- noCacheTokens: undefined,
320
- cacheReadTokens: undefined,
321
- cacheWriteTokens: undefined,
322
- },
323
- outputTokens: 0,
324
- outputTokenDetails: {
325
- textTokens: undefined,
326
- reasoningTokens: undefined,
327
- },
328
- totalTokens: 0,
329
- } as LanguageModelUsage),
330
- performance: {
331
- effectiveOutputTokensPerSecond: 0,
332
- outputTokensPerSecond: undefined,
333
- inputTokensPerSecond: undefined,
334
- effectiveTotalTokensPerSecond: 0,
335
- stepTimeMs: 0,
336
- responseTimeMs: 0,
337
- toolExecutionMs: {},
338
- timeToFirstOutputMs: undefined,
339
- },
340
- warnings,
341
- request: {
342
- body: '',
343
- messages: [], // TODO implement step request messages
344
- },
345
- response: {
346
- id: responseMetadata?.id ?? 'unknown',
347
- timestamp: responseMetadata?.timestamp ?? new Date(),
348
- modelId: responseMetadata?.modelId ?? 'unknown',
349
- messages: [],
350
- },
351
- providerMetadata: finish?.providerMetadata ?? {},
352
- } as StepResult<ToolSet, any>;
353
-
354
254
  return {
355
255
  toolCalls,
356
256
  finish,
357
- step,
358
- chunks,
257
+ raw: {
258
+ text,
259
+ reasoning: reasoningParts,
260
+ responseMetadata,
261
+ warnings,
262
+ },
359
263
  providerExecutedToolResults,
360
264
  };
361
265
  }
@@ -9,6 +9,7 @@ import {
9
9
  type Experimental_LanguageModelStreamPart as ModelCallStreamPart,
10
10
  type Experimental_SandboxSession as SandboxSession,
11
11
  type LanguageModel,
12
+ type LanguageModelUsage,
12
13
  type ModelMessage,
13
14
  type StepResult,
14
15
  type ToolCallRepairFunction,
@@ -17,10 +18,12 @@ import {
17
18
  } from 'ai';
18
19
  import { createRestrictedTelemetryDispatcher } from 'ai/internal';
19
20
  import {
21
+ type DoStreamStepRawResult,
20
22
  doStreamStep,
21
23
  type ModelStopCondition,
22
24
  type ParsedToolCall,
23
25
  type ProviderExecutedToolResult,
26
+ type StreamFinish,
24
27
  } from './do-stream-step.js';
25
28
  import { serializeToolSet } from './serializable-schema.js';
26
29
  import type {
@@ -344,7 +347,7 @@ export async function* streamTextIterator({
344
347
  headers: currentGenerationSettings.headers,
345
348
  } as never);
346
349
 
347
- const { toolCalls, finish, step, providerExecutedToolResults } =
350
+ const { toolCalls, finish, raw, providerExecutedToolResults } =
348
351
  await doStreamStep(
349
352
  conversationPrompt,
350
353
  currentModel,
@@ -356,11 +359,16 @@ export async function* streamTextIterator({
356
359
  includeRawChunks,
357
360
  repairToolCall,
358
361
  responseFormat,
359
- runtimeContext: currentRuntimeContext,
360
- toolsContext: currentToolsContext,
361
- stepNumber,
362
362
  },
363
363
  );
364
+ // Reconstruct the full StepResult outside the step boundary so the
365
+ // durable event log doesn't carry StepResult's redundant copies (or the
366
+ // per-chunk snapshot the step used to return).
367
+ const step = buildStepResult(raw, toolCalls, finish, {
368
+ stepNumber,
369
+ runtimeContext: currentRuntimeContext,
370
+ toolsContext: currentToolsContext,
371
+ });
364
372
 
365
373
  await telemetryDispatcher.onLanguageModelCallEnd?.({
366
374
  callId: step.callId,
@@ -514,6 +522,108 @@ function normalizeStepForTelemetry(step: StepResult<any, any>) {
514
522
  };
515
523
  }
516
524
 
525
+ /**
526
+ * Reconstruct a full `StepResult` from the minimal aggregates returned by
527
+ * `doStreamStep`. Runs outside the step boundary so StepResult's redundant
528
+ * fields (duplicate tool-call lists, `content`, `reasoningText`, the
529
+ * always-empty `*ToolResults` arrays) and the per-chunk snapshot don't cross
530
+ * it. The shape matches what the AI SDK's `streamText` exposes to callers.
531
+ */
532
+ function buildStepResult(
533
+ raw: DoStreamStepRawResult,
534
+ toolCalls: ParsedToolCall[],
535
+ finish: StreamFinish | undefined,
536
+ opts: {
537
+ stepNumber: number;
538
+ runtimeContext: Context;
539
+ toolsContext: Record<string, Context | undefined>;
540
+ },
541
+ ): StepResult<ToolSet, any> {
542
+ const { text, reasoning: reasoningParts, responseMetadata, warnings } = raw;
543
+ const reasoningText = reasoningParts.map(r => r.text).join('') || undefined;
544
+
545
+ const validToolCalls = toolCalls
546
+ .filter(tc => !tc.invalid)
547
+ .map(tc => ({
548
+ type: 'tool-call' as const,
549
+ toolCallId: tc.toolCallId,
550
+ toolName: tc.toolName,
551
+ input: tc.input,
552
+ ...(tc.dynamic ? { dynamic: true as const } : {}),
553
+ }));
554
+
555
+ return {
556
+ callId: 'workflow-agent',
557
+ stepNumber: opts.stepNumber,
558
+ model: {
559
+ provider: responseMetadata?.modelId?.split(':')[0] ?? 'unknown',
560
+ modelId: responseMetadata?.modelId ?? 'unknown',
561
+ },
562
+ functionId: undefined,
563
+ metadata: undefined,
564
+ runtimeContext: opts.runtimeContext ?? {},
565
+ toolsContext: opts.toolsContext ?? {},
566
+ content: [
567
+ ...(text ? [{ type: 'text' as const, text }] : []),
568
+ ...validToolCalls,
569
+ ],
570
+ text,
571
+ reasoning: reasoningParts.map(r => ({
572
+ type: 'reasoning' as const,
573
+ text: r.text,
574
+ })),
575
+ reasoningText,
576
+ files: [],
577
+ sources: [],
578
+ toolCalls: validToolCalls,
579
+ staticToolCalls: [],
580
+ dynamicToolCalls: validToolCalls.filter(tc => tc.dynamic),
581
+ toolResults: [],
582
+ staticToolResults: [],
583
+ dynamicToolResults: [],
584
+ finishReason: finish?.finishReason ?? 'other',
585
+ rawFinishReason: finish?.rawFinishReason,
586
+ usage:
587
+ finish?.usage ??
588
+ ({
589
+ inputTokens: 0,
590
+ inputTokenDetails: {
591
+ noCacheTokens: undefined,
592
+ cacheReadTokens: undefined,
593
+ cacheWriteTokens: undefined,
594
+ },
595
+ outputTokens: 0,
596
+ outputTokenDetails: {
597
+ textTokens: undefined,
598
+ reasoningTokens: undefined,
599
+ },
600
+ totalTokens: 0,
601
+ } as LanguageModelUsage),
602
+ performance: {
603
+ effectiveOutputTokensPerSecond: 0,
604
+ outputTokensPerSecond: undefined,
605
+ inputTokensPerSecond: undefined,
606
+ effectiveTotalTokensPerSecond: 0,
607
+ stepTimeMs: 0,
608
+ responseTimeMs: 0,
609
+ toolExecutionMs: {},
610
+ timeToFirstOutputMs: undefined,
611
+ },
612
+ warnings,
613
+ request: {
614
+ body: '',
615
+ messages: [], // TODO implement step request messages
616
+ },
617
+ response: {
618
+ id: responseMetadata?.id ?? 'unknown',
619
+ timestamp: responseMetadata?.timestamp ?? new Date(),
620
+ modelId: responseMetadata?.modelId ?? 'unknown',
621
+ messages: [],
622
+ },
623
+ providerMetadata: finish?.providerMetadata ?? {},
624
+ } as StepResult<ToolSet, any>;
625
+ }
626
+
517
627
  /**
518
628
  * Strip OpenAI's itemId from providerMetadata (requires reasoning items we don't preserve).
519
629
  * Preserves all other provider metadata (e.g., Gemini's thoughtSignature).
@@ -1148,6 +1148,16 @@ export interface WorkflowAgentStreamResult<
1148
1148
  */
1149
1149
  toolResults: ToolResult[];
1150
1150
 
1151
+ /**
1152
+ * The finish reason from the last step.
1153
+ */
1154
+ finishReason: FinishReason;
1155
+
1156
+ /**
1157
+ * The total token usage across all steps.
1158
+ */
1159
+ totalUsage: LanguageModelUsage;
1160
+
1151
1161
  /**
1152
1162
  * The generated structured output. It uses the `output` specification.
1153
1163
  * Only available when `output` is specified.
@@ -2062,6 +2072,8 @@ export class WorkflowAgent<
2062
2072
  steps,
2063
2073
  toolCalls: [],
2064
2074
  toolResults: [],
2075
+ finishReason: 'other',
2076
+ totalUsage: aggregateUsage(steps),
2065
2077
  output: undefined as OUTPUT,
2066
2078
  };
2067
2079
  }
@@ -2264,15 +2276,16 @@ export class WorkflowAgent<
2264
2276
  }
2265
2277
 
2266
2278
  const messages = iterMessages as unknown as ModelMessage[];
2279
+ const lastStep = steps[steps.length - 1];
2280
+ const totalUsage = aggregateUsage(steps);
2281
+ const finishReason = lastStep?.finishReason ?? 'other';
2267
2282
 
2268
2283
  if (mergedOnEnd && !wasAborted) {
2269
- const lastStep = steps[steps.length - 1];
2270
- const totalUsage = aggregateUsage(steps);
2271
2284
  await mergedOnEnd({
2272
2285
  steps,
2273
2286
  messages,
2274
2287
  text: lastStep?.text ?? '',
2275
- finishReason: lastStep?.finishReason ?? 'other',
2288
+ finishReason,
2276
2289
  usage: totalUsage,
2277
2290
  totalUsage,
2278
2291
  runtimeContext,
@@ -2283,10 +2296,10 @@ export class WorkflowAgent<
2283
2296
  }
2284
2297
  if (!wasAborted && steps.length > 0) {
2285
2298
  const telemetrySteps = steps.map(normalizeStepForTelemetry);
2286
- const lastStep = telemetrySteps[telemetrySteps.length - 1];
2287
- const totalUsage = aggregateUsage(steps);
2299
+ const lastTelemetryStep =
2300
+ telemetrySteps[telemetrySteps.length - 1];
2288
2301
  await telemetryDispatcher.onEnd?.({
2289
- ...lastStep,
2302
+ ...lastTelemetryStep,
2290
2303
  steps: telemetrySteps,
2291
2304
  usage: totalUsage,
2292
2305
  totalUsage,
@@ -2327,6 +2340,8 @@ export class WorkflowAgent<
2327
2340
  steps,
2328
2341
  toolCalls: allToolCalls,
2329
2342
  toolResults: allToolResults,
2343
+ finishReason,
2344
+ totalUsage,
2330
2345
  output: undefined as OUTPUT,
2331
2346
  };
2332
2347
  }
@@ -2491,15 +2506,17 @@ export class WorkflowAgent<
2491
2506
  }
2492
2507
  }
2493
2508
 
2509
+ const lastStep = steps[steps.length - 1];
2510
+ const totalUsage = aggregateUsage(steps);
2511
+ const finishReason = lastStep?.finishReason ?? 'other';
2512
+
2494
2513
  // Call onEnd callback if provided (always call, even on errors, but not on abort)
2495
2514
  if (mergedOnEnd && !wasAborted) {
2496
- const lastStep = steps[steps.length - 1];
2497
- const totalUsage = aggregateUsage(steps);
2498
2515
  await mergedOnEnd({
2499
2516
  steps,
2500
2517
  messages: messages as ModelMessage[],
2501
2518
  text: lastStep?.text ?? '',
2502
- finishReason: lastStep?.finishReason ?? 'other',
2519
+ finishReason,
2503
2520
  usage: totalUsage,
2504
2521
  totalUsage,
2505
2522
  runtimeContext,
@@ -2509,10 +2526,9 @@ export class WorkflowAgent<
2509
2526
  }
2510
2527
  if (!wasAborted && steps.length > 0) {
2511
2528
  const telemetrySteps = steps.map(normalizeStepForTelemetry);
2512
- const lastStep = telemetrySteps[telemetrySteps.length - 1];
2513
- const totalUsage = aggregateUsage(steps);
2529
+ const lastTelemetryStep = telemetrySteps[telemetrySteps.length - 1];
2514
2530
  await telemetryDispatcher.onEnd?.({
2515
- ...lastStep,
2531
+ ...lastTelemetryStep,
2516
2532
  steps: telemetrySteps,
2517
2533
  usage: totalUsage,
2518
2534
  totalUsage,
@@ -2546,6 +2562,8 @@ export class WorkflowAgent<
2546
2562
  steps,
2547
2563
  toolCalls: lastStepToolCalls,
2548
2564
  toolResults: lastStepToolResults,
2565
+ finishReason,
2566
+ totalUsage,
2549
2567
  output: experimentalOutput,
2550
2568
  };
2551
2569
  }
@@ -15,6 +15,87 @@ import {
15
15
  import { createAsyncIterableStream } from 'ai/internal';
16
16
  import { normalizeUIMessageStreamParts } from './normalize-ui-message-stream.js';
17
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
+
18
99
  export interface SendMessagesOptions<UI_MESSAGE extends UIMessage> {
19
100
  trigger: 'submit-message' | 'regenerate-message';
20
101
  chatId: string;
@@ -339,6 +420,17 @@ export class WorkflowChatTransport<
339
420
  // the incremental chunkIndex which would be wrong.
340
421
  let replayFromStart = false;
341
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
+
342
434
  while (!gotFinish) {
343
435
  const startIndex = useExplicitStartIndex
344
436
  ? explicitStartIndex
@@ -404,6 +496,8 @@ export class WorkflowChatTransport<
404
496
 
405
497
  chunkIndex++;
406
498
 
499
+ if (orphanFilter?.shouldDrop(chunk.value)) continue;
500
+
407
501
  yield chunk.value;
408
502
 
409
503
  if (chunk.value.type === 'finish') {