ai 7.0.69 → 7.0.71

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.
@@ -92,7 +92,7 @@ import {
92
92
  } from "@ai-sdk/provider-utils";
93
93
 
94
94
  // src/version.ts
95
- var VERSION = true ? "7.0.69" : "0.0.0-test";
95
+ var VERSION = true ? "7.0.71" : "0.0.0-test";
96
96
 
97
97
  // src/util/download/download.ts
98
98
  var download = async ({
@@ -36,10 +36,10 @@ For simpler use cases that don't need durability, use [`ToolLoopAgent`](/docs/ag
36
36
  ## Installation
37
37
 
38
38
  ```bash
39
- npm install @ai-sdk/workflow workflow
39
+ npm install @ai-sdk/workflow workflow@beta
40
40
  ```
41
41
 
42
- `@ai-sdk/workflow` requires the `ai` package and `zod` as peer dependencies. The `workflow` package provides the Workflow DevKit runtime (`getWritable`, `'use workflow'`, `'use step'`).
42
+ `@ai-sdk/workflow` requires Workflow 5, which is currently available under the `beta` tag, as well as the `ai` package and `zod` peer dependencies. The `workflow` package provides the Workflow DevKit runtime (`getWritable`, `'use workflow'`, `'use step'`).
43
43
 
44
44
  ## Creating a WorkflowAgent
45
45
 
@@ -185,6 +185,11 @@ return createUIMessageStreamResponse({
185
185
  });
186
186
  ```
187
187
 
188
+ The transform also forwards `reset-step` events emitted by `WorkflowAgent` on
189
+ retries.
190
+ Clients remove partial parts from the failed model-call step before processing
191
+ the retried output.
192
+
188
193
  ## Resumable Streaming with WorkflowChatTransport
189
194
 
190
195
  Workflow functions can time out or be interrupted by network failures. `WorkflowChatTransport` is a [`ChatTransport`](/docs/ai-sdk-ui/transport) implementation that handles these interruptions automatically — it detects when a stream ends without a `finish` event and reconnects to resume from where it left off.
@@ -202,7 +207,6 @@ export default function Chat() {
202
207
  new WorkflowChatTransport({
203
208
  api: '/api/chat',
204
209
  maxConsecutiveErrors: 5,
205
- initialStartIndex: -50, // On page refresh, fetch last 50 chunks
206
210
  }),
207
211
  [],
208
212
  );
@@ -236,6 +240,7 @@ export async function POST(request: Request) {
236
240
 
237
241
  ```ts filename="app/api/chat/[runId]/stream/route.ts"
238
242
  import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow';
243
+ import { createUIMessageStreamResponse } from 'ai';
239
244
  import type { NextRequest } from 'next/server';
240
245
  import { getRun } from 'workflow/api';
241
246
 
@@ -247,23 +252,36 @@ export async function GET(
247
252
  const startIndex = Number(
248
253
  new URL(request.url).searchParams.get('startIndex') ?? '0',
249
254
  );
255
+ if (!Number.isSafeInteger(startIndex) || startIndex < 0) {
256
+ return Response.json(
257
+ { error: 'startIndex must be a non-negative safe integer' },
258
+ { status: 400 },
259
+ );
260
+ }
250
261
 
251
262
  const run = await getRun(runId);
252
263
  const readable = run
253
- .getReadable({ startIndex })
254
- .pipeThrough(createModelCallToUIChunkTransform());
264
+ .getReadable({ startIndex: 0 })
265
+ .pipeThrough(
266
+ createModelCallToUIChunkTransform({ uiStartIndex: startIndex }),
267
+ );
255
268
 
256
- return new Response(readable, {
269
+ return createUIMessageStreamResponse({
270
+ stream: readable,
257
271
  headers: {
258
- 'Content-Type': 'text/event-stream',
259
- 'Cache-Control': 'no-cache',
260
- Connection: 'keep-alive',
261
272
  'x-workflow-run-id': runId,
262
273
  },
263
274
  });
264
275
  }
265
276
  ```
266
277
 
278
+ `WorkflowChatTransport` counts `UIMessageChunk` objects, while the durable
279
+ `WorkflowAgent` stream stores raw `ModelCallStreamPart` objects. Replay the raw
280
+ stream from index `0` and apply the non-negative UI cursor in
281
+ `createModelCallToUIChunkTransform()` as shown above. Negative start indexes
282
+ require a durable stream that already stores `UIMessageChunk` objects and
283
+ cannot be used with this raw-to-UI conversion.
284
+
267
285
  For the full API reference, see [`WorkflowChatTransport`](/docs/reference/ai-sdk-workflow/workflow-chat-transport).
268
286
 
269
287
  ## Tools as Workflow Steps
@@ -589,7 +607,7 @@ export type MyAgentUIMessage = InferWorkflowAgentUIMessage<typeof myAgent>;
589
607
  Install the new package alongside `workflow`:
590
608
 
591
609
  ```bash
592
- npm install @ai-sdk/workflow
610
+ npm install @ai-sdk/workflow workflow@beta
593
611
  ```
594
612
 
595
613
  ### Write `ModelCallStreamPart`, not `UIMessageChunk`
@@ -484,6 +484,64 @@ try {
484
484
  }
485
485
  ```
486
486
 
487
+ ### Basic Sandbox Sessions Without Network Control
488
+
489
+ The following example demonstrates how the basic-session API works. If your
490
+ project can expose a full network sandbox session, passing that session is
491
+ strongly recommended. Pass a restricted basic session only when your project
492
+ cannot expose the full network session.
493
+
494
+ A basic sandbox session only exposes filesystem and process APIs. The agent
495
+ still leaves the sandbox lifecycle to the caller. For a bridge-backed harness,
496
+ configure the bridge port and its externally reachable endpoint on the adapter
497
+ because the basic session cannot resolve them.
498
+
499
+ ```ts
500
+ import { HarnessAgent, prepareSandboxForHarness } from '@ai-sdk/harness/agent';
501
+ import { createClaudeCode } from '@ai-sdk/harness-claude-code';
502
+ import { createVercelSandbox } from '@ai-sdk/sandbox-vercel';
503
+ import { Sandbox } from '@vercel/sandbox';
504
+
505
+ const sandbox = await Sandbox.create({
506
+ runtime: 'node24',
507
+ ports: [4000],
508
+ });
509
+ const sandboxProvider = createVercelSandbox({ sandbox });
510
+ const sandboxSession = await sandboxProvider.createSession();
511
+ const portEndpoint = await sandboxSession.getPortEndpoint({
512
+ port: 4000,
513
+ protocol: 'ws',
514
+ });
515
+ const restrictedSandboxSession = sandboxSession.restricted();
516
+ const claudeCode = createClaudeCode({ port: 4000, portEndpoint });
517
+
518
+ await prepareSandboxForHarness({
519
+ session: restrictedSandboxSession,
520
+ harnesses: [claudeCode],
521
+ });
522
+
523
+ const agent = new HarnessAgent({ harness: claudeCode });
524
+ const session = await agent.createSession({
525
+ sandboxSession: restrictedSandboxSession,
526
+ });
527
+
528
+ try {
529
+ const result = await agent.stream({
530
+ session,
531
+ prompt: 'Create a short TODO.md for this repository.',
532
+ });
533
+
534
+ for await (const part of result.stream) {
535
+ if (part.type === 'text-delta') {
536
+ process.stdout.write(part.text);
537
+ }
538
+ }
539
+ } finally {
540
+ await session.destroy();
541
+ await sandbox.stop();
542
+ }
543
+ ```
544
+
487
545
  ## Next Steps
488
546
 
489
547
  - [Tools](/docs/ai-sdk-harnesses/tools) for built-in and host-executed tools.
@@ -171,7 +171,6 @@ export default function Chat() {
171
171
  new WorkflowChatTransport({
172
172
  api: '/api/chat',
173
173
  maxConsecutiveErrors: 5,
174
- initialStartIndex: -50, // On page refresh, fetch last 50 chunks
175
174
  onChatEnd: ({ chatId, chunkIndex }) => {
176
175
  console.log(`Chat complete: ${chunkIndex} chunks`);
177
176
  },
@@ -188,10 +187,15 @@ export default function Chat() {
188
187
  Key features:
189
188
 
190
189
  - **Automatic reconnection**: Detects interrupted streams (no `finish` event) and reconnects via GET to `{api}/{runId}/stream`
191
- - **Page refresh recovery**: `initialStartIndex` with negative values (e.g., `-50`) fetches only the tail of the stream instead of replaying everything
190
+ - **Page refresh recovery**: `initialStartIndex` controls where the initial reconnection begins
192
191
  - **Configurable retries**: `maxConsecutiveErrors` controls how many consecutive reconnection failures to tolerate
193
192
  - **Lifecycle callbacks**: `onChatSendMessage` and `onChatEnd` for tracking chat state
194
193
 
194
+ Negative `initialStartIndex` values can fetch only the tail when the durable
195
+ server stream already stores `UIMessageChunk` objects. For raw `WorkflowAgent`
196
+ streams, use a non-negative cursor and follow the server-side conversion in the
197
+ WorkflowAgent guide.
198
+
195
199
  For the full API reference, see [`WorkflowChatTransport`](/docs/reference/ai-sdk-workflow/workflow-chat-transport). For server-side endpoint setup, see the [WorkflowAgent guide](/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport).
196
200
 
197
201
  ## Building Custom Transports
@@ -442,6 +442,22 @@ data: {"type":"finish-step"}
442
442
 
443
443
  ```
444
444
 
445
+ ### Reset Step Part
446
+
447
+ Removes all message parts received since the most recent `start-step` part. If
448
+ there is no step boundary, it removes all parts from the current message. This
449
+ is useful when a streamed step is retried and partial output from the failed
450
+ attempt must be invalidated before replacement output is sent.
451
+
452
+ Format: Server-Sent Event with JSON object
453
+
454
+ Example:
455
+
456
+ ```
457
+ data: {"type":"reset-step"}
458
+
459
+ ```
460
+
445
461
  ### Finish Message Part
446
462
 
447
463
  A part indicating the completion of a message.
@@ -696,7 +696,7 @@ Returns a `Promise<WorkflowAgentStreamResult>` with the following properties:
696
696
 
697
697
  ## Utilities
698
698
 
699
- ### `createModelCallToUIChunkTransform()`
699
+ ### `createModelCallToUIChunkTransform(options?)`
700
700
 
701
701
  Creates a `TransformStream` that converts raw `ModelCallStreamPart` chunks (written by the agent to the `writable` stream) into `UIMessageChunk` objects suitable for client consumption.
702
702
 
@@ -708,6 +708,20 @@ return createUIMessageStreamResponse({
708
708
  });
709
709
  ```
710
710
 
711
+ When resuming with a `WorkflowChatTransport` cursor, replay the raw workflow
712
+ stream from index `0` and pass the non-negative UI chunk index to the transform:
713
+
714
+ ```ts
715
+ const readable = run
716
+ .getReadable({ startIndex: 0 })
717
+ .pipeThrough(createModelCallToUIChunkTransform({ uiStartIndex: startIndex }));
718
+ ```
719
+
720
+ `uiStartIndex` must be a non-negative safe integer. Raw model stream parts and
721
+ UI message chunks are not one-to-one, so do not pass a UI chunk index to
722
+ `getReadable`. Negative tail indexes require a durable stream that already
723
+ stores `UIMessageChunk` objects.
724
+
711
725
  ### `toUIMessageChunk()`
712
726
 
713
727
  Converts a single `ModelCallStreamPart` to a `UIMessageChunk`. Returns `undefined` for parts that don't map to UI chunks.
@@ -20,7 +20,6 @@ export default function Chat() {
20
20
  transport: new WorkflowChatTransport({
21
21
  api: '/api/chat',
22
22
  maxConsecutiveErrors: 5,
23
- initialStartIndex: -50,
24
23
  }),
25
24
  });
26
25
 
@@ -67,7 +66,7 @@ export default function Chat() {
67
66
  type: 'number',
68
67
  isOptional: true,
69
68
  description:
70
- 'Default chunk index to start from when reconnecting. Negative values read from the end of the stream (e.g., -50 fetches the last 50 chunks), useful for resuming after a page refresh without replaying the full conversation. Can be overridden per-call via reconnectToStream options. Default: 0.',
69
+ 'Default chunk index to start from when reconnecting. Negative values read from the end of a durable UIMessageChunk stream (e.g., -50 fetches the last 50 chunks), useful for resuming after a page refresh without replaying the full conversation. Raw ModelCallStreamPart streams do not support negative UI chunk indexes. Can be overridden per-call via reconnectToStream options. Default: 0.',
71
70
  },
72
71
  {
73
72
  name: 'onChatSendMessage',
@@ -183,7 +182,7 @@ const stream = await transport.reconnectToStream({
183
182
  type: 'number',
184
183
  isOptional: true,
185
184
  description:
186
- "Override the start index for this reconnection. Negative values read from the end of the stream. When omitted, falls back to the constructor's initialStartIndex.",
185
+ "Override the start index for this reconnection. Negative values read from the end when the server's durable stream and tail-index header use the same UIMessageChunk index space. When omitted, falls back to the constructor's initialStartIndex.",
187
186
  },
188
187
  ]}
189
188
  />
@@ -209,6 +208,10 @@ When `initialStartIndex` is negative (e.g., `-50`), the transport sends it as-is
209
208
 
210
209
  If the header is missing or invalid, the transport falls back to replaying from the beginning (`startIndex=0`).
211
210
 
211
+ Negative indexes require a durable server stream whose stored objects are
212
+ already `UIMessageChunk` objects. The raw `WorkflowAgent` conversion shown
213
+ below supports non-negative indexes only.
214
+
212
215
  ## Server Requirements
213
216
 
214
217
  For `WorkflowChatTransport` to work, your server must provide two endpoints:
@@ -262,7 +265,7 @@ export default function Chat() {
262
265
  }
263
266
  ```
264
267
 
265
- ### With Callbacks and Page Refresh Recovery
268
+ ### With Callbacks
266
269
 
267
270
  ```tsx
268
271
  'use client';
@@ -277,7 +280,6 @@ export default function Chat() {
277
280
  new WorkflowChatTransport({
278
281
  api: '/api/chat',
279
282
  maxConsecutiveErrors: 5,
280
- initialStartIndex: -50, // Resume from last 50 chunks on page refresh
281
283
  onChatSendMessage: response => {
282
284
  const runId = response.headers.get('x-workflow-run-id');
283
285
  console.log('Workflow run started:', runId);
@@ -318,6 +320,7 @@ export async function POST(request: Request) {
318
320
 
319
321
  ```ts filename="app/api/chat/[runId]/stream/route.ts"
320
322
  import { createModelCallToUIChunkTransform } from '@ai-sdk/workflow';
323
+ import { createUIMessageStreamResponse } from 'ai';
321
324
  import type { NextRequest } from 'next/server';
322
325
  import { getRun } from 'workflow/api';
323
326
 
@@ -329,19 +332,30 @@ export async function GET(
329
332
  const startIndex = Number(
330
333
  new URL(request.url).searchParams.get('startIndex') ?? '0',
331
334
  );
335
+ if (!Number.isSafeInteger(startIndex) || startIndex < 0) {
336
+ return Response.json(
337
+ { error: 'startIndex must be a non-negative safe integer' },
338
+ { status: 400 },
339
+ );
340
+ }
332
341
 
333
342
  const run = await getRun(runId);
334
343
  const readable = run
335
- .getReadable({ startIndex })
336
- .pipeThrough(createModelCallToUIChunkTransform());
344
+ .getReadable({ startIndex: 0 })
345
+ .pipeThrough(
346
+ createModelCallToUIChunkTransform({ uiStartIndex: startIndex }),
347
+ );
337
348
 
338
- return new Response(readable, {
349
+ return createUIMessageStreamResponse({
350
+ stream: readable,
339
351
  headers: {
340
- 'Content-Type': 'text/event-stream',
341
- 'Cache-Control': 'no-cache',
342
- Connection: 'keep-alive',
343
352
  'x-workflow-run-id': runId,
344
353
  },
345
354
  });
346
355
  }
347
356
  ```
357
+
358
+ This `WorkflowAgent` endpoint replays raw `ModelCallStreamPart` objects from
359
+ index `0`, then applies the transport's non-negative cursor after converting
360
+ them to `UIMessageChunk` objects. Negative start indexes require a durable
361
+ stream whose stored objects are already `UIMessageChunk` objects.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai",
3
- "version": "7.0.69",
3
+ "version": "7.0.71",
4
4
  "type": "module",
5
5
  "description": "AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.",
6
6
  "license": "Apache-2.0",
@@ -42,9 +42,9 @@
42
42
  }
43
43
  },
44
44
  "dependencies": {
45
- "@ai-sdk/gateway": "4.0.55",
45
+ "@ai-sdk/gateway": "4.0.57",
46
46
  "@ai-sdk/provider": "4.0.7",
47
- "@ai-sdk/provider-utils": "5.0.27"
47
+ "@ai-sdk/provider-utils": "5.0.28"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@edge-runtime/vm": "^5.0.0",
@@ -68,6 +68,12 @@ import { validateObjectGenerationInput } from './validate-object-generation-inpu
68
68
 
69
69
  const originalGenerateId = createIdGenerator({ prefix: 'aiobj', size: 24 });
70
70
 
71
+ async function markPromiseAsHandled<T>(promise: Promise<T>): Promise<void> {
72
+ try {
73
+ await promise;
74
+ } catch {}
75
+ }
76
+
71
77
  /**
72
78
  * Callback that is set using the `onError` option.
73
79
  *
@@ -540,7 +546,10 @@ class DefaultStreamObjectResult<
540
546
  controller.enqueue(chunk);
541
547
 
542
548
  if (chunk.type === 'error') {
543
- onError({ error: wrapGatewayError(chunk.error) });
549
+ void notify({
550
+ event: { error: wrapGatewayError(chunk.error) },
551
+ callbacks: onError,
552
+ });
544
553
  }
545
554
  },
546
555
  });
@@ -656,6 +665,7 @@ class DefaultStreamObjectResult<
656
665
  let providerMetadata: ProviderMetadata | undefined;
657
666
  let object: RESULT | undefined;
658
667
  let error: unknown | undefined;
668
+ let terminalError: { error: unknown } | undefined;
659
669
  let msToFirstChunk: number | undefined = undefined;
660
670
 
661
671
  let accumulatedText = '';
@@ -751,19 +761,35 @@ class DefaultStreamObjectResult<
751
761
  break;
752
762
  }
753
763
 
764
+ case 'error': {
765
+ if (terminalError === undefined) {
766
+ const wrappedError = wrapGatewayError(chunk.error);
767
+ terminalError = { error: wrappedError };
768
+ error = wrappedError;
769
+ finishReason = 'error';
770
+ self.rejectResultPromises(wrappedError);
771
+ }
772
+
773
+ controller.enqueue(chunk);
774
+ break;
775
+ }
776
+
754
777
  case 'finish': {
755
778
  if (textDelta !== '') {
756
779
  controller.enqueue({ type: 'text-delta', textDelta });
757
780
  }
758
781
 
759
- finishReason = chunk.finishReason.unified;
782
+ finishReason =
783
+ terminalError === undefined
784
+ ? chunk.finishReason.unified
785
+ : 'error';
760
786
 
761
787
  usage = asLanguageModelUsage(chunk.usage);
762
788
  providerMetadata = chunk.providerMetadata;
763
789
 
764
790
  controller.enqueue({
765
791
  ...chunk,
766
- finishReason: chunk.finishReason.unified,
792
+ finishReason,
767
793
  usage,
768
794
  response: fullResponse,
769
795
  });
@@ -774,6 +800,10 @@ class DefaultStreamObjectResult<
774
800
  model: model.modelId,
775
801
  });
776
802
 
803
+ if (terminalError !== undefined) {
804
+ break;
805
+ }
806
+
777
807
  self._usage.resolve(usage);
778
808
  self._providerMetadata.resolve(providerMetadata);
779
809
  self._warnings.resolve(warnings);
@@ -867,9 +897,19 @@ class DefaultStreamObjectResult<
867
897
  }),
868
898
  );
869
899
 
870
- stitchableStream.addStream(transformedStream);
900
+ stitchableStream.addStream(transformedStream, {
901
+ onError(error) {
902
+ const wrappedError = wrapGatewayError(error);
903
+ self.rejectResultPromises(wrappedError);
904
+ void notify({
905
+ event: { error: wrappedError },
906
+ callbacks: onError,
907
+ });
908
+ },
909
+ });
871
910
  })()
872
911
  .catch(async error => {
912
+ self.rejectResultPromises(error);
873
913
  await telemetryDispatcher.onError?.({ callId, error });
874
914
 
875
915
  stitchableStream.addStream(
@@ -888,6 +928,29 @@ class DefaultStreamObjectResult<
888
928
  this.outputStrategy = outputStrategy;
889
929
  }
890
930
 
931
+ private rejectResultPromises(error: unknown) {
932
+ this.rejectResultPromise({ delayedPromise: this._object, error });
933
+ this.rejectResultPromise({ delayedPromise: this._usage, error });
934
+ this.rejectResultPromise({ delayedPromise: this._providerMetadata, error });
935
+ this.rejectResultPromise({ delayedPromise: this._warnings, error });
936
+ this.rejectResultPromise({ delayedPromise: this._request, error });
937
+ this.rejectResultPromise({ delayedPromise: this._response, error });
938
+ this.rejectResultPromise({ delayedPromise: this._finishReason, error });
939
+ }
940
+
941
+ private rejectResultPromise<T>({
942
+ delayedPromise,
943
+ error,
944
+ }: {
945
+ delayedPromise: DelayedPromise<T>;
946
+ error: unknown;
947
+ }) {
948
+ if (delayedPromise.isPending()) {
949
+ delayedPromise.reject(error);
950
+ markPromiseAsHandled(delayedPromise.promise);
951
+ }
952
+ }
953
+
891
954
  get object() {
892
955
  return this._object.promise;
893
956
  }
@@ -11,6 +11,7 @@ import type { TimeoutConfiguration } from '../prompt/request-options';
11
11
  import type { Telemetry, TelemetryDispatcher } from '../telemetry/telemetry';
12
12
  import { getOwn } from '../util/get-own';
13
13
  import { executeToolCall } from './execute-tool-call';
14
+ import { isToolExecutionAllowedFinishReason } from './is-tool-execution-allowed-finish-reason';
14
15
  import { resolveToolApproval } from './resolve-tool-approval';
15
16
  import type { LanguageModelStreamPart } from './stream-language-model-call';
16
17
  import { maybeSignApproval } from './tool-approval-signature';
@@ -197,6 +198,10 @@ export function executeToolsFromStream<
197
198
  }
198
199
 
199
200
  case 'model-call-end': {
201
+ if (!isToolExecutionAllowedFinishReason(chunk.finishReason)) {
202
+ return;
203
+ }
204
+
200
205
  await Promise.all(
201
206
  toolCallsToExecute.map(async toolCall => {
202
207
  try {
@@ -79,6 +79,7 @@ import type {
79
79
  } from './generate-text-events';
80
80
  import type { GenerateTextResult } from './generate-text-result';
81
81
  import { DefaultGeneratedFile } from './generated-file';
82
+ import { isToolExecutionAllowedFinishReason } from './is-tool-execution-allowed-finish-reason';
82
83
  import type {
83
84
  OnLanguageModelCallEndCallback,
84
85
  OnLanguageModelCallStartCallback,
@@ -1259,7 +1260,12 @@ export async function generateText<
1259
1260
  );
1260
1261
  const toolExecutionMs: Record<string, number> = {};
1261
1262
 
1262
- if (stepExecutionTools != null) {
1263
+ if (
1264
+ stepExecutionTools != null &&
1265
+ isToolExecutionAllowedFinishReason(
1266
+ currentModelResponse.finishReason.unified,
1267
+ )
1268
+ ) {
1263
1269
  const toolExecutionResults = await executeTools({
1264
1270
  toolCalls: clientToolCalls.filter(
1265
1271
  toolCall =>
@@ -1432,13 +1438,11 @@ export async function generateText<
1432
1438
  }
1433
1439
  }
1434
1440
  } while (
1435
- // Continue if:
1436
- // 1. There are client tool calls that have all been executed or denied, OR
1437
- // 2. There are pending deferred results from provider-executed tools
1438
- ((clientToolCalls.length > 0 &&
1439
- clientToolOutputs.length + deniedToolApprovalResponses.length ===
1440
- clientToolCalls.length) ||
1441
- pendingDeferredToolCalls.size > 0) &&
1441
+ // Continue only after all client tool calls have been executed or denied,
1442
+ // and if there are client results or pending deferred provider results.
1443
+ clientToolOutputs.length + deniedToolApprovalResponses.length ===
1444
+ clientToolCalls.length &&
1445
+ (clientToolCalls.length > 0 || pendingDeferredToolCalls.size > 0) &&
1442
1446
  // continue until a stop condition is met:
1443
1447
  !(await isStopConditionMet({ stopConditions, steps }))
1444
1448
  );
@@ -0,0 +1,7 @@
1
+ import type { FinishReason } from '../types/language-model';
2
+
3
+ export function isToolExecutionAllowedFinishReason(
4
+ finishReason: FinishReason,
5
+ ): boolean {
6
+ return finishReason === 'stop' || finishReason === 'tool-calls';
7
+ }
@@ -1193,7 +1193,10 @@ class DefaultStreamTextResult<
1193
1193
 
1194
1194
  const { part } = chunk;
1195
1195
 
1196
- await onChunk?.({ chunk: part });
1196
+ await notify({
1197
+ event: { chunk: part },
1198
+ callbacks: onChunk,
1199
+ });
1197
1200
 
1198
1201
  if (part.type === 'error') {
1199
1202
  const error = wrapGatewayError(part.error);
@@ -1202,7 +1205,10 @@ class DefaultStreamTextResult<
1202
1205
  recordedNoOutputError = error;
1203
1206
  }
1204
1207
 
1205
- await onError({ error });
1208
+ await notify({
1209
+ event: { error },
1210
+ callbacks: onError,
1211
+ });
1206
1212
  }
1207
1213
 
1208
1214
  if (
@@ -2422,13 +2428,12 @@ class DefaultStreamTextResult<
2422
2428
  cleanupStepTimeouts();
2423
2429
 
2424
2430
  if (
2425
- // Continue if:
2426
- // 1. There are client tool calls that have all been executed or denied, OR
2427
- // 2. There are pending deferred results from provider-executed tools, OR
2428
- ((clientToolCalls.length > 0 &&
2429
- clientToolCalls.length ===
2430
- clientToolOutputs.length +
2431
- deniedToolApprovalResponses.length) ||
2431
+ // Continue only after all client tool calls have been executed or denied,
2432
+ // and if there are client results or pending deferred provider results.
2433
+ clientToolCalls.length ===
2434
+ clientToolOutputs.length +
2435
+ deniedToolApprovalResponses.length &&
2436
+ (clientToolCalls.length > 0 ||
2432
2437
  pendingDeferredToolCalls.size > 0) &&
2433
2438
  // continue until a stop condition is met:
2434
2439
  !(await isStopConditionMet({
@@ -63,7 +63,7 @@ export async function convertToModelMessages<UI_MESSAGE extends UIMessage>(
63
63
  part =>
64
64
  !isToolUIPart(part) ||
65
65
  part.state === 'approval-responded' ||
66
- part.state === 'output-available' ||
66
+ (part.state === 'output-available' && part.preliminary !== true) ||
67
67
  part.state === 'output-error' ||
68
68
  part.state === 'output-denied',
69
69
  ),
@@ -887,6 +887,23 @@ export function processUIMessageStream<UI_MESSAGE extends UIMessage>({
887
887
  break;
888
888
  }
889
889
 
890
+ case 'reset-step': {
891
+ const currentStepParts = getCurrentStepParts();
892
+
893
+ state.activeTextParts = createIdMap();
894
+ state.activeReasoningParts = createIdMap();
895
+ state.partialToolCalls = createIdMap();
896
+
897
+ if (currentStepParts.length > 0) {
898
+ state.message.parts.splice(
899
+ state.message.parts.length - currentStepParts.length,
900
+ currentStepParts.length,
901
+ );
902
+ write();
903
+ }
904
+ break;
905
+ }
906
+
890
907
  case 'start': {
891
908
  if (chunk.messageId != null) {
892
909
  state.message.id = chunk.messageId;
@@ -183,6 +183,9 @@ export const uiMessageChunkSchema = lazySchema(() =>
183
183
  z.looseObject({
184
184
  type: z.literal('finish-step'),
185
185
  }),
186
+ z.looseObject({
187
+ type: z.literal('reset-step'),
188
+ }),
186
189
  z.looseObject({
187
190
  type: z.literal('start'),
188
191
  messageId: z.string().optional(),
@@ -378,6 +381,12 @@ export type UIMessageChunk<
378
381
  | {
379
382
  type: 'finish-step';
380
383
  }
384
+ | {
385
+ /**
386
+ * Removes all message parts added during the current step.
387
+ */
388
+ type: 'reset-step';
389
+ }
381
390
  | {
382
391
  type: 'start';
383
392
  messageId?: string;