@convex-dev/agent 0.7.1 → 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 (81) 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 +10 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/messages.d.ts +340 -338
  9. package/dist/component/messages.d.ts.map +1 -1
  10. package/dist/component/messages.js +74 -14
  11. package/dist/component/messages.js.map +1 -1
  12. package/dist/component/schema.d.ts +726 -622
  13. package/dist/component/schema.d.ts.map +1 -1
  14. package/dist/component/streams.d.ts +6 -1
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +14 -13
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/component/vector/index.d.ts +9 -9
  19. package/dist/component/vector/index.d.ts.map +1 -1
  20. package/dist/component/vector/tables.d.ts +5 -5
  21. package/dist/component/vector/tables.d.ts.map +1 -1
  22. package/dist/component/vector/tables.js +1 -1
  23. package/dist/component/vector/tables.js.map +1 -1
  24. package/dist/errors.d.ts +2 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +106 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/validators.d.ts +1952 -1952
  29. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  30. package/dist/vercel/client/messages.d.ts +50 -32
  31. package/dist/vercel/client/messages.d.ts.map +1 -1
  32. package/dist/vercel/client/messages.js +2 -0
  33. package/dist/vercel/client/messages.js.map +1 -1
  34. package/dist/vercel/client/search.d.ts +475 -32
  35. package/dist/vercel/client/search.d.ts.map +1 -1
  36. package/dist/vercel/client/search.js +34 -9
  37. package/dist/vercel/client/search.js.map +1 -1
  38. package/dist/vercel/client/start.d.ts.map +1 -1
  39. package/dist/vercel/client/start.js +1 -0
  40. package/dist/vercel/client/start.js.map +1 -1
  41. package/dist/vercel/client/streamText.d.ts +1 -2
  42. package/dist/vercel/client/streamText.d.ts.map +1 -1
  43. package/dist/vercel/client/streamText.js +47 -17
  44. package/dist/vercel/client/streamText.js.map +1 -1
  45. package/dist/vercel/client/streaming.d.ts +304 -301
  46. package/dist/vercel/client/streaming.d.ts.map +1 -1
  47. package/dist/vercel/client/streaming.js +22 -9
  48. package/dist/vercel/client/streaming.js.map +1 -1
  49. package/dist/vercel/client/types.d.ts +2 -0
  50. package/dist/vercel/client/types.d.ts.map +1 -1
  51. package/dist/vercel/client/types.js.map +1 -1
  52. package/dist/vercel/client/utils.d.ts +9 -5
  53. package/dist/vercel/client/utils.d.ts.map +1 -1
  54. package/dist/vercel/client/utils.js +13 -13
  55. package/dist/vercel/client/utils.js.map +1 -1
  56. package/dist/vercel/index.d.ts +91 -90
  57. package/dist/vercel/index.d.ts.map +1 -1
  58. package/dist/vercel/index.js +2 -0
  59. package/dist/vercel/index.js.map +1 -1
  60. package/package.json +16 -9
  61. package/src/client/messages.ts +12 -0
  62. package/src/component/_generated/component.ts +10 -0
  63. package/src/component/messages.test.ts +404 -0
  64. package/src/component/messages.ts +94 -12
  65. package/src/component/streams.ts +20 -15
  66. package/src/component/vector/tables.ts +1 -1
  67. package/src/errors.test.ts +76 -0
  68. package/src/errors.ts +113 -0
  69. package/src/vercel/client/index.test.ts +47 -0
  70. package/src/vercel/client/messages.ts +20 -0
  71. package/src/vercel/client/search.test.ts +183 -8
  72. package/src/vercel/client/search.ts +46 -12
  73. package/src/vercel/client/start.ts +1 -0
  74. package/src/vercel/client/streamText.test.ts +113 -17
  75. package/src/vercel/client/streamText.ts +52 -28
  76. package/src/vercel/client/streaming.test.ts +71 -7
  77. package/src/vercel/client/streaming.ts +27 -7
  78. package/src/vercel/client/types.ts +2 -0
  79. package/src/vercel/client/utils.test.ts +97 -0
  80. package/src/vercel/client/utils.ts +15 -15
  81. package/src/vercel/index.ts +2 -0
@@ -386,6 +386,7 @@ export async function startGeneration<
386
386
  threadId,
387
387
  agentName: opts.agentName,
388
388
  promptMessageId,
389
+ abandonIfPromptMissing: true,
389
390
  pendingMessageId,
390
391
  messages: serialized.messages,
391
392
  embeddings,
@@ -11,13 +11,27 @@ import {
11
11
  import { v } from "convex/values";
12
12
  import { components, initConvexTest } from "./setup.test.js";
13
13
  import { mockModel } from "./mockModel.js";
14
- import { runAbortCleanup } from "./streamText.js";
14
+ import { runStreamCleanup } from "./streamText.js";
15
+ import { errorToString } from "./utils.js";
15
16
 
16
17
  const schema = defineSchema({});
17
18
  type DataModel = DataModelFromSchemaDefinition<typeof schema>;
18
19
  const action = actionGeneric as ActionBuilder<DataModel, "public">;
19
20
 
20
21
  const FINAL_TEXT = "Hello from the model";
22
+ const PROVIDER_FAILURE_TEXT = "Mock provider failure";
23
+ const CLEANUP_FAILURE_TEXT = "finalizeMessage rejected";
24
+
25
+ function hasKeys(
26
+ value: unknown,
27
+ keys: string[],
28
+ ): value is Record<string, unknown> {
29
+ return (
30
+ value !== null &&
31
+ typeof value === "object" &&
32
+ keys.every((key) => key in value)
33
+ );
34
+ }
21
35
 
22
36
  const agent = new Agent(components.agent, {
23
37
  name: "stream-test",
@@ -34,6 +48,14 @@ const emptyAgent = new Agent(components.agent, {
34
48
  }),
35
49
  });
36
50
 
51
+ const failingAgent = new Agent(components.agent, {
52
+ name: "failing-stream-test",
53
+ languageModel: mockModel({
54
+ content: [{ type: "text", text: "partial response" }],
55
+ fail: { error: PROVIDER_FAILURE_TEXT },
56
+ }),
57
+ });
58
+
37
59
  // Action that exercises streamText with saveStreamDeltas.returnImmediately=true.
38
60
  // It consumes the stream after streamText returns, simulating the HTTP response
39
61
  // path described in issue #265.
@@ -91,11 +113,49 @@ export const streamTextEmptyReturnImmediately = action({
91
113
  },
92
114
  });
93
115
 
116
+ export const streamTextCleanupFailure = action({
117
+ args: { threadId: v.string() },
118
+ handler: async (ctx, { threadId }) => {
119
+ const providerErrors: string[] = [];
120
+ let aborts = 0;
121
+ const failingCtx = {
122
+ ...ctx,
123
+ runMutation: (async (reference, args) => {
124
+ if (hasKeys(args, ["messageId", "result"])) {
125
+ throw new Error(CLEANUP_FAILURE_TEXT);
126
+ }
127
+ return ctx.runMutation(reference, args);
128
+ }) as typeof ctx.runMutation,
129
+ };
130
+ let caught: string | undefined;
131
+ try {
132
+ await failingAgent.streamText(
133
+ failingCtx,
134
+ { threadId },
135
+ {
136
+ prompt: "Test",
137
+ onError: ({ error }) => {
138
+ providerErrors.push(errorToString(error));
139
+ },
140
+ onAbort: () => {
141
+ aborts += 1;
142
+ },
143
+ },
144
+ { saveStreamDeltas: { chunking: "word", throttleMs: 0 } },
145
+ );
146
+ } catch (error) {
147
+ caught = errorToString(error);
148
+ }
149
+ return { providerErrors, aborts, caught };
150
+ },
151
+ });
152
+
94
153
  const testApi: ApiFromModules<{
95
154
  fns: {
96
155
  streamTextReturnImmediately: typeof streamTextReturnImmediately;
97
156
  streamTextEmptyAwaited: typeof streamTextEmptyAwaited;
98
157
  streamTextEmptyReturnImmediately: typeof streamTextEmptyReturnImmediately;
158
+ streamTextCleanupFailure: typeof streamTextCleanupFailure;
99
159
  };
100
160
  }>["fns"] = anyApi["streamText.test"] as any;
101
161
 
@@ -147,28 +207,64 @@ describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () =
147
207
  });
148
208
 
149
209
  describe("streamText abort cleanup", () => {
150
- test("attempts every cleanup and rethrows the first internal failure", async () => {
210
+ test("finishes durable cleanup before invoking onAbort", async () => {
151
211
  const calls: string[] = [];
152
- const firstFailure = new Error("failed pending message cleanup");
212
+ let resolveStreamer!: () => void;
213
+ const syncFailure = new Error("synchronous pending message cleanup");
153
214
 
154
- await expect(
155
- runAbortCleanup({
156
- failCall: async () => {
157
- calls.push("call.fail");
158
- throw firstFailure;
159
- },
160
- failStreamer: async () => {
215
+ const cleanup = runStreamCleanup({
216
+ failCall: () => {
217
+ calls.push("call.fail");
218
+ throw syncFailure;
219
+ },
220
+ failStreamer: () =>
221
+ new Promise<void>((resolve) => {
161
222
  calls.push("streamer.fail");
162
- throw new Error("failed stream cleanup");
163
- },
164
- onAbort: () => {
165
- calls.push("user.onAbort");
166
- },
167
- }),
168
- ).rejects.toBe(firstFailure);
223
+ resolveStreamer = resolve;
224
+ }),
225
+ onAbort: () => {
226
+ calls.push("user.onAbort");
227
+ },
228
+ });
229
+
230
+ await Promise.resolve();
231
+ expect(calls).toEqual(["call.fail", "streamer.fail"]);
232
+ resolveStreamer();
233
+ await expect(cleanup).rejects.toBe(syncFailure);
169
234
 
170
235
  expect(calls).toEqual(["call.fail", "streamer.fail", "user.onAbort"]);
171
236
  });
237
+
238
+ test("surfaces a cleanup failure without hiding the provider error", async () => {
239
+ const t = initConvexTest(schema);
240
+ const threadId = await t.run(async (ctx) =>
241
+ createThread(ctx, components.agent, { userId: "u1" }),
242
+ );
243
+
244
+ const { providerErrors, aborts, caught } = await t.action(
245
+ testApi.streamTextCleanupFailure,
246
+ { threadId },
247
+ );
248
+
249
+ expect(providerErrors).toEqual([PROVIDER_FAILURE_TEXT]);
250
+ expect(aborts).toBe(0);
251
+ expect(caught).toBe(CLEANUP_FAILURE_TEXT);
252
+
253
+ const streaming = await t.run(async (ctx) =>
254
+ ctx.runQuery(components.agent.streams.list, {
255
+ threadId,
256
+ statuses: ["streaming"],
257
+ }),
258
+ );
259
+ const aborted = await t.run(async (ctx) =>
260
+ ctx.runQuery(components.agent.streams.list, {
261
+ threadId,
262
+ statuses: ["aborted"],
263
+ }),
264
+ );
265
+ expect(streaming).toHaveLength(0);
266
+ expect(aborted).toHaveLength(1);
267
+ });
172
268
  });
173
269
 
174
270
  describe("streamText with an empty final step (issue #274)", () => {
@@ -27,19 +27,22 @@ import { getModelName, getProviderName } from "../../shared.js";
27
27
  import { errorToString, willContinue } from "./utils.js";
28
28
  import { materializeUIMessageChunkFiles } from "../fileMaterialization.js";
29
29
 
30
- /** Finish every abort cleanup path before surfacing an internal failure. */
31
- export async function runAbortCleanup(cleanup: {
30
+ export async function runStreamCleanup(cleanup: {
32
31
  failCall: () => Promise<void>;
33
32
  failStreamer: () => Promise<void>;
34
33
  onAbort?: () => PromiseLike<void> | void;
35
34
  }): Promise<void> {
36
35
  const results = await Promise.allSettled([
37
- cleanup.failCall(),
38
- cleanup.failStreamer(),
36
+ Promise.resolve().then(() => cleanup.failCall()),
37
+ Promise.resolve().then(() => cleanup.failStreamer()),
39
38
  ]);
40
- await cleanup.onAbort?.();
41
- const failure = results.find((result) => result.status === "rejected");
42
- 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;
43
46
  }
44
47
 
45
48
  /**
@@ -103,15 +106,11 @@ export async function streamText<
103
106
  Tools,
104
107
  object,
105
108
  RUNTIME_CONTEXT
106
- >(
107
- ctx,
108
- component,
109
- streamTextArgs,
110
- options,
111
- "streamText",
112
- );
109
+ >(ctx, component, streamTextArgs, options, "streamText");
113
110
 
114
111
  const steps: StepResult<Tools, RUNTIME_CONTEXT>[] = [];
112
+ let firstStreamError: string | undefined;
113
+ let streamCleanupFailure: { error: unknown } | undefined;
115
114
  let initialResponseMessages: ModelMessage[] = [];
116
115
  let initialResponseMessagesSaved = false;
117
116
  const responseMessagesForStep = (
@@ -180,15 +179,26 @@ export async function streamText<
180
179
  ),
181
180
  onError: async (error) => {
182
181
  console.error("onError", error);
183
- await call.fail(errorToString(error.error));
184
- 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
+ }
185
192
  return streamTextArgs.onError?.(error);
186
193
  },
187
194
  onAbort: async (event) => {
195
+ const providerTriggeredAbort =
196
+ firstStreamError !== undefined && !args.abortSignal?.aborted;
197
+ if (providerTriggeredAbort) return;
188
198
  const reason = args.abortSignal?.reason
189
199
  ? errorToString(args.abortSignal.reason)
190
200
  : "streamText aborted";
191
- await runAbortCleanup({
201
+ await runStreamCleanup({
192
202
  failCall: () => call.fail(reason),
193
203
  failStreamer: async () => streamer?.fail(reason),
194
204
  onAbort: () => streamTextArgs.onAbort?.(event),
@@ -227,13 +237,17 @@ export async function streamText<
227
237
  // returnImmediately path: streamText is about to return without
228
238
  // awaiting consumption, so the deferred-save block below won't
229
239
  // see this step. Save inline now (issue #265).
230
- const finishStreamId = await streamer.getOrCreateStreamId();
231
- await call.save(
232
- { step, responseMessages: responseMessagesForStep(step) },
233
- false,
234
- finishStreamId,
235
- );
236
- 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
+ }
237
251
  }
238
252
  } else {
239
253
  await call.save(
@@ -258,8 +272,11 @@ export async function streamText<
258
272
  // If the stream errored (e.g. onStepFinish threw), the DeltaStreamer's
259
273
  // finish() was never called, leaving the streaming message stuck in
260
274
  // "streaming" state. Clean it up by marking it as aborted.
261
- await streamer?.fail(e instanceof Error ? e.message : String(e));
262
- // 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
+ }
263
280
  if (pendingFinalStep) {
264
281
  try {
265
282
  await call.save(pendingFinalStep, false);
@@ -272,10 +289,17 @@ export async function streamText<
272
289
  }
273
290
  }
274
291
 
292
+ if (streamCleanupFailure) throw streamCleanupFailure.error;
293
+
275
294
  // If we deferred the final step save, do it now with atomic stream finish.
276
295
  if (pendingFinalStep && streamer) {
277
- const finishStreamId = await streamer.getOrCreateStreamId();
278
- 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;
279
303
  }
280
304
  const metadata: GenerationOutputMetadata = {
281
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,12 +197,73 @@ 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();
206
234
  });
207
235
  });
208
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",
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
+ );
265
+ });
266
+
209
267
  test("shares signal and fail cleanup while stream creation is in flight", async () => {
210
268
  let resolveCreate!: (streamId: string) => void;
211
269
  const creatingStream = new Promise<string>((resolve) => {
@@ -293,10 +351,13 @@ describe("DeltaStreamer", () => {
293
351
  });
294
352
 
295
353
  test("aborts the component stream when a delta write fails", async () => {
354
+ const deltaFailure = {
355
+ error: { code: "provider_disconnected", message: "Provider dropped" },
356
+ };
296
357
  const runMutation = vi
297
358
  .fn()
298
359
  .mockResolvedValueOnce("stream-1")
299
- .mockRejectedValueOnce(new Error("delta failed"))
360
+ .mockRejectedValueOnce(deltaFailure)
300
361
  .mockResolvedValueOnce(undefined);
301
362
  let abortReason: string | undefined;
302
363
  const streamer = new DeltaStreamer<string>(
@@ -314,11 +375,14 @@ describe("DeltaStreamer", () => {
314
375
  await streamer.addParts(["A"]);
315
376
  await streamer.finish();
316
377
 
317
- expect(abortReason).toBe("delta failed");
378
+ expect(abortReason).toBe("provider_disconnected: Provider dropped");
318
379
  expect(runMutation).toHaveBeenNthCalledWith(
319
380
  3,
320
381
  components.agent.streams.abort,
321
- { streamId: "stream-1", reason: "delta failed" },
382
+ {
383
+ streamId: "stream-1",
384
+ reason: "provider_disconnected: Provider dropped",
385
+ },
322
386
  );
323
387
  });
324
388
 
@@ -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,
@@ -300,7 +301,10 @@ export class DeltaStreamer<T> {
300
301
  if (this.#finishedExternally) {
301
302
  return;
302
303
  }
303
- await this.getStreamId();
304
+ const streamId = await this.getOrCreateStreamId({
305
+ ifAborted: "returnUndefined",
306
+ });
307
+ if (!streamId) return;
304
308
  this.#nextParts.push(...parts);
305
309
  if (
306
310
  !this.#ongoingWrite &&
@@ -319,9 +323,7 @@ export class DeltaStreamer<T> {
319
323
  // A provider can throw while responding to an abort. Join the durable
320
324
  // abort transition here, outside the active delta writer, before
321
325
  // preserving the provider error for the caller.
322
- await this.#abort(
323
- error instanceof Error ? error.message : "stream consumption failed",
324
- ).catch(() => {});
326
+ await this.#abort(errorToString(error)).catch(() => {});
325
327
  throw error;
326
328
  }
327
329
  // Skip finish if it will be handled externally (atomically with message save)
@@ -348,8 +350,26 @@ export class DeltaStreamer<T> {
348
350
  * Get the stream ID, waiting for it to be created if necessary.
349
351
  * Useful for passing to addMessages for atomic finish.
350
352
  */
351
- public async getOrCreateStreamId(): Promise<string> {
352
- 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;
353
373
  }
354
374
 
355
375
  async #sendDelta() {
@@ -368,7 +388,7 @@ export class DeltaStreamer<T> {
368
388
  delta,
369
389
  );
370
390
  } catch (e) {
371
- await this.#abortDelta(e instanceof Error ? e.message : "unknown error");
391
+ await this.#abortDelta(errorToString(e));
372
392
  return;
373
393
  }
374
394
  if (!success) {
@@ -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
- }