@convex-dev/agent 0.7.0 → 0.7.2

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.
Files changed (116) hide show
  1. package/README.md +5 -8
  2. package/dist/client/messages.d.ts +10 -0
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +1 -0
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +15 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/files.d.ts +6 -4
  9. package/dist/component/files.d.ts.map +1 -1
  10. package/dist/component/files.js +53 -42
  11. package/dist/component/files.js.map +1 -1
  12. package/dist/component/messages.d.ts +340 -338
  13. package/dist/component/messages.d.ts.map +1 -1
  14. package/dist/component/messages.js +80 -16
  15. package/dist/component/messages.js.map +1 -1
  16. package/dist/component/schema.d.ts +782 -626
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/schema.js +12 -2
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/component/streams.d.ts +16 -2
  21. package/dist/component/streams.d.ts.map +1 -1
  22. package/dist/component/streams.js +86 -30
  23. package/dist/component/streams.js.map +1 -1
  24. package/dist/component/vector/index.d.ts +9 -9
  25. package/dist/component/vector/index.d.ts.map +1 -1
  26. package/dist/component/vector/tables.d.ts +5 -5
  27. package/dist/component/vector/tables.d.ts.map +1 -1
  28. package/dist/component/vector/tables.js +1 -1
  29. package/dist/component/vector/tables.js.map +1 -1
  30. package/dist/errors.d.ts +2 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +106 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
  35. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
  36. package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
  37. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
  38. package/dist/validators.d.ts +1952 -1952
  39. package/dist/vercel/UIMessages.js +1 -1
  40. package/dist/vercel/UIMessages.js.map +1 -1
  41. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  42. package/dist/vercel/client/files.d.ts.map +1 -1
  43. package/dist/vercel/client/files.js +51 -27
  44. package/dist/vercel/client/files.js.map +1 -1
  45. package/dist/vercel/client/messages.d.ts +50 -32
  46. package/dist/vercel/client/messages.d.ts.map +1 -1
  47. package/dist/vercel/client/messages.js +2 -0
  48. package/dist/vercel/client/messages.js.map +1 -1
  49. package/dist/vercel/client/search.d.ts +475 -32
  50. package/dist/vercel/client/search.d.ts.map +1 -1
  51. package/dist/vercel/client/search.js +34 -9
  52. package/dist/vercel/client/search.js.map +1 -1
  53. package/dist/vercel/client/start.d.ts.map +1 -1
  54. package/dist/vercel/client/start.js +1 -0
  55. package/dist/vercel/client/start.js.map +1 -1
  56. package/dist/vercel/client/streamText.d.ts +1 -2
  57. package/dist/vercel/client/streamText.d.ts.map +1 -1
  58. package/dist/vercel/client/streamText.js +49 -17
  59. package/dist/vercel/client/streamText.js.map +1 -1
  60. package/dist/vercel/client/streaming.d.ts +318 -301
  61. package/dist/vercel/client/streaming.d.ts.map +1 -1
  62. package/dist/vercel/client/streaming.js +56 -21
  63. package/dist/vercel/client/streaming.js.map +1 -1
  64. package/dist/vercel/client/types.d.ts +2 -0
  65. package/dist/vercel/client/types.d.ts.map +1 -1
  66. package/dist/vercel/client/types.js.map +1 -1
  67. package/dist/vercel/client/utils.d.ts +9 -5
  68. package/dist/vercel/client/utils.d.ts.map +1 -1
  69. package/dist/vercel/client/utils.js +13 -13
  70. package/dist/vercel/client/utils.js.map +1 -1
  71. package/dist/vercel/fileMaterialization.d.ts +24 -0
  72. package/dist/vercel/fileMaterialization.d.ts.map +1 -0
  73. package/dist/vercel/fileMaterialization.js +118 -0
  74. package/dist/vercel/fileMaterialization.js.map +1 -0
  75. package/dist/vercel/index.d.ts +91 -90
  76. package/dist/vercel/index.d.ts.map +1 -1
  77. package/dist/vercel/index.js +2 -0
  78. package/dist/vercel/index.js.map +1 -1
  79. package/dist/vercel/mapping.d.ts.map +1 -1
  80. package/dist/vercel/mapping.js +7 -1
  81. package/dist/vercel/mapping.js.map +1 -1
  82. package/package.json +16 -9
  83. package/src/client/messages.ts +12 -0
  84. package/src/component/_generated/component.ts +18 -2
  85. package/src/component/files.test.ts +81 -6
  86. package/src/component/files.ts +69 -41
  87. package/src/component/messages.test.ts +496 -0
  88. package/src/component/messages.ts +105 -15
  89. package/src/component/schema.ts +16 -2
  90. package/src/component/streams.test.ts +109 -0
  91. package/src/component/streams.ts +123 -35
  92. package/src/component/vector/tables.ts +1 -1
  93. package/src/errors.test.ts +76 -0
  94. package/src/errors.ts +113 -0
  95. package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
  96. package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
  97. package/src/vercel/UIMessages.ts +1 -1
  98. package/src/vercel/client/files.test.ts +56 -0
  99. package/src/vercel/client/files.ts +53 -28
  100. package/src/vercel/client/index.test.ts +47 -0
  101. package/src/vercel/client/messages.ts +20 -0
  102. package/src/vercel/client/search.test.ts +183 -8
  103. package/src/vercel/client/search.ts +46 -12
  104. package/src/vercel/client/start.ts +1 -0
  105. package/src/vercel/client/streamText.test.ts +113 -17
  106. package/src/vercel/client/streamText.ts +55 -28
  107. package/src/vercel/client/streaming.test.ts +142 -7
  108. package/src/vercel/client/streaming.ts +73 -19
  109. package/src/vercel/client/types.ts +2 -0
  110. package/src/vercel/client/utils.test.ts +97 -0
  111. package/src/vercel/client/utils.ts +15 -15
  112. package/src/vercel/fileMaterialization.ts +174 -0
  113. package/src/vercel/index.ts +2 -0
  114. package/src/vercel/mapping.test.ts +117 -0
  115. package/src/vercel/mapping.ts +15 -1
  116. package/src/vercel/toUIMessages.test.ts +7 -1
@@ -25,20 +25,24 @@ import { startGeneration } from "./start.js";
25
25
  import type { Agent } from "../index.js";
26
26
  import { getModelName, getProviderName } from "../../shared.js";
27
27
  import { errorToString, willContinue } from "./utils.js";
28
+ import { materializeUIMessageChunkFiles } from "../fileMaterialization.js";
28
29
 
29
- /** Finish every abort cleanup path before surfacing an internal failure. */
30
- export async function runAbortCleanup(cleanup: {
30
+ export async function runStreamCleanup(cleanup: {
31
31
  failCall: () => Promise<void>;
32
32
  failStreamer: () => Promise<void>;
33
33
  onAbort?: () => PromiseLike<void> | void;
34
34
  }): Promise<void> {
35
35
  const results = await Promise.allSettled([
36
- cleanup.failCall(),
37
- cleanup.failStreamer(),
36
+ Promise.resolve().then(() => cleanup.failCall()),
37
+ Promise.resolve().then(() => cleanup.failStreamer()),
38
38
  ]);
39
- await cleanup.onAbort?.();
40
- const failure = results.find((result) => result.status === "rejected");
41
- if (failure) throw failure.reason;
39
+ const [abortResult] = await Promise.allSettled([
40
+ Promise.resolve().then(() => cleanup.onAbort?.()),
41
+ ]);
42
+ const failure = [...results, abortResult].find(
43
+ (result) => result.status === "rejected",
44
+ );
45
+ if (failure?.status === "rejected") throw failure.reason;
42
46
  }
43
47
 
44
48
  /**
@@ -102,15 +106,11 @@ export async function streamText<
102
106
  Tools,
103
107
  object,
104
108
  RUNTIME_CONTEXT
105
- >(
106
- ctx,
107
- component,
108
- streamTextArgs,
109
- options,
110
- "streamText",
111
- );
109
+ >(ctx, component, streamTextArgs, options, "streamText");
112
110
 
113
111
  const steps: StepResult<Tools, RUNTIME_CONTEXT>[] = [];
112
+ let firstStreamError: string | undefined;
113
+ let streamCleanupFailure: { error: unknown } | undefined;
114
114
  let initialResponseMessages: ModelMessage[] = [];
115
115
  let initialResponseMessagesSaved = false;
116
116
  const responseMessagesForStep = (
@@ -152,6 +152,8 @@ export async function streamText<
152
152
  : undefined,
153
153
  onAsyncAbort: call.fail,
154
154
  compress: compressUIMessageChunks,
155
+ materialize: (parts) =>
156
+ materializeUIMessageChunkFiles(ctx, component, parts),
155
157
  abortSignal: args.abortSignal,
156
158
  },
157
159
  {
@@ -177,15 +179,26 @@ export async function streamText<
177
179
  ),
178
180
  onError: async (error) => {
179
181
  console.error("onError", error);
180
- await call.fail(errorToString(error.error));
181
- await streamer?.fail(errorToString(error.error));
182
+ const reason = (firstStreamError ??= errorToString(error.error));
183
+ try {
184
+ await runStreamCleanup({
185
+ failCall: () => call.fail(reason),
186
+ failStreamer: async () => streamer?.fail(reason),
187
+ });
188
+ } catch (cleanupError) {
189
+ streamCleanupFailure ??= { error: cleanupError };
190
+ console.error("Failed to clean up errored stream:", cleanupError);
191
+ }
182
192
  return streamTextArgs.onError?.(error);
183
193
  },
184
194
  onAbort: async (event) => {
195
+ const providerTriggeredAbort =
196
+ firstStreamError !== undefined && !args.abortSignal?.aborted;
197
+ if (providerTriggeredAbort) return;
185
198
  const reason = args.abortSignal?.reason
186
199
  ? errorToString(args.abortSignal.reason)
187
200
  : "streamText aborted";
188
- await runAbortCleanup({
201
+ await runStreamCleanup({
189
202
  failCall: () => call.fail(reason),
190
203
  failStreamer: async () => streamer?.fail(reason),
191
204
  onAbort: () => streamTextArgs.onAbort?.(event),
@@ -224,13 +237,17 @@ export async function streamText<
224
237
  // returnImmediately path: streamText is about to return without
225
238
  // awaiting consumption, so the deferred-save block below won't
226
239
  // see this step. Save inline now (issue #265).
227
- const finishStreamId = await streamer.getOrCreateStreamId();
228
- await call.save(
229
- { step, responseMessages: responseMessagesForStep(step) },
230
- false,
231
- finishStreamId,
232
- );
233
- initialResponseMessagesSaved = true;
240
+ const finishStreamId = await streamer.getOrCreateStreamId({
241
+ ifAborted: "returnUndefined",
242
+ });
243
+ if (finishStreamId) {
244
+ await call.save(
245
+ { step, responseMessages: responseMessagesForStep(step) },
246
+ false,
247
+ finishStreamId,
248
+ );
249
+ initialResponseMessagesSaved = true;
250
+ }
234
251
  }
235
252
  } else {
236
253
  await call.save(
@@ -255,8 +272,11 @@ export async function streamText<
255
272
  // If the stream errored (e.g. onStepFinish threw), the DeltaStreamer's
256
273
  // finish() was never called, leaving the streaming message stuck in
257
274
  // "streaming" state. Clean it up by marking it as aborted.
258
- await streamer?.fail(e instanceof Error ? e.message : String(e));
259
- // Save the deferred final step if it was already generated but not yet persisted
275
+ try {
276
+ await streamer?.fail(errorToString(e));
277
+ } catch (cleanupError) {
278
+ streamCleanupFailure ??= { error: cleanupError };
279
+ }
260
280
  if (pendingFinalStep) {
261
281
  try {
262
282
  await call.save(pendingFinalStep, false);
@@ -269,10 +289,17 @@ export async function streamText<
269
289
  }
270
290
  }
271
291
 
292
+ if (streamCleanupFailure) throw streamCleanupFailure.error;
293
+
272
294
  // If we deferred the final step save, do it now with atomic stream finish.
273
295
  if (pendingFinalStep && streamer) {
274
- const finishStreamId = await streamer.getOrCreateStreamId();
275
- await call.save(pendingFinalStep, false, finishStreamId);
296
+ const finishStreamId = await streamer.getOrCreateStreamId({
297
+ ifAborted: "returnUndefined",
298
+ });
299
+ if (finishStreamId) {
300
+ await call.save(pendingFinalStep, false, finishStreamId);
301
+ }
302
+ pendingFinalStep = undefined;
276
303
  }
277
304
  const metadata: GenerationOutputMetadata = {
278
305
  promptMessageId,
@@ -5,10 +5,7 @@ import type { GenericSchema, SchemaDefinition } from "convex/server";
5
5
  import { streamText } from "ai";
6
6
  import { components, initConvexTest } from "./setup.test.js";
7
7
  import { mockModel } from "./mockModel.js";
8
- import {
9
- compressUIMessageChunks,
10
- DeltaStreamer,
11
- } from "./streaming.js";
8
+ import { compressUIMessageChunks, DeltaStreamer } from "./streaming.js";
12
9
  import { getParts } from "../deltas.js";
13
10
  import type { TestConvex } from "convex-test";
14
11
 
@@ -200,10 +197,71 @@ describe("DeltaStreamer", () => {
200
197
  expect(streamer.abortController.signal.aborted).toBe(true);
201
198
  await streamer.addParts(["ignored"]);
202
199
  expect(streamer.streamId).toBeUndefined();
200
+ await expect(
201
+ streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
202
+ ).resolves.toBeUndefined();
203
203
  await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
204
204
  "Cannot create a stream after it has been aborted",
205
205
  );
206
+ await expect(streamer.getStreamId()).rejects.toThrow(
207
+ "Cannot create a stream after it has been aborted",
208
+ );
209
+ });
210
+ });
211
+
212
+ test("preserves the public throwing behavior after an existing stream aborts", async () => {
213
+ await t.run(async (ctx) => {
214
+ const streamer = new DeltaStreamer<string>(
215
+ components.agent,
216
+ ctx,
217
+ { ...defaultTestOptions },
218
+ { ...testMetadata, threadId },
219
+ );
220
+ const streamId = await streamer.getStreamId();
221
+
222
+ await streamer.fail("provider error");
223
+
224
+ expect(streamer.streamId).toBe(streamId);
225
+ await expect(streamer.getStreamId()).rejects.toThrow(
226
+ "Cannot create a stream after it has been aborted",
227
+ );
228
+ await expect(streamer.getOrCreateStreamId()).rejects.toThrow(
229
+ "Cannot create a stream after it has been aborted",
230
+ );
231
+ await expect(
232
+ streamer.getOrCreateStreamId({ ifAborted: "returnUndefined" }),
233
+ ).resolves.toBeUndefined();
234
+ });
235
+ });
236
+
237
+ test("does not return an ID when abort wins during stream creation", async () => {
238
+ let resolveCreate!: (streamId: string) => void;
239
+ const creatingStream = new Promise<string>((resolve) => {
240
+ resolveCreate = resolve;
241
+ });
242
+ const runMutation = vi
243
+ .fn()
244
+ .mockImplementationOnce(() => creatingStream)
245
+ .mockResolvedValueOnce(true);
246
+ const streamer = new DeltaStreamer<string>(
247
+ components.agent,
248
+ { runMutation } as unknown as MutationCtx,
249
+ { ...defaultTestOptions },
250
+ { ...testMetadata, threadId },
251
+ );
252
+
253
+ const getting = streamer.getOrCreateStreamId({
254
+ ifAborted: "returnUndefined",
206
255
  });
256
+ const failing = streamer.fail("provider error");
257
+ resolveCreate("stream-1");
258
+
259
+ await failing;
260
+ await expect(getting).resolves.toBeUndefined();
261
+ expect(streamer.streamId).toBe("stream-1");
262
+ await expect(streamer.getStreamId()).rejects.toThrow(
263
+ "Cannot create a stream after it has been aborted",
264
+ );
207
265
  });
208
266
 
209
267
  test("shares signal and fail cleanup while stream creation is in flight", async () => {
@@ -249,11 +307,57 @@ describe("DeltaStreamer", () => {
249
307
  );
250
308
  });
251
309
 
310
+ test("waits for signal cleanup when the source throws", async () => {
311
+ let resolveDelta!: (value: boolean) => void;
312
+ const deltaWrite = new Promise<boolean>((resolve) => {
313
+ resolveDelta = resolve;
314
+ });
315
+ const runMutation = vi
316
+ .fn()
317
+ .mockResolvedValueOnce("stream-1")
318
+ .mockImplementationOnce(() => deltaWrite)
319
+ .mockResolvedValueOnce(undefined);
320
+ const abortController = new AbortController();
321
+ const streamer = new DeltaStreamer<string>(
322
+ components.agent,
323
+ { runMutation } as unknown as MutationCtx,
324
+ { ...defaultTestOptions, abortSignal: abortController.signal },
325
+ { ...testMetadata, threadId },
326
+ );
327
+ const sourceError = new Error("provider aborted");
328
+ const source = {
329
+ async *[Symbol.asyncIterator]() {
330
+ yield "chunk";
331
+ abortController.abort();
332
+ throw sourceError;
333
+ },
334
+ } as unknown as Parameters<typeof streamer.consumeStream>[0];
335
+
336
+ const consuming = streamer.consumeStream(source);
337
+ let settled = false;
338
+ void consuming.catch(() => {
339
+ settled = true;
340
+ });
341
+ await vi.waitFor(() => expect(runMutation).toHaveBeenCalledTimes(2));
342
+ expect(settled).toBe(false);
343
+
344
+ resolveDelta(true);
345
+ await expect(consuming).rejects.toBe(sourceError);
346
+ expect(runMutation).toHaveBeenNthCalledWith(
347
+ 3,
348
+ components.agent.streams.abort,
349
+ { streamId: "stream-1", reason: "abortSignal" },
350
+ );
351
+ });
352
+
252
353
  test("aborts the component stream when a delta write fails", async () => {
354
+ const deltaFailure = {
355
+ error: { code: "provider_disconnected", message: "Provider dropped" },
356
+ };
253
357
  const runMutation = vi
254
358
  .fn()
255
359
  .mockResolvedValueOnce("stream-1")
256
- .mockRejectedValueOnce(new Error("delta failed"))
360
+ .mockRejectedValueOnce(deltaFailure)
257
361
  .mockResolvedValueOnce(undefined);
258
362
  let abortReason: string | undefined;
259
363
  const streamer = new DeltaStreamer<string>(
@@ -271,11 +375,42 @@ describe("DeltaStreamer", () => {
271
375
  await streamer.addParts(["A"]);
272
376
  await streamer.finish();
273
377
 
274
- expect(abortReason).toBe("delta failed");
378
+ expect(abortReason).toBe("provider_disconnected: Provider dropped");
275
379
  expect(runMutation).toHaveBeenNthCalledWith(
276
380
  3,
277
381
  components.agent.streams.abort,
278
- { streamId: "stream-1", reason: "delta failed" },
382
+ {
383
+ streamId: "stream-1",
384
+ reason: "provider_disconnected: Provider dropped",
385
+ },
386
+ );
387
+ });
388
+
389
+ test("aborts the component stream when file materialization fails", async () => {
390
+ const materializationFailure = new Error("storage failed");
391
+ const runMutation = vi
392
+ .fn()
393
+ .mockResolvedValueOnce("stream-1")
394
+ .mockResolvedValueOnce(undefined);
395
+ const streamer = new DeltaStreamer<string>(
396
+ components.agent,
397
+ { runMutation } as unknown as MutationCtx,
398
+ {
399
+ ...defaultTestOptions,
400
+ onAsyncAbort: async () => {},
401
+ materialize: async () => {
402
+ throw materializationFailure;
403
+ },
404
+ },
405
+ { ...testMetadata, threadId },
406
+ );
407
+
408
+ await streamer.addParts(["A"]);
409
+ await expect(streamer.finish()).resolves.toBeUndefined();
410
+ expect(runMutation).toHaveBeenNthCalledWith(
411
+ 2,
412
+ components.agent.streams.abort,
413
+ { streamId: "stream-1", reason: "storage failed" },
279
414
  );
280
415
  });
281
416
 
@@ -8,6 +8,7 @@ import {
8
8
  type UIMessageChunk,
9
9
  } from "ai";
10
10
  import { v } from "convex/values";
11
+ import { errorToString } from "../../errors.js";
11
12
  import {
12
13
  vMessageDoc,
13
14
  vPaginationResult,
@@ -204,6 +205,12 @@ export class DeltaStreamer<T> {
204
205
  throttleMs: number;
205
206
  onAsyncAbort: (reason: string) => Promise<void>;
206
207
  compress: ((parts: T[]) => T[]) | null;
208
+ materialize:
209
+ | ((parts: T[]) => Promise<{
210
+ parts: T[];
211
+ fileRefs: Array<{ url: string; fileId: string }>;
212
+ }>)
213
+ | null;
207
214
  };
208
215
  #nextParts: T[] = [];
209
216
  #latestWrite: number = 0;
@@ -223,6 +230,10 @@ export class DeltaStreamer<T> {
223
230
  onAsyncAbort: (reason: string) => Promise<void>;
224
231
  abortSignal: AbortSignal | undefined;
225
232
  compress: ((parts: T[]) => T[]) | null;
233
+ materialize?: (parts: T[]) => Promise<{
234
+ parts: T[];
235
+ fileRefs: Array<{ url: string; fileId: string }>;
236
+ }>;
226
237
  },
227
238
  public readonly metadata: {
228
239
  threadId: string;
@@ -240,6 +251,7 @@ export class DeltaStreamer<T> {
240
251
  throttleMs: config.throttleMs ?? DEFAULT_STREAMING_OPTIONS.throttleMs,
241
252
  onAsyncAbort: config.onAsyncAbort,
242
253
  compress: config.compress,
254
+ materialize: config.materialize ?? null,
243
255
  };
244
256
  this.#nextParts = [];
245
257
  this.abortController = new AbortController();
@@ -289,7 +301,10 @@ export class DeltaStreamer<T> {
289
301
  if (this.#finishedExternally) {
290
302
  return;
291
303
  }
292
- await this.getStreamId();
304
+ const streamId = await this.getOrCreateStreamId({
305
+ ifAborted: "returnUndefined",
306
+ });
307
+ if (!streamId) return;
293
308
  this.#nextParts.push(...parts);
294
309
  if (
295
310
  !this.#ongoingWrite &&
@@ -300,8 +315,16 @@ export class DeltaStreamer<T> {
300
315
  }
301
316
 
302
317
  public async consumeStream(stream: AsyncIterableStream<T>) {
303
- for await (const chunk of stream) {
304
- await this.addParts([chunk]);
318
+ try {
319
+ for await (const chunk of stream) {
320
+ await this.addParts([chunk]);
321
+ }
322
+ } catch (error) {
323
+ // A provider can throw while responding to an abort. Join the durable
324
+ // abort transition here, outside the active delta writer, before
325
+ // preserving the provider error for the caller.
326
+ await this.#abort(errorToString(error)).catch(() => {});
327
+ throw error;
305
328
  }
306
329
  // Skip finish if it will be handled externally (atomically with message save)
307
330
  // or if the stream was aborted (e.g., due to a failed delta write).
@@ -327,29 +350,45 @@ export class DeltaStreamer<T> {
327
350
  * Get the stream ID, waiting for it to be created if necessary.
328
351
  * Useful for passing to addMessages for atomic finish.
329
352
  */
330
- public async getOrCreateStreamId(): Promise<string> {
331
- return this.getStreamId();
353
+ public async getOrCreateStreamId(): Promise<string>;
354
+ public async getOrCreateStreamId(options: {
355
+ ifAborted: "returnUndefined";
356
+ }): Promise<string | undefined>;
357
+ public async getOrCreateStreamId(options?: {
358
+ ifAborted?: "returnUndefined";
359
+ }): Promise<string | undefined> {
360
+ if (options?.ifAborted !== "returnUndefined") {
361
+ return this.getStreamId();
362
+ }
363
+ if (this.abortController.signal.aborted) {
364
+ await this.#abortPromise;
365
+ return undefined;
366
+ }
367
+ const streamId = await this.getStreamId();
368
+ if (this.abortController.signal.aborted) {
369
+ await this.#abortPromise;
370
+ return undefined;
371
+ }
372
+ return streamId;
332
373
  }
333
374
 
334
375
  async #sendDelta() {
335
376
  if (this.abortController.signal.aborted) {
336
377
  return;
337
378
  }
338
- const delta = this.#createDelta();
339
- if (!delta) {
340
- return;
341
- }
342
- this.#latestWrite = Date.now();
343
379
  let success: boolean;
344
380
  try {
381
+ const delta = await this.#createDelta();
382
+ if (!delta) {
383
+ return;
384
+ }
385
+ this.#latestWrite = Date.now();
345
386
  success = await this.ctx.runMutation(
346
387
  this.component.streams.addDelta,
347
388
  delta,
348
389
  );
349
390
  } catch (e) {
350
- await this.#abortDelta(
351
- e instanceof Error ? e.message : "unknown error",
352
- );
391
+ await this.#abortDelta(errorToString(e));
353
392
  return;
354
393
  }
355
394
  if (!success) {
@@ -375,21 +414,36 @@ export class DeltaStreamer<T> {
375
414
  }
376
415
  }
377
416
 
378
- #createDelta(): StreamDelta | undefined {
417
+ async #createDelta(): Promise<
418
+ | (StreamDelta & { fileRefs?: Array<{ url: string; fileId: string }> })
419
+ | undefined
420
+ > {
379
421
  if (this.#nextParts.length === 0) {
380
422
  return undefined;
381
423
  }
382
424
  const start = this.#cursor;
383
- const end = start + this.#nextParts.length;
425
+ const pendingParts = this.#nextParts;
426
+ const end = start + pendingParts.length;
384
427
  this.#cursor = end;
385
- const parts = this.config.compress
386
- ? this.config.compress(this.#nextParts)
387
- : this.#nextParts;
388
428
  this.#nextParts = [];
429
+ const materialized = this.config.materialize
430
+ ? await this.config.materialize(pendingParts)
431
+ : { parts: pendingParts, fileRefs: [] };
432
+ const parts = this.config.compress
433
+ ? this.config.compress(materialized.parts)
434
+ : materialized.parts;
389
435
  if (!this.streamId) {
390
436
  throw new Error("Creating a delta before the stream is created");
391
437
  }
392
- return { streamId: this.streamId, start, end, parts };
438
+ return {
439
+ streamId: this.streamId,
440
+ start,
441
+ end,
442
+ parts,
443
+ ...(materialized.fileRefs.length > 0
444
+ ? { fileRefs: materialized.fileRefs }
445
+ : {}),
446
+ };
393
447
  }
394
448
 
395
449
  public async finish() {
@@ -203,6 +203,8 @@ export type ContextOptions = {
203
203
  /**
204
204
  * How many recent messages to include. These are added after the search
205
205
  * messages, and do not count against the search limit.
206
+ * If the limit splits an order, additional messages are included to complete
207
+ * that order. If it cannot be completed, the incomplete order is omitted.
206
208
  * Default: 100
207
209
  */
208
210
  recentMessages?: number;
@@ -0,0 +1,97 @@
1
+ import type { StepResult } from "ai";
2
+ import { describe, expect, test } from "vitest";
3
+ import { hasSuccessfulToolCall, willContinue } from "./utils.js";
4
+
5
+ // Minimal StepResult builder — only the fields willContinue and
6
+ // hasSuccessfulToolCall actually read. Loosely typed on purpose so test
7
+ // fixtures can be terse; cast at the boundary.
8
+ type StepFixture = {
9
+ finishReason?: string;
10
+ content?: Array<{ type: string; toolName?: string }>;
11
+ toolCalls?: Array<{ toolCallId: string; toolName: string }>;
12
+ toolResults?: Array<{ toolCallId: string; toolName: string }>;
13
+ };
14
+
15
+ function makeStep(partial: StepFixture): StepResult<any> {
16
+ return {
17
+ finishReason: "tool-calls",
18
+ content: [],
19
+ toolCalls: [],
20
+ toolResults: [],
21
+ ...partial,
22
+ } as unknown as StepResult<any>;
23
+ }
24
+
25
+ describe("hasSuccessfulToolCall", () => {
26
+ test("returns true when last step has a tool-result for the named tool", () => {
27
+ const step = makeStep({
28
+ content: [{ type: "tool-result", toolName: "search" }],
29
+ });
30
+ expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(true);
31
+ });
32
+
33
+ test("returns false when only a tool-error is present for the named tool", () => {
34
+ const step = makeStep({
35
+ content: [{ type: "tool-error", toolName: "search" }],
36
+ });
37
+ expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(false);
38
+ });
39
+
40
+ test("returns false when the matching tool name is missing", () => {
41
+ const step = makeStep({
42
+ content: [{ type: "tool-result", toolName: "other" }],
43
+ });
44
+ expect(hasSuccessfulToolCall("search")({ steps: [step] })).toBe(false);
45
+ });
46
+
47
+ test("only inspects the last step", () => {
48
+ const earlier = makeStep({
49
+ content: [{ type: "tool-result", toolName: "search" }],
50
+ });
51
+ const last = makeStep({
52
+ content: [{ type: "tool-error", toolName: "search" }],
53
+ });
54
+ expect(hasSuccessfulToolCall("search")({ steps: [earlier, last] })).toBe(
55
+ false,
56
+ );
57
+ });
58
+
59
+ test("returns false when steps is empty", () => {
60
+ expect(hasSuccessfulToolCall("search")({ steps: [] })).toBe(false);
61
+ });
62
+ });
63
+
64
+ describe("willContinue", () => {
65
+ test("does not stop when a tool-error fills in for a missing tool-result", async () => {
66
+ // Two tool calls; one returns a result, the other errors.
67
+ const step = makeStep({
68
+ toolCalls: [
69
+ { toolCallId: "1", toolName: "a" },
70
+ { toolCallId: "2", toolName: "b" },
71
+ ],
72
+ toolResults: [{ toolCallId: "1", toolName: "a" }],
73
+ content: [
74
+ { type: "tool-result", toolName: "a" },
75
+ { type: "tool-error", toolName: "b" },
76
+ ],
77
+ });
78
+ // No stopWhen → returns false (no further stop conditions). The point
79
+ // is the function progresses past the early `toolCalls > completed`
80
+ // bail; pre-fix it returned early because tool-error wasn't counted.
81
+ expect(await willContinue([step], undefined)).toBe(false);
82
+ });
83
+
84
+ test("stops when a tool call has neither a result nor an error yet", async () => {
85
+ const step = makeStep({
86
+ toolCalls: [{ toolCallId: "1", toolName: "a" }],
87
+ toolResults: [],
88
+ content: [],
89
+ });
90
+ expect(await willContinue([step], undefined)).toBe(false);
91
+ });
92
+
93
+ test("stops when finishReason is not tool-calls", async () => {
94
+ const step = makeStep({ finishReason: "stop" });
95
+ expect(await willContinue([step], undefined)).toBe(false);
96
+ });
97
+ });
@@ -1,17 +1,23 @@
1
1
  import type { Context } from "@ai-sdk/provider-utils";
2
2
  import type { StepResult, StopCondition, ToolSet } from "ai";
3
3
 
4
+ export { errorToString } from "../../errors.js";
5
+
4
6
  /**
5
- * A stop condition that only matches tool calls which completed
6
- * successfully (i.e. produced a `tool-result`, not a `tool-error`).
7
+ * A stop condition that only matches tool calls of the given name which
8
+ * completed successfully i.e. produced a `tool-result` content part.
9
+ * Failed tool calls (which surface as `tool-error` parts under AI SDK v6)
10
+ * do not match.
7
11
  *
8
- * Use this instead of the AI SDK's `hasToolCall` when you want the
9
- * agent to retry on argument validation failures rather than stopping.
12
+ * Use this instead of the AI SDK's `hasToolCall` when you want the agent
13
+ * to retry on argument-validation or runtime tool failures rather than
14
+ * stopping. Evaluated only against the last step (consistent with how
15
+ * `stopWhen` is applied after each step).
10
16
  */
11
17
  export function hasSuccessfulToolCall(toolName: string): StopCondition<any> {
12
18
  return ({ steps }) =>
13
- steps[steps.length - 1]?.toolResults?.some(
14
- (result) => result.toolName === toolName,
19
+ steps[steps.length - 1]?.content?.some(
20
+ (p) => p.type === "tool-result" && p.toolName === toolName,
15
21
  ) ?? false;
16
22
  }
17
23
 
@@ -31,12 +37,13 @@ export async function willContinue<
31
37
  if (step.finishReason !== "tool-calls") return false;
32
38
  // Count both successful results and errors as completed outputs.
33
39
  // Failed tool calls are represented as tool-error content parts, so only
34
- // checking toolResults misses them.
40
+ // checking toolResults misses them. The fallback to step.toolResults.length
41
+ // is for callers whose steps lack content (mocks); the optional chain is
42
+ // defensive rather than load-bearing.
35
43
  const completedOutputs =
36
44
  step.content?.filter(
37
45
  (p) => p.type === "tool-result" || p.type === "tool-error",
38
46
  ).length ?? step.toolResults.length;
39
- // we don't have a tool result, so we'll wait for more
40
47
  if (step.toolCalls.length > completedOutputs) return false;
41
48
  if (Array.isArray(stopWhen)) {
42
49
  return (await Promise.all(stopWhen.map(async (s) => s({ steps })))).every(
@@ -45,10 +52,3 @@ export async function willContinue<
45
52
  }
46
53
  return !!stopWhen && !(await stopWhen({ steps }));
47
54
  }
48
-
49
- export function errorToString(error: unknown): string {
50
- if (error instanceof Error) {
51
- return error.message;
52
- }
53
- return String(error);
54
- }