@convex-dev/agent 0.7.1 → 0.7.3

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 (90) 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 -15
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/component/threads.d.ts.map +1 -1
  19. package/dist/component/threads.js +1 -0
  20. package/dist/component/threads.js.map +1 -1
  21. package/dist/component/vector/index.d.ts +9 -9
  22. package/dist/component/vector/index.d.ts.map +1 -1
  23. package/dist/component/vector/tables.d.ts +5 -5
  24. package/dist/component/vector/tables.d.ts.map +1 -1
  25. package/dist/component/vector/tables.js +1 -1
  26. package/dist/component/vector/tables.js.map +1 -1
  27. package/dist/errors.d.ts +2 -0
  28. package/dist/errors.d.ts.map +1 -0
  29. package/dist/errors.js +106 -0
  30. package/dist/errors.js.map +1 -0
  31. package/dist/validators.d.ts +1952 -1952
  32. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  33. package/dist/vercel/client/messages.d.ts +50 -32
  34. package/dist/vercel/client/messages.d.ts.map +1 -1
  35. package/dist/vercel/client/messages.js +2 -0
  36. package/dist/vercel/client/messages.js.map +1 -1
  37. package/dist/vercel/client/search.d.ts +475 -32
  38. package/dist/vercel/client/search.d.ts.map +1 -1
  39. package/dist/vercel/client/search.js +34 -9
  40. package/dist/vercel/client/search.js.map +1 -1
  41. package/dist/vercel/client/start.d.ts.map +1 -1
  42. package/dist/vercel/client/start.js +1 -0
  43. package/dist/vercel/client/start.js.map +1 -1
  44. package/dist/vercel/client/streamText.d.ts +1 -2
  45. package/dist/vercel/client/streamText.d.ts.map +1 -1
  46. package/dist/vercel/client/streamText.js +79 -22
  47. package/dist/vercel/client/streamText.js.map +1 -1
  48. package/dist/vercel/client/streaming.d.ts +326 -305
  49. package/dist/vercel/client/streaming.d.ts.map +1 -1
  50. package/dist/vercel/client/streaming.js +140 -25
  51. package/dist/vercel/client/streaming.js.map +1 -1
  52. package/dist/vercel/client/types.d.ts +2 -0
  53. package/dist/vercel/client/types.d.ts.map +1 -1
  54. package/dist/vercel/client/types.js.map +1 -1
  55. package/dist/vercel/client/utils.d.ts +9 -5
  56. package/dist/vercel/client/utils.d.ts.map +1 -1
  57. package/dist/vercel/client/utils.js +13 -13
  58. package/dist/vercel/client/utils.js.map +1 -1
  59. package/dist/vercel/index.d.ts +91 -90
  60. package/dist/vercel/index.d.ts.map +1 -1
  61. package/dist/vercel/index.js +2 -0
  62. package/dist/vercel/index.js.map +1 -1
  63. package/package.json +16 -9
  64. package/src/client/messages.ts +12 -0
  65. package/src/component/_generated/component.ts +10 -0
  66. package/src/component/messages.test.ts +404 -0
  67. package/src/component/messages.ts +94 -12
  68. package/src/component/streams.test.ts +54 -1
  69. package/src/component/streams.ts +20 -17
  70. package/src/component/threads.test.ts +39 -0
  71. package/src/component/threads.ts +1 -0
  72. package/src/component/vector/tables.ts +1 -1
  73. package/src/errors.test.ts +76 -0
  74. package/src/errors.ts +113 -0
  75. package/src/vercel/client/deltaFlush.test.ts +106 -0
  76. package/src/vercel/client/index.test.ts +47 -0
  77. package/src/vercel/client/messages.ts +20 -0
  78. package/src/vercel/client/search.test.ts +183 -8
  79. package/src/vercel/client/search.ts +46 -12
  80. package/src/vercel/client/start.ts +1 -0
  81. package/src/vercel/client/streamText.test.ts +577 -18
  82. package/src/vercel/client/streamText.ts +84 -32
  83. package/src/vercel/client/streaming.integration.test.ts +40 -3
  84. package/src/vercel/client/streaming.test.ts +101 -7
  85. package/src/vercel/client/streaming.throttle.test.ts +144 -0
  86. package/src/vercel/client/streaming.ts +161 -25
  87. package/src/vercel/client/types.ts +2 -0
  88. package/src/vercel/client/utils.test.ts +97 -0
  89. package/src/vercel/client/utils.ts +15 -15
  90. package/src/vercel/index.ts +2 -0
@@ -9,15 +9,31 @@ import {
9
9
  anyApi,
10
10
  } from "convex/server";
11
11
  import { v } from "convex/values";
12
+ import type { LanguageModelV4Source } from "@ai-sdk/provider";
12
13
  import { components, initConvexTest } from "./setup.test.js";
13
14
  import { mockModel } from "./mockModel.js";
14
- import { runAbortCleanup } from "./streamText.js";
15
+ import { runStreamCleanup } from "./streamText.js";
16
+ import type { StreamingOptions } from "./streaming.js";
17
+ import { errorToString } from "./utils.js";
15
18
 
16
19
  const schema = defineSchema({});
17
20
  type DataModel = DataModelFromSchemaDefinition<typeof schema>;
18
21
  const action = actionGeneric as ActionBuilder<DataModel, "public">;
19
22
 
20
23
  const FINAL_TEXT = "Hello from the model";
24
+ const PROVIDER_FAILURE_TEXT = "Mock provider failure";
25
+ const CLEANUP_FAILURE_TEXT = "finalizeMessage rejected";
26
+
27
+ function hasKeys(
28
+ value: unknown,
29
+ keys: string[],
30
+ ): value is Record<string, unknown> {
31
+ return (
32
+ value !== null &&
33
+ typeof value === "object" &&
34
+ keys.every((key) => key in value)
35
+ );
36
+ }
21
37
 
22
38
  const agent = new Agent(components.agent, {
23
39
  name: "stream-test",
@@ -34,6 +50,39 @@ const emptyAgent = new Agent(components.agent, {
34
50
  }),
35
51
  });
36
52
 
53
+ const failingAgent = new Agent(components.agent, {
54
+ name: "failing-stream-test",
55
+ languageModel: mockModel({
56
+ content: [{ type: "text", text: "partial response" }],
57
+ fail: { error: PROVIDER_FAILURE_TEXT },
58
+ }),
59
+ });
60
+
61
+ const sourceParts: LanguageModelV4Source[] = [
62
+ {
63
+ type: "source",
64
+ sourceType: "url",
65
+ id: "source-url-1",
66
+ url: "https://example.com/reference",
67
+ title: "Reference",
68
+ },
69
+ {
70
+ type: "source",
71
+ sourceType: "document",
72
+ id: "source-document-1",
73
+ mediaType: "application/pdf",
74
+ title: "Document",
75
+ filename: "document.pdf",
76
+ },
77
+ ];
78
+
79
+ const sourceAgent = new Agent(components.agent, {
80
+ name: "source-stream-test",
81
+ languageModel: mockModel({
82
+ content: [{ type: "text", text: FINAL_TEXT }, ...sourceParts],
83
+ }),
84
+ });
85
+
37
86
  // Action that exercises streamText with saveStreamDeltas.returnImmediately=true.
38
87
  // It consumes the stream after streamText returns, simulating the HTTP response
39
88
  // path described in issue #265.
@@ -55,7 +104,6 @@ export const streamTextReturnImmediately = action({
55
104
  // Drain the stream the way an HTTP response would. This triggers
56
105
  // onStepFinish for every step, including the final one.
57
106
  await result.consumeStream();
58
- return { ok: true };
59
107
  },
60
108
  });
61
109
 
@@ -91,14 +139,291 @@ export const streamTextEmptyReturnImmediately = action({
91
139
  },
92
140
  });
93
141
 
142
+ export const streamTextThrottled = action({
143
+ args: { threadId: v.string() },
144
+ handler: async (ctx, { threadId }) => {
145
+ const result = await agent.streamText(
146
+ ctx,
147
+ { threadId },
148
+ { prompt: "Test" },
149
+ {
150
+ saveStreamDeltas: {
151
+ returnImmediately: true,
152
+ chunking: "word",
153
+ throttleMs: 60_000,
154
+ },
155
+ },
156
+ );
157
+ await result.consumeStream();
158
+ return { ok: true };
159
+ },
160
+ });
161
+
162
+ // Same as streamTextThrottled, but awaited: streamText consumes the stream
163
+ // itself, so the terminal transition happens at end-of-stream.
164
+ export const streamTextThrottledAwaited = action({
165
+ args: { threadId: v.string() },
166
+ handler: async (ctx, { threadId }) => {
167
+ await agent.streamText(
168
+ ctx,
169
+ { threadId },
170
+ { prompt: "Test" },
171
+ {
172
+ saveStreamDeltas: {
173
+ chunking: "word",
174
+ throttleMs: 60_000,
175
+ },
176
+ },
177
+ );
178
+ return { ok: true };
179
+ },
180
+ });
181
+
182
+ export const streamTextNoStorage = action({
183
+ args: { threadId: v.string() },
184
+ handler: async (ctx, { threadId }) => {
185
+ await agent.streamText(
186
+ ctx,
187
+ { threadId },
188
+ { prompt: "Test" },
189
+ {
190
+ saveStreamDeltas: { chunking: "word", throttleMs: 0 },
191
+ storageOptions: { saveMessages: "none" },
192
+ },
193
+ );
194
+ return { ok: true };
195
+ },
196
+ });
197
+
198
+ export const streamTextNoStorageImmediate = action({
199
+ args: { threadId: v.string() },
200
+ handler: async (ctx, { threadId }) => {
201
+ const r = await agent.streamText(
202
+ ctx,
203
+ { threadId },
204
+ { prompt: "Test" },
205
+ {
206
+ saveStreamDeltas: {
207
+ returnImmediately: true,
208
+ chunking: "word",
209
+ throttleMs: 0,
210
+ },
211
+ storageOptions: { saveMessages: "none" },
212
+ },
213
+ );
214
+ await r.consumeStream();
215
+ return { ok: true };
216
+ },
217
+ });
218
+
219
+ export const streamTextCleanupFailure = action({
220
+ args: { threadId: v.string() },
221
+ handler: async (ctx, { threadId }) => {
222
+ const providerErrors: string[] = [];
223
+ let aborts = 0;
224
+ const failingCtx = {
225
+ ...ctx,
226
+ runMutation: (async (reference, args) => {
227
+ if (hasKeys(args, ["messageId", "result"])) {
228
+ throw new Error(CLEANUP_FAILURE_TEXT);
229
+ }
230
+ return ctx.runMutation(reference, args);
231
+ }) as typeof ctx.runMutation,
232
+ };
233
+ let caught: string | undefined;
234
+ try {
235
+ await failingAgent.streamText(
236
+ failingCtx,
237
+ { threadId },
238
+ {
239
+ prompt: "Test",
240
+ onError: ({ error }) => {
241
+ providerErrors.push(errorToString(error));
242
+ },
243
+ onAbort: () => {
244
+ aborts += 1;
245
+ },
246
+ },
247
+ { saveStreamDeltas: { chunking: "word", throttleMs: 0 } },
248
+ );
249
+ } catch (error) {
250
+ caught = errorToString(error);
251
+ }
252
+ return { providerErrors, aborts, caught };
253
+ },
254
+ });
255
+
256
+ // A generation that someone aborts out of band while it is streaming, the way
257
+ // a client cancelling a request would: list the streaming row and abort it.
258
+ // The throttle holds every part after the first, so the only remaining delta
259
+ // write is the one the finishing save drains, and the component refuses it.
260
+ export const streamTextAbortedMidStream = action({
261
+ args: { threadId: v.string() },
262
+ handler: async (ctx, { threadId }) => {
263
+ const result = await agent.streamText(
264
+ ctx,
265
+ { threadId },
266
+ { prompt: "Test" },
267
+ {
268
+ saveStreamDeltas: {
269
+ returnImmediately: true,
270
+ chunking: "word",
271
+ throttleMs: 60_000,
272
+ },
273
+ },
274
+ );
275
+ for (let i = 0; i < 50; i++) {
276
+ const streaming = await ctx.runQuery(components.agent.streams.list, {
277
+ threadId,
278
+ statuses: ["streaming"],
279
+ });
280
+ if (streaming.length) {
281
+ await ctx.runMutation(components.agent.streams.abort, {
282
+ streamId: streaming[0].streamId,
283
+ reason: "external abort",
284
+ });
285
+ break;
286
+ }
287
+ await new Promise((resolve) => setTimeout(resolve, 5));
288
+ }
289
+ await result.consumeStream();
290
+ },
291
+ });
292
+
293
+ // An empty generation on the awaited path with nothing stored: no part ever
294
+ // reaches the streamer, so no row exists when consumption ends.
295
+ export const streamTextEmptyNoStorageAwaited = action({
296
+ args: { threadId: v.string() },
297
+ handler: async (ctx, { threadId }) => {
298
+ await emptyAgent.streamText(
299
+ ctx,
300
+ { threadId },
301
+ { prompt: "Test" },
302
+ {
303
+ saveStreamDeltas: { chunking: "word", throttleMs: 0 },
304
+ storageOptions: { saveMessages: "none" },
305
+ },
306
+ );
307
+ return { ok: true };
308
+ },
309
+ });
310
+
311
+ export const streamTextWithSources = action({
312
+ args: { threadId: v.string(), sendSources: v.optional(v.boolean()) },
313
+ handler: async (ctx, { threadId, sendSources }) => {
314
+ const saveStreamDeltas: StreamingOptions = {
315
+ chunking: "word",
316
+ throttleMs: 0,
317
+ };
318
+ if (sendSources !== undefined) {
319
+ saveStreamDeltas.sendSources = sendSources;
320
+ }
321
+ await sourceAgent.streamText(
322
+ ctx,
323
+ { threadId },
324
+ { prompt: "Test" },
325
+ { saveStreamDeltas },
326
+ );
327
+ return { ok: true };
328
+ },
329
+ });
330
+
94
331
  const testApi: ApiFromModules<{
95
332
  fns: {
96
333
  streamTextReturnImmediately: typeof streamTextReturnImmediately;
334
+ streamTextThrottled: typeof streamTextThrottled;
335
+ streamTextThrottledAwaited: typeof streamTextThrottledAwaited;
336
+ streamTextAbortedMidStream: typeof streamTextAbortedMidStream;
337
+ streamTextNoStorage: typeof streamTextNoStorage;
338
+ streamTextNoStorageImmediate: typeof streamTextNoStorageImmediate;
339
+ streamTextEmptyNoStorageAwaited: typeof streamTextEmptyNoStorageAwaited;
97
340
  streamTextEmptyAwaited: typeof streamTextEmptyAwaited;
98
341
  streamTextEmptyReturnImmediately: typeof streamTextEmptyReturnImmediately;
342
+ streamTextCleanupFailure: typeof streamTextCleanupFailure;
343
+ streamTextWithSources: typeof streamTextWithSources;
99
344
  };
100
345
  }>["fns"] = anyApi["streamText.test"] as any;
101
346
 
347
+ describe("streamText source visibility", () => {
348
+ test.each([
349
+ { name: "omitted", sendSources: undefined },
350
+ { name: "enabled", sendSources: true },
351
+ ])(
352
+ "keeps awaited deltas healthy with sources $name",
353
+ async ({ sendSources }) => {
354
+ const t = initConvexTest(schema);
355
+ const threadId = await t.run(async (ctx) =>
356
+ createThread(ctx, components.agent, { userId: "u1" }),
357
+ );
358
+
359
+ await t.action(testApi.streamTextWithSources, {
360
+ threadId,
361
+ ...(sendSources === undefined ? {} : { sendSources }),
362
+ });
363
+
364
+ const streams = await t.run(async (ctx) =>
365
+ ctx.runQuery(components.agent.streams.list, {
366
+ threadId,
367
+ statuses: ["streaming", "finished", "aborted"],
368
+ }),
369
+ );
370
+ expect(streams).toEqual([
371
+ expect.objectContaining({ status: "finished" }),
372
+ ]);
373
+ const deltas = await t.run(async (ctx) =>
374
+ ctx.runQuery(components.agent.streams.listDeltas, {
375
+ threadId,
376
+ cursors: streams.map((stream) => ({
377
+ streamId: stream.streamId,
378
+ cursor: 0,
379
+ })),
380
+ }),
381
+ );
382
+ const parts = deltas.flatMap((delta) => delta.parts);
383
+ expect(
384
+ parts
385
+ .filter((part) => part.type === "text-delta")
386
+ .map((part) => part.delta)
387
+ .join(""),
388
+ ).toBe(FINAL_TEXT);
389
+ const streamedSources = parts.filter(
390
+ (part) => part.type === "source-url" || part.type === "source-document",
391
+ );
392
+ expect(streamedSources).toEqual(
393
+ sendSources
394
+ ? [
395
+ expect.objectContaining({
396
+ type: "source-url",
397
+ sourceId: "source-url-1",
398
+ url: "https://example.com/reference",
399
+ title: "Reference",
400
+ }),
401
+ expect.objectContaining({
402
+ type: "source-document",
403
+ sourceId: "source-document-1",
404
+ mediaType: "application/pdf",
405
+ title: "Document",
406
+ filename: "document.pdf",
407
+ }),
408
+ ]
409
+ : [],
410
+ );
411
+
412
+ const messages = await t.run(async (ctx) =>
413
+ sourceAgent.listMessages(ctx, {
414
+ threadId,
415
+ paginationOpts: { cursor: null, numItems: 50 },
416
+ }),
417
+ );
418
+ expect(
419
+ messages.page.filter(
420
+ (message) => message.message?.role === "assistant",
421
+ ),
422
+ ).toMatchObject([{ sources: sourceParts }]);
423
+ },
424
+ );
425
+ });
426
+
102
427
  describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () => {
103
428
  test("persists the final assistant text to the messages table", async () => {
104
429
  const t = initConvexTest(schema);
@@ -147,28 +472,64 @@ describe("streamText with saveStreamDeltas.returnImmediately (issue #265)", () =
147
472
  });
148
473
 
149
474
  describe("streamText abort cleanup", () => {
150
- test("attempts every cleanup and rethrows the first internal failure", async () => {
475
+ test("finishes durable cleanup before invoking onAbort", async () => {
151
476
  const calls: string[] = [];
152
- const firstFailure = new Error("failed pending message cleanup");
477
+ let resolveStreamer!: () => void;
478
+ const syncFailure = new Error("synchronous pending message cleanup");
153
479
 
154
- await expect(
155
- runAbortCleanup({
156
- failCall: async () => {
157
- calls.push("call.fail");
158
- throw firstFailure;
159
- },
160
- failStreamer: async () => {
480
+ const cleanup = runStreamCleanup({
481
+ failCall: () => {
482
+ calls.push("call.fail");
483
+ throw syncFailure;
484
+ },
485
+ failStreamer: () =>
486
+ new Promise<void>((resolve) => {
161
487
  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);
488
+ resolveStreamer = resolve;
489
+ }),
490
+ onAbort: () => {
491
+ calls.push("user.onAbort");
492
+ },
493
+ });
494
+
495
+ await Promise.resolve();
496
+ expect(calls).toEqual(["call.fail", "streamer.fail"]);
497
+ resolveStreamer();
498
+ await expect(cleanup).rejects.toBe(syncFailure);
169
499
 
170
500
  expect(calls).toEqual(["call.fail", "streamer.fail", "user.onAbort"]);
171
501
  });
502
+
503
+ test("surfaces a cleanup failure without hiding the provider error", async () => {
504
+ const t = initConvexTest(schema);
505
+ const threadId = await t.run(async (ctx) =>
506
+ createThread(ctx, components.agent, { userId: "u1" }),
507
+ );
508
+
509
+ const { providerErrors, aborts, caught } = await t.action(
510
+ testApi.streamTextCleanupFailure,
511
+ { threadId },
512
+ );
513
+
514
+ expect(providerErrors).toEqual([PROVIDER_FAILURE_TEXT]);
515
+ expect(aborts).toBe(0);
516
+ expect(caught).toBe(CLEANUP_FAILURE_TEXT);
517
+
518
+ const streaming = await t.run(async (ctx) =>
519
+ ctx.runQuery(components.agent.streams.list, {
520
+ threadId,
521
+ statuses: ["streaming"],
522
+ }),
523
+ );
524
+ const aborted = await t.run(async (ctx) =>
525
+ ctx.runQuery(components.agent.streams.list, {
526
+ threadId,
527
+ statuses: ["aborted"],
528
+ }),
529
+ );
530
+ expect(streaming).toHaveLength(0);
531
+ expect(aborted).toHaveLength(1);
532
+ });
172
533
  });
173
534
 
174
535
  describe("streamText with an empty final step (issue #274)", () => {
@@ -221,3 +582,201 @@ describe("streamText with an empty final step (issue #274)", () => {
221
582
  },
222
583
  );
223
584
  });
585
+
586
+ describe("saveStreamDeltas flushes buffered parts (issue #323)", () => {
587
+ test("deltas hold the full text when the generation outpaces the throttle", async () => {
588
+ const t = initConvexTest(schema);
589
+ const threadId = await t.run(async (ctx) =>
590
+ createThread(ctx, components.agent, { userId: "u1" }),
591
+ );
592
+
593
+ await t.action(testApi.streamTextThrottled, { threadId });
594
+ await t.finishAllScheduledFunctions(() => {});
595
+
596
+ const streams = await t.run(async (ctx) =>
597
+ ctx.runQuery(components.agent.streams.list, {
598
+ threadId,
599
+ statuses: ["streaming", "finished", "aborted"],
600
+ }),
601
+ );
602
+ const deltas = await t.run(async (ctx) =>
603
+ ctx.runQuery(components.agent.streams.listDeltas, {
604
+ threadId,
605
+ cursors: streams.map((s) => ({ streamId: s.streamId, cursor: 0 })),
606
+ }),
607
+ );
608
+
609
+ expect(streams).toHaveLength(1);
610
+ expect(streams[0].status).toBe("finished");
611
+
612
+ let cursor = 0;
613
+ for (const delta of deltas) {
614
+ expect(delta.start).toBe(cursor);
615
+ cursor = delta.end;
616
+ }
617
+
618
+ const parts = deltas.flatMap((d) => d.parts);
619
+ const types = parts.map((p) => p.type);
620
+ expect(types.at(0)).toBe("start");
621
+ expect(types).toContain("text-start");
622
+ expect(types).toContain("text-end");
623
+ // The stream-level "finish" chunk is emitted after the last step ends, so
624
+ // it cannot exist yet; the row's finished status carries that instead.
625
+ expect(types.at(-1)).toBe("finish-step");
626
+ expect(types).not.toContain("finish");
627
+ expect(
628
+ parts
629
+ .filter((p) => p.type === "text-delta")
630
+ .map((p) => (p as { delta?: string }).delta ?? "")
631
+ .join(""),
632
+ ).toBe(FINAL_TEXT);
633
+
634
+ const messages = await t.run(async (ctx) =>
635
+ agent.listMessages(ctx, {
636
+ threadId,
637
+ paginationOpts: { cursor: null, numItems: 50 },
638
+ }),
639
+ );
640
+ expect(
641
+ messages.page
642
+ .filter((m) => m.message?.role === "assistant")
643
+ .map((m) => m.text)
644
+ .join(""),
645
+ ).toBe(FINAL_TEXT);
646
+ });
647
+
648
+ test("an out of band abort fails the generation instead of saving it", async () => {
649
+ const t = initConvexTest(schema);
650
+ const threadId = await t.run(async (ctx) =>
651
+ createThread(ctx, components.agent, { userId: "u1" }),
652
+ );
653
+
654
+ await t.action(testApi.streamTextAbortedMidStream, { threadId });
655
+ await t.finishAllScheduledFunctions(() => {});
656
+
657
+ const streams = await t.run(async (ctx) =>
658
+ ctx.runQuery(components.agent.streams.list, {
659
+ threadId,
660
+ statuses: ["streaming", "finished", "aborted"],
661
+ }),
662
+ );
663
+ expect(streams).toHaveLength(1);
664
+ expect(streams[0].status).toBe("aborted");
665
+
666
+ // Nobody gets to save a successful message onto a row someone aborted.
667
+ const messages = await t.run(async (ctx) =>
668
+ agent.listMessages(ctx, {
669
+ threadId,
670
+ paginationOpts: { cursor: null, numItems: 50 },
671
+ }),
672
+ );
673
+ expect(
674
+ messages.page
675
+ .filter((m) => m.message?.role === "assistant")
676
+ .map((m) => m.status),
677
+ ).toEqual(["failed"]);
678
+ });
679
+
680
+ test("the awaited path captures the stream-level finish chunk", async () => {
681
+ const t = initConvexTest(schema);
682
+ const threadId = await t.run(async (ctx) =>
683
+ createThread(ctx, components.agent, { userId: "u1" }),
684
+ );
685
+
686
+ await t.action(testApi.streamTextThrottledAwaited, { threadId });
687
+ await t.finishAllScheduledFunctions(() => {});
688
+
689
+ const streams = await t.run(async (ctx) =>
690
+ ctx.runQuery(components.agent.streams.list, {
691
+ threadId,
692
+ statuses: ["streaming", "finished", "aborted"],
693
+ }),
694
+ );
695
+ const deltas = await t.run(async (ctx) =>
696
+ ctx.runQuery(components.agent.streams.listDeltas, {
697
+ threadId,
698
+ cursors: streams.map((s) => ({ streamId: s.streamId, cursor: 0 })),
699
+ }),
700
+ );
701
+
702
+ expect(streams).toHaveLength(1);
703
+ expect(streams[0].status).toBe("finished");
704
+
705
+ let cursor = 0;
706
+ for (const delta of deltas) {
707
+ expect(delta.start).toBe(cursor);
708
+ cursor = delta.end;
709
+ }
710
+
711
+ const parts = deltas.flatMap((d) => d.parts);
712
+ const types = parts.map((p) => p.type);
713
+ // Unlike the returnImmediately path, nothing stops accepting parts early
714
+ // here: consumeStream drains at EOF, so the trailing chunks the AI SDK
715
+ // emits after the last onStepEnd are persisted too.
716
+ expect(types.at(0)).toBe("start");
717
+ expect(types.at(-1)).toBe("finish");
718
+ expect(types).toContain("finish-step");
719
+ expect(
720
+ parts
721
+ .filter((p) => p.type === "text-delta")
722
+ .map((p) => (p as { delta?: string }).delta ?? "")
723
+ .join(""),
724
+ ).toBe(FINAL_TEXT);
725
+ });
726
+ });
727
+
728
+ describe("stream finish ownership without message storage", () => {
729
+ test("the row still terminates when saveMessages is none", async () => {
730
+ const t = initConvexTest(schema);
731
+ const threadId = await t.run(async (ctx) =>
732
+ createThread(ctx, components.agent, { userId: "u1" }),
733
+ );
734
+
735
+ await t.action(testApi.streamTextNoStorage, { threadId });
736
+ await t.finishAllScheduledFunctions(() => {});
737
+
738
+ const streams = await t.run(async (ctx) =>
739
+ ctx.runQuery(components.agent.streams.list, {
740
+ threadId,
741
+ statuses: ["streaming", "finished", "aborted"],
742
+ }),
743
+ );
744
+ expect(streams.map((s) => s.status)).toEqual(["finished"]);
745
+ });
746
+
747
+ test("leaves no row behind when the generation produces nothing", async () => {
748
+ const t = initConvexTest(schema);
749
+ const threadId = await t.run(async (ctx) =>
750
+ createThread(ctx, components.agent, { userId: "u1" }),
751
+ );
752
+
753
+ await t.action(testApi.streamTextEmptyNoStorageAwaited, { threadId });
754
+ await t.finishAllScheduledFunctions(() => {});
755
+
756
+ const streams = await t.run(async (ctx) =>
757
+ ctx.runQuery(components.agent.streams.list, {
758
+ threadId,
759
+ statuses: ["streaming", "finished", "aborted"],
760
+ }),
761
+ );
762
+ expect(streams.filter((s) => s.status === "streaming")).toEqual([]);
763
+ });
764
+
765
+ test("the row still terminates on the returnImmediately path", async () => {
766
+ const t = initConvexTest(schema);
767
+ const threadId = await t.run(async (ctx) =>
768
+ createThread(ctx, components.agent, { userId: "u1" }),
769
+ );
770
+
771
+ await t.action(testApi.streamTextNoStorageImmediate, { threadId });
772
+ await t.finishAllScheduledFunctions(() => {});
773
+
774
+ const streams = await t.run(async (ctx) =>
775
+ ctx.runQuery(components.agent.streams.list, {
776
+ threadId,
777
+ statuses: ["streaming", "finished", "aborted"],
778
+ }),
779
+ );
780
+ expect(streams.map((s) => s.status)).toEqual(["finished"]);
781
+ });
782
+ });