@convex-dev/agent 0.6.0-alpha.1 → 0.6.0-beta.0

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 (44) hide show
  1. package/dist/UIMessages.d.ts.map +1 -1
  2. package/dist/UIMessages.js +88 -0
  3. package/dist/UIMessages.js.map +1 -1
  4. package/dist/client/definePlaygroundAPI.d.ts +17 -17
  5. package/dist/client/index.d.ts +44 -44
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +54 -20
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/messages.d.ts +3 -3
  10. package/dist/client/search.d.ts +3 -3
  11. package/dist/client/search.d.ts.map +1 -1
  12. package/dist/client/search.js +14 -4
  13. package/dist/client/search.js.map +1 -1
  14. package/dist/client/start.js +2 -2
  15. package/dist/client/start.js.map +1 -1
  16. package/dist/client/streamText.d.ts.map +1 -1
  17. package/dist/client/streamText.js +10 -0
  18. package/dist/client/streamText.js.map +1 -1
  19. package/dist/client/streaming.d.ts +47 -47
  20. package/dist/client/streaming.d.ts.map +1 -1
  21. package/dist/client/streaming.js +37 -21
  22. package/dist/client/streaming.js.map +1 -1
  23. package/dist/component/messages.d.ts +47 -47
  24. package/dist/component/schema.d.ts +40 -40
  25. package/dist/component/streams.d.ts +2 -2
  26. package/dist/component/threads.d.ts +6 -6
  27. package/dist/component/vector/index.d.ts +1 -1
  28. package/dist/mapping.d.ts +19 -15
  29. package/dist/mapping.d.ts.map +1 -1
  30. package/dist/mapping.js +90 -47
  31. package/dist/mapping.js.map +1 -1
  32. package/dist/validators.d.ts +13 -13
  33. package/package.json +1 -1
  34. package/src/UIMessages.ts +126 -0
  35. package/src/client/approval.test.ts +144 -0
  36. package/src/client/index.ts +73 -23
  37. package/src/client/search.test.ts +4 -5
  38. package/src/client/search.ts +16 -4
  39. package/src/client/start.ts +2 -2
  40. package/src/client/streamText.ts +9 -0
  41. package/src/client/streaming.integration.test.ts +1206 -0
  42. package/src/client/streaming.ts +35 -21
  43. package/src/mapping.test.ts +136 -71
  44. package/src/mapping.ts +119 -50
@@ -0,0 +1,1206 @@
1
+ import { beforeEach, describe, expect, test } from "vitest";
2
+ import { createThread } from "./index.js";
3
+ import type { GenericSchema, SchemaDefinition } from "convex/server";
4
+ import { streamText } from "ai";
5
+ import { components, initConvexTest } from "./setup.test.js";
6
+ import { mockModel } from "./mockModel.js";
7
+ import {
8
+ compressUIMessageChunks,
9
+ DeltaStreamer,
10
+ mergeTransforms,
11
+ } from "./streaming.js";
12
+ import {
13
+ getParts,
14
+ deriveUIMessagesFromDeltas,
15
+ deriveUIMessagesFromTextStreamParts,
16
+ } from "../deltas.js";
17
+ import type { TestConvex } from "convex-test";
18
+ import type { StreamDelta, StreamMessage } from "../validators.js";
19
+ import { dedupeMessages } from "../react/useUIMessages.js";
20
+
21
+ const defaultTestOptions = {
22
+ throttleMs: 0,
23
+ abortSignal: undefined,
24
+ compress: null,
25
+ onAsyncAbort: async (_reason: string) => {
26
+ // In integration tests, async aborts can happen when the stream
27
+ // finishes before a pending delta write completes. This is expected.
28
+ },
29
+ };
30
+
31
+ const testMetadata = {
32
+ order: 0,
33
+ stepOrder: 0,
34
+ agentName: "test agent",
35
+ model: "test model",
36
+ provider: "test provider",
37
+ providerOptions: {},
38
+ format: "UIMessageChunk" as const,
39
+ };
40
+
41
+ // ============================================================================
42
+ // HTTP Streaming Initiation
43
+ // ============================================================================
44
+
45
+ describe("HTTP Streaming Initiation", () => {
46
+ let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
47
+ let threadId: string;
48
+
49
+ beforeEach(async () => {
50
+ t = initConvexTest();
51
+ await t.run(async (ctx) => {
52
+ threadId = await createThread(ctx, components.agent, {});
53
+ });
54
+ });
55
+
56
+ test("DeltaStreamer creates a stream on first addParts call", async () => {
57
+ await t.run(async (ctx) => {
58
+ const streamer = new DeltaStreamer(
59
+ components.agent,
60
+ ctx,
61
+ { ...defaultTestOptions },
62
+ { ...testMetadata, threadId },
63
+ );
64
+
65
+ expect(streamer.streamId).toBeUndefined();
66
+
67
+ await streamer.addParts([{ type: "start" }]);
68
+ expect(streamer.streamId).toBeDefined();
69
+ });
70
+ });
71
+
72
+ test("DeltaStreamer.getStreamId creates the stream lazily", async () => {
73
+ await t.run(async (ctx) => {
74
+ const streamer = new DeltaStreamer(
75
+ components.agent,
76
+ ctx,
77
+ { ...defaultTestOptions },
78
+ { ...testMetadata, threadId },
79
+ );
80
+
81
+ expect(streamer.streamId).toBeUndefined();
82
+ const streamId = await streamer.getStreamId();
83
+ expect(streamId).toBeDefined();
84
+ expect(streamer.streamId).toBe(streamId);
85
+ });
86
+ });
87
+
88
+ test("DeltaStreamer.getStreamId returns the same ID on repeated calls", async () => {
89
+ await t.run(async (ctx) => {
90
+ const streamer = new DeltaStreamer(
91
+ components.agent,
92
+ ctx,
93
+ { ...defaultTestOptions },
94
+ { ...testMetadata, threadId },
95
+ );
96
+
97
+ const id1 = await streamer.getStreamId();
98
+ const id2 = await streamer.getStreamId();
99
+ expect(id1).toBe(id2);
100
+ });
101
+ });
102
+
103
+ test("Stream is created with streaming state", async () => {
104
+ await t.run(async (ctx) => {
105
+ const streamer = new DeltaStreamer(
106
+ components.agent,
107
+ ctx,
108
+ { ...defaultTestOptions },
109
+ { ...testMetadata, threadId },
110
+ );
111
+
112
+ await streamer.getStreamId();
113
+
114
+ const streams = await ctx.runQuery(components.agent.streams.list, {
115
+ threadId,
116
+ statuses: ["streaming"],
117
+ });
118
+ expect(streams).toHaveLength(1);
119
+ expect(streams[0].status).toBe("streaming");
120
+ expect(streams[0].agentName).toBe("test agent");
121
+ expect(streams[0].model).toBe("test model");
122
+ });
123
+ });
124
+
125
+ test("consumeStream processes full AI SDK stream to deltas", async () => {
126
+ await t.run(async (ctx) => {
127
+ const streamer = new DeltaStreamer(
128
+ components.agent,
129
+ ctx,
130
+ { ...defaultTestOptions },
131
+ { ...testMetadata, threadId },
132
+ );
133
+
134
+ const result = streamText({
135
+ model: mockModel({
136
+ content: [{ type: "text", text: "Hello world" }],
137
+ }),
138
+ prompt: "Test",
139
+ });
140
+
141
+ await streamer.consumeStream(result.toUIMessageStream());
142
+ // Ensure the AI SDK result is also fully consumed
143
+ await result.consumeStream();
144
+ expect(streamer.streamId).toBeDefined();
145
+
146
+ // Verify deltas were saved
147
+ const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
148
+ threadId,
149
+ cursors: [{ cursor: 0, streamId: streamer.streamId! }],
150
+ });
151
+ expect(deltas.length).toBeGreaterThan(0);
152
+
153
+ // Verify we can reconstruct the text from deltas
154
+ const { parts } = getParts(deltas);
155
+ const textParts = parts.filter(
156
+ (p: any) => p.type === "text-delta",
157
+ );
158
+ expect(textParts.length).toBeGreaterThan(0);
159
+ });
160
+ });
161
+
162
+ test("consumeStream transitions stream to finished state", async () => {
163
+ await t.run(async (ctx) => {
164
+ const streamer = new DeltaStreamer(
165
+ components.agent,
166
+ ctx,
167
+ { ...defaultTestOptions },
168
+ { ...testMetadata, threadId },
169
+ );
170
+
171
+ const result = streamText({
172
+ model: mockModel({
173
+ content: [{ type: "text", text: "Done" }],
174
+ }),
175
+ prompt: "Test",
176
+ });
177
+
178
+ await streamer.consumeStream(result.toUIMessageStream());
179
+
180
+ // Stream should now be finished
181
+ const streamingStreams = await ctx.runQuery(
182
+ components.agent.streams.list,
183
+ { threadId, statuses: ["streaming"] },
184
+ );
185
+ expect(streamingStreams).toHaveLength(0);
186
+
187
+ const finishedStreams = await ctx.runQuery(
188
+ components.agent.streams.list,
189
+ { threadId, statuses: ["finished"] },
190
+ );
191
+ expect(finishedStreams).toHaveLength(1);
192
+ expect(finishedStreams[0].status).toBe("finished");
193
+ });
194
+ });
195
+
196
+ test("markFinishedExternally prevents consumeStream from calling finish", async () => {
197
+ await t.run(async (ctx) => {
198
+ const streamer = new DeltaStreamer(
199
+ components.agent,
200
+ ctx,
201
+ { ...defaultTestOptions },
202
+ { ...testMetadata, threadId },
203
+ );
204
+
205
+ await streamer.getStreamId();
206
+ streamer.markFinishedExternally();
207
+
208
+ const result = streamText({
209
+ model: mockModel({
210
+ content: [{ type: "text", text: "Hello" }],
211
+ }),
212
+ prompt: "Test",
213
+ });
214
+
215
+ await streamer.consumeStream(result.toUIMessageStream());
216
+
217
+ // Stream should still be in streaming state since finish was skipped
218
+ const streamingStreams = await ctx.runQuery(
219
+ components.agent.streams.list,
220
+ { threadId, statuses: ["streaming"] },
221
+ );
222
+ expect(streamingStreams).toHaveLength(1);
223
+ });
224
+ });
225
+ });
226
+
227
+ // ============================================================================
228
+ // Stream Exclusion Logic
229
+ // ============================================================================
230
+
231
+ describe("Stream Exclusion Logic", () => {
232
+ let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
233
+ let threadId: string;
234
+
235
+ beforeEach(async () => {
236
+ t = initConvexTest();
237
+ await t.run(async (ctx) => {
238
+ threadId = await createThread(ctx, components.agent, {});
239
+ });
240
+ });
241
+
242
+ test("list defaults to only streaming status", async () => {
243
+ await t.run(async (ctx) => {
244
+ // Create a stream and finish it
245
+ const streamer1 = new DeltaStreamer(
246
+ components.agent,
247
+ ctx,
248
+ { ...defaultTestOptions },
249
+ { ...testMetadata, threadId, order: 0 },
250
+ );
251
+ const r1 = streamText({
252
+ model: mockModel({ content: [{ type: "text", text: "Finished" }] }),
253
+ prompt: "Test",
254
+ });
255
+ await streamer1.consumeStream(r1.toUIMessageStream());
256
+
257
+ // Create a still-streaming stream
258
+ const streamer2 = new DeltaStreamer(
259
+ components.agent,
260
+ ctx,
261
+ { ...defaultTestOptions },
262
+ { ...testMetadata, threadId, order: 1 },
263
+ );
264
+ await streamer2.getStreamId();
265
+ await streamer2.addParts([{ type: "start" }]);
266
+
267
+ // Default list: only streaming
268
+ const defaultStreams = await ctx.runQuery(
269
+ components.agent.streams.list,
270
+ { threadId },
271
+ );
272
+ expect(defaultStreams).toHaveLength(1);
273
+ expect(defaultStreams[0].status).toBe("streaming");
274
+ expect(defaultStreams[0].order).toBe(1);
275
+ });
276
+ });
277
+
278
+ test("list with includeStatuses filters correctly", async () => {
279
+ await t.run(async (ctx) => {
280
+ // Create and finish a stream
281
+ const finishedStreamer = new DeltaStreamer(
282
+ components.agent,
283
+ ctx,
284
+ { ...defaultTestOptions },
285
+ { ...testMetadata, threadId, order: 0 },
286
+ );
287
+ const r = streamText({
288
+ model: mockModel({ content: [{ type: "text", text: "Done" }] }),
289
+ prompt: "Test",
290
+ });
291
+ await finishedStreamer.consumeStream(r.toUIMessageStream());
292
+
293
+ // Create and abort a stream
294
+ const abortedStreamer = new DeltaStreamer(
295
+ components.agent,
296
+ ctx,
297
+ { ...defaultTestOptions },
298
+ { ...testMetadata, threadId, order: 1 },
299
+ );
300
+ await abortedStreamer.getStreamId();
301
+ await abortedStreamer.fail("test abort");
302
+
303
+ // Create a still-streaming stream
304
+ const activeStreamer = new DeltaStreamer(
305
+ components.agent,
306
+ ctx,
307
+ { ...defaultTestOptions },
308
+ { ...testMetadata, threadId, order: 2 },
309
+ );
310
+ await activeStreamer.getStreamId();
311
+
312
+ // Query for all statuses
313
+ const allStreams = await ctx.runQuery(components.agent.streams.list, {
314
+ threadId,
315
+ statuses: ["streaming", "finished", "aborted"],
316
+ });
317
+ expect(allStreams).toHaveLength(3);
318
+
319
+ // Query for only finished
320
+ const finishedStreams = await ctx.runQuery(
321
+ components.agent.streams.list,
322
+ { threadId, statuses: ["finished"] },
323
+ );
324
+ expect(finishedStreams).toHaveLength(1);
325
+ expect(finishedStreams[0].status).toBe("finished");
326
+
327
+ // Query for only aborted
328
+ const abortedStreams = await ctx.runQuery(
329
+ components.agent.streams.list,
330
+ { threadId, statuses: ["aborted"] },
331
+ );
332
+ expect(abortedStreams).toHaveLength(1);
333
+ expect(abortedStreams[0].status).toBe("aborted");
334
+
335
+ // Query for streaming + aborted
336
+ const streamingAndAborted = await ctx.runQuery(
337
+ components.agent.streams.list,
338
+ { threadId, statuses: ["streaming", "aborted"] },
339
+ );
340
+ expect(streamingAndAborted).toHaveLength(2);
341
+ });
342
+ });
343
+
344
+ test("startOrder filters out streams with lower order", async () => {
345
+ await t.run(async (ctx) => {
346
+ // Create streams at different orders
347
+ for (const order of [0, 1, 2, 3]) {
348
+ const streamer = new DeltaStreamer(
349
+ components.agent,
350
+ ctx,
351
+ { ...defaultTestOptions },
352
+ { ...testMetadata, threadId, order },
353
+ );
354
+ await streamer.getStreamId();
355
+ }
356
+
357
+ // startOrder=2 should only return streams with order >= 2
358
+ const filtered = await ctx.runQuery(components.agent.streams.list, {
359
+ threadId,
360
+ startOrder: 2,
361
+ statuses: ["streaming"],
362
+ });
363
+ expect(filtered).toHaveLength(2);
364
+ expect(filtered.every((s) => s.order >= 2)).toBe(true);
365
+ });
366
+ });
367
+
368
+ test("streams from different threads are isolated", async () => {
369
+ let threadId2: string;
370
+ await t.run(async (ctx) => {
371
+ threadId2 = await createThread(ctx, components.agent, {});
372
+
373
+ // Create a stream in thread 1
374
+ const s1 = new DeltaStreamer(
375
+ components.agent,
376
+ ctx,
377
+ { ...defaultTestOptions },
378
+ { ...testMetadata, threadId, order: 0 },
379
+ );
380
+ await s1.getStreamId();
381
+
382
+ // Create a stream in thread 2
383
+ const s2 = new DeltaStreamer(
384
+ components.agent,
385
+ ctx,
386
+ { ...defaultTestOptions },
387
+ { ...testMetadata, threadId: threadId2, order: 0 },
388
+ );
389
+ await s2.getStreamId();
390
+
391
+ // Each thread should only see its own streams
392
+ const t1Streams = await ctx.runQuery(components.agent.streams.list, {
393
+ threadId,
394
+ statuses: ["streaming"],
395
+ });
396
+ expect(t1Streams).toHaveLength(1);
397
+
398
+ const t2Streams = await ctx.runQuery(components.agent.streams.list, {
399
+ threadId: threadId2,
400
+ statuses: ["streaming"],
401
+ });
402
+ expect(t2Streams).toHaveLength(1);
403
+
404
+ expect(t1Streams[0].streamId).not.toBe(t2Streams[0].streamId);
405
+ });
406
+ });
407
+
408
+ test("dedupeMessages prefers finalized over streaming over pending", () => {
409
+ type M = {
410
+ order: number;
411
+ stepOrder: number;
412
+ status: "pending" | "success" | "failed" | "streaming";
413
+ };
414
+
415
+ const messages: M[] = [
416
+ { order: 1, stepOrder: 0, status: "pending" },
417
+ { order: 2, stepOrder: 0, status: "success" },
418
+ { order: 3, stepOrder: 0, status: "pending" },
419
+ ];
420
+ const streamMessages: M[] = [
421
+ { order: 1, stepOrder: 0, status: "streaming" },
422
+ { order: 2, stepOrder: 0, status: "streaming" },
423
+ { order: 3, stepOrder: 0, status: "success" },
424
+ ];
425
+
426
+ const result = dedupeMessages(messages, streamMessages);
427
+ expect(result).toHaveLength(3);
428
+ // pending replaced by streaming
429
+ expect(result[0].status).toBe("streaming");
430
+ // success kept over streaming
431
+ expect(result[1].status).toBe("success");
432
+ // pending replaced by success
433
+ expect(result[2].status).toBe("success");
434
+ });
435
+ });
436
+
437
+ // ============================================================================
438
+ // Delta Stream Consumption
439
+ // ============================================================================
440
+
441
+ describe("Delta Stream Consumption", () => {
442
+ let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
443
+ let threadId: string;
444
+
445
+ beforeEach(async () => {
446
+ t = initConvexTest();
447
+ await t.run(async (ctx) => {
448
+ threadId = await createThread(ctx, components.agent, {});
449
+ });
450
+ });
451
+
452
+ test("cursor-based incremental delta fetching", async () => {
453
+ await t.run(async (ctx) => {
454
+ const streamer = new DeltaStreamer(
455
+ components.agent,
456
+ ctx,
457
+ { ...defaultTestOptions },
458
+ { ...testMetadata, threadId },
459
+ );
460
+
461
+ const result = streamText({
462
+ model: mockModel({
463
+ content: [{ type: "text", text: "One Two Three Four" }],
464
+ }),
465
+ prompt: "Test",
466
+ });
467
+ await streamer.consumeStream(result.toUIMessageStream());
468
+ const streamId = streamer.streamId!;
469
+
470
+ // Fetch all deltas from start
471
+ const allDeltas = await ctx.runQuery(
472
+ components.agent.streams.listDeltas,
473
+ { threadId, cursors: [{ cursor: 0, streamId }] },
474
+ );
475
+ expect(allDeltas.length).toBeGreaterThan(0);
476
+ const { parts: allParts, cursor: endCursor } = getParts(allDeltas);
477
+
478
+ // Fetch from midpoint cursor - should only get remaining deltas
479
+ const midCursor = Math.floor(endCursor / 2);
480
+ const laterDeltas = await ctx.runQuery(
481
+ components.agent.streams.listDeltas,
482
+ { threadId, cursors: [{ cursor: midCursor, streamId }] },
483
+ );
484
+ const { parts: laterParts } = getParts(laterDeltas, midCursor);
485
+
486
+ // Later parts should be a subset of all parts
487
+ expect(laterParts.length).toBeLessThanOrEqual(allParts.length);
488
+
489
+ // Fetching from the end cursor should yield nothing
490
+ const noDeltas = await ctx.runQuery(
491
+ components.agent.streams.listDeltas,
492
+ { threadId, cursors: [{ cursor: endCursor, streamId }] },
493
+ );
494
+ expect(noDeltas).toHaveLength(0);
495
+ });
496
+ });
497
+
498
+ test("multi-stream delta fetching with separate cursors", async () => {
499
+ await t.run(async (ctx) => {
500
+ // Create two streams with different content
501
+ const streamer1 = new DeltaStreamer(
502
+ components.agent,
503
+ ctx,
504
+ { ...defaultTestOptions },
505
+ { ...testMetadata, threadId, order: 0 },
506
+ );
507
+ const r1 = streamText({
508
+ model: mockModel({ content: [{ type: "text", text: "Stream One" }] }),
509
+ prompt: "Test 1",
510
+ });
511
+ await streamer1.consumeStream(r1.toUIMessageStream());
512
+
513
+ const streamer2 = new DeltaStreamer(
514
+ components.agent,
515
+ ctx,
516
+ { ...defaultTestOptions },
517
+ { ...testMetadata, threadId, order: 1 },
518
+ );
519
+ const r2 = streamText({
520
+ model: mockModel({ content: [{ type: "text", text: "Stream Two" }] }),
521
+ prompt: "Test 2",
522
+ });
523
+ await streamer2.consumeStream(r2.toUIMessageStream());
524
+
525
+ const id1 = streamer1.streamId!;
526
+ const id2 = streamer2.streamId!;
527
+
528
+ // Fetch deltas for both streams simultaneously
529
+ const deltas = await ctx.runQuery(
530
+ components.agent.streams.listDeltas,
531
+ {
532
+ threadId,
533
+ cursors: [
534
+ { cursor: 0, streamId: id1 },
535
+ { cursor: 0, streamId: id2 },
536
+ ],
537
+ },
538
+ );
539
+
540
+ // Should have deltas for both streams
541
+ const s1Deltas = deltas.filter((d) => d.streamId === id1);
542
+ const s2Deltas = deltas.filter((d) => d.streamId === id2);
543
+ expect(s1Deltas.length).toBeGreaterThan(0);
544
+ expect(s2Deltas.length).toBeGreaterThan(0);
545
+ });
546
+ });
547
+
548
+ test("deriveUIMessagesFromDeltas reconstructs messages from UIMessageChunk format", async () => {
549
+ await t.run(async (ctx) => {
550
+ const streamer = new DeltaStreamer(
551
+ components.agent,
552
+ ctx,
553
+ { ...defaultTestOptions },
554
+ { ...testMetadata, threadId },
555
+ );
556
+
557
+ const result = streamText({
558
+ model: mockModel({
559
+ content: [{ type: "text", text: "Hello from deltas" }],
560
+ }),
561
+ prompt: "Test",
562
+ });
563
+ await streamer.consumeStream(result.toUIMessageStream());
564
+ const streamId = streamer.streamId!;
565
+
566
+ // Fetch stream messages and deltas
567
+ const streams = await ctx.runQuery(components.agent.streams.list, {
568
+ threadId,
569
+ statuses: ["finished"],
570
+ });
571
+ const deltas = await ctx.runQuery(
572
+ components.agent.streams.listDeltas,
573
+ { threadId, cursors: [{ cursor: 0, streamId }] },
574
+ );
575
+
576
+ // Derive UI messages
577
+ const uiMessages = await deriveUIMessagesFromDeltas(
578
+ threadId,
579
+ streams,
580
+ deltas,
581
+ );
582
+ expect(uiMessages).toHaveLength(1);
583
+ expect(uiMessages[0].role).toBe("assistant");
584
+ expect(uiMessages[0].text).toContain("Hello");
585
+ expect(uiMessages[0].text).toContain("from");
586
+ expect(uiMessages[0].text).toContain("deltas");
587
+ });
588
+ });
589
+
590
+ test("compression merges consecutive text deltas", async () => {
591
+ await t.run(async (ctx) => {
592
+ const streamer = new DeltaStreamer(
593
+ components.agent,
594
+ ctx,
595
+ {
596
+ throttleMs: 1000,
597
+ abortSignal: undefined,
598
+ compress: compressUIMessageChunks,
599
+ onAsyncAbort: async () => {
600
+ throw new Error("async abort");
601
+ },
602
+ },
603
+ { ...testMetadata, threadId },
604
+ );
605
+
606
+ const result = streamText({
607
+ model: mockModel({
608
+ content: [
609
+ { type: "text", text: "A B C" },
610
+ { type: "reasoning", text: "X Y Z" },
611
+ ],
612
+ }),
613
+ prompt: "Test",
614
+ });
615
+ await streamer.consumeStream(result.toUIMessageStream());
616
+ const streamId = streamer.streamId!;
617
+
618
+ const deltas = await ctx.runQuery(
619
+ components.agent.streams.listDeltas,
620
+ { threadId, cursors: [{ cursor: 0, streamId }] },
621
+ );
622
+ const { parts } = getParts(deltas);
623
+
624
+ // Compressed: all text-deltas for one text section should be merged
625
+ const textDeltas = parts.filter((p: any) => p.type === "text-delta");
626
+ // With compression and throttleMs=1000, text deltas should be merged
627
+ expect(textDeltas.length).toBeLessThanOrEqual(1);
628
+ if (textDeltas.length === 1) {
629
+ expect((textDeltas[0] as { delta: string }).delta).toBe("A B C");
630
+ }
631
+
632
+ // Reasoning deltas should also be merged
633
+ const reasoningDeltas = parts.filter(
634
+ (p: any) => p.type === "reasoning-delta",
635
+ );
636
+ expect(reasoningDeltas.length).toBeLessThanOrEqual(1);
637
+ });
638
+ });
639
+
640
+ test("getParts validates delta continuity", () => {
641
+ const streamId = "test-stream";
642
+
643
+ // Normal continuous deltas
644
+ const deltas: StreamDelta[] = [
645
+ { streamId, start: 0, end: 3, parts: [{ type: "text-delta" }] },
646
+ { streamId, start: 3, end: 6, parts: [{ type: "text-delta" }] },
647
+ { streamId, start: 6, end: 9, parts: [{ type: "text-delta" }] },
648
+ ];
649
+ const { parts, cursor } = getParts(deltas);
650
+ expect(parts).toHaveLength(3);
651
+ expect(cursor).toBe(9);
652
+ });
653
+
654
+ test("getParts handles gap in deltas gracefully", () => {
655
+ const streamId = "test-stream";
656
+
657
+ // Deltas with a gap (missing 3-6)
658
+ const deltas: StreamDelta[] = [
659
+ { streamId, start: 0, end: 3, parts: [{ type: "a" }] },
660
+ { streamId, start: 6, end: 9, parts: [{ type: "b" }] },
661
+ ];
662
+ const { parts, cursor } = getParts(deltas);
663
+ // Should stop at the gap
664
+ expect(parts).toHaveLength(1);
665
+ expect(cursor).toBe(3);
666
+ });
667
+
668
+ test("getParts skips already-consumed deltas", () => {
669
+ const streamId = "test-stream";
670
+ const deltas: StreamDelta[] = [
671
+ { streamId, start: 0, end: 3, parts: [{ type: "old" }] },
672
+ { streamId, start: 3, end: 6, parts: [{ type: "new" }] },
673
+ ];
674
+ // Start from cursor=3 to skip first delta
675
+ const { parts, cursor } = getParts(deltas, 3);
676
+ expect(parts).toHaveLength(1);
677
+ expect((parts[0] as { type: string }).type).toBe("new");
678
+ expect(cursor).toBe(6);
679
+ });
680
+
681
+ test("TextStreamPart format delta reconstruction with tool calls", () => {
682
+ const streamId = "s1";
683
+ const streamMessage: StreamMessage = {
684
+ streamId,
685
+ order: 1,
686
+ stepOrder: 0,
687
+ status: "streaming",
688
+ };
689
+ const deltas: StreamDelta[] = [
690
+ {
691
+ streamId,
692
+ start: 0,
693
+ end: 1,
694
+ parts: [{ type: "text-delta", id: "txt-0", text: "Let me call a tool. " }],
695
+ },
696
+ {
697
+ streamId,
698
+ start: 1,
699
+ end: 2,
700
+ parts: [
701
+ {
702
+ type: "tool-call",
703
+ toolCallId: "tc1",
704
+ toolName: "search",
705
+ input: { query: "hello" },
706
+ },
707
+ ],
708
+ },
709
+ {
710
+ streamId,
711
+ start: 2,
712
+ end: 3,
713
+ parts: [
714
+ {
715
+ type: "tool-result",
716
+ toolCallId: "tc1",
717
+ toolName: "search",
718
+ output: "Found 3 results",
719
+ },
720
+ ],
721
+ },
722
+ {
723
+ streamId,
724
+ start: 3,
725
+ end: 4,
726
+ parts: [
727
+ { type: "text-delta", id: "txt-1", text: "Here are the results." },
728
+ ],
729
+ },
730
+ ];
731
+
732
+ const [messages, , changed] = deriveUIMessagesFromTextStreamParts(
733
+ "thread1",
734
+ [streamMessage],
735
+ [],
736
+ deltas,
737
+ );
738
+
739
+ expect(messages).toHaveLength(1);
740
+ expect(changed).toBe(true);
741
+
742
+ const msg = messages[0];
743
+ expect(msg.text).toContain("Let me call a tool.");
744
+ expect(msg.text).toContain("Here are the results.");
745
+
746
+ const toolParts = msg.parts.filter((p: any) =>
747
+ p.type.startsWith("tool-"),
748
+ );
749
+ expect(toolParts.length).toBeGreaterThan(0);
750
+ });
751
+ });
752
+
753
+ // ============================================================================
754
+ // Fallback Behavior between HTTP and Delta Streams
755
+ // ============================================================================
756
+
757
+ describe("Fallback Behavior", () => {
758
+ let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
759
+ let threadId: string;
760
+
761
+ beforeEach(async () => {
762
+ t = initConvexTest();
763
+ await t.run(async (ctx) => {
764
+ threadId = await createThread(ctx, components.agent, {});
765
+ });
766
+ });
767
+
768
+ test("aborted stream transitions to aborted state", async () => {
769
+ await t.run(async (ctx) => {
770
+ const streamer = new DeltaStreamer(
771
+ components.agent,
772
+ ctx,
773
+ { ...defaultTestOptions },
774
+ { ...testMetadata, threadId },
775
+ );
776
+ await streamer.getStreamId();
777
+
778
+ await streamer.fail("User canceled");
779
+
780
+ const aborted = await ctx.runQuery(components.agent.streams.list, {
781
+ threadId,
782
+ statuses: ["aborted"],
783
+ });
784
+ expect(aborted).toHaveLength(1);
785
+ expect(aborted[0].status).toBe("aborted");
786
+
787
+ // No streaming streams left
788
+ const streaming = await ctx.runQuery(components.agent.streams.list, {
789
+ threadId,
790
+ statuses: ["streaming"],
791
+ });
792
+ expect(streaming).toHaveLength(0);
793
+ });
794
+ });
795
+
796
+ test("abort via abortByOrder aborts all streams at that order", async () => {
797
+ await t.run(async (ctx) => {
798
+ // Create two streams at the same order (different stepOrders)
799
+ const s1 = new DeltaStreamer(
800
+ components.agent,
801
+ ctx,
802
+ { ...defaultTestOptions },
803
+ { ...testMetadata, threadId, order: 5, stepOrder: 0 },
804
+ );
805
+ await s1.getStreamId();
806
+
807
+ const s2 = new DeltaStreamer(
808
+ components.agent,
809
+ ctx,
810
+ { ...defaultTestOptions },
811
+ { ...testMetadata, threadId, order: 5, stepOrder: 1 },
812
+ );
813
+ await s2.getStreamId();
814
+
815
+ // Abort by order
816
+ const result = await ctx.runMutation(
817
+ components.agent.streams.abortByOrder,
818
+ { threadId, order: 5, reason: "batch abort" },
819
+ );
820
+ expect(result).toBe(true);
821
+
822
+ const streaming = await ctx.runQuery(components.agent.streams.list, {
823
+ threadId,
824
+ statuses: ["streaming"],
825
+ });
826
+ expect(streaming).toHaveLength(0);
827
+
828
+ const aborted = await ctx.runQuery(components.agent.streams.list, {
829
+ threadId,
830
+ statuses: ["aborted"],
831
+ });
832
+ expect(aborted).toHaveLength(2);
833
+ });
834
+ });
835
+
836
+ test("fail on already-aborted stream is a no-op", async () => {
837
+ await t.run(async (ctx) => {
838
+ const streamer = new DeltaStreamer(
839
+ components.agent,
840
+ ctx,
841
+ { ...defaultTestOptions },
842
+ { ...testMetadata, threadId },
843
+ );
844
+ await streamer.getStreamId();
845
+
846
+ // First abort
847
+ await streamer.fail("First abort");
848
+
849
+ // Second abort is a no-op (no error thrown)
850
+ await streamer.fail("Second abort");
851
+
852
+ const aborted = await ctx.runQuery(components.agent.streams.list, {
853
+ threadId,
854
+ statuses: ["aborted"],
855
+ });
856
+ expect(aborted).toHaveLength(1);
857
+ });
858
+ });
859
+
860
+ test("finish on non-existent stream is a no-op", async () => {
861
+ await t.run(async (ctx) => {
862
+ const streamer = new DeltaStreamer(
863
+ components.agent,
864
+ ctx,
865
+ { ...defaultTestOptions },
866
+ { ...testMetadata, threadId },
867
+ );
868
+
869
+ // Calling finish without ever creating a stream should be safe
870
+ await streamer.finish();
871
+ expect(streamer.streamId).toBeUndefined();
872
+ });
873
+ });
874
+
875
+ test("deriveUIMessagesFromDeltas maps stream status correctly", async () => {
876
+ // Streaming status
877
+ const streamingMsg: StreamMessage = {
878
+ streamId: "s1",
879
+ order: 0,
880
+ stepOrder: 0,
881
+ status: "streaming",
882
+ };
883
+ const finishedMsg: StreamMessage = {
884
+ streamId: "s2",
885
+ order: 1,
886
+ stepOrder: 0,
887
+ status: "finished",
888
+ };
889
+ const abortedMsg: StreamMessage = {
890
+ streamId: "s3",
891
+ order: 2,
892
+ stepOrder: 0,
893
+ status: "aborted",
894
+ };
895
+
896
+ const msgs = await deriveUIMessagesFromDeltas(
897
+ "t1",
898
+ [streamingMsg, finishedMsg, abortedMsg],
899
+ [],
900
+ );
901
+ expect(msgs[0].status).toBe("streaming");
902
+ expect(msgs[1].status).toBe("success");
903
+ expect(msgs[2].status).toBe("failed");
904
+ });
905
+
906
+ test("dedupeMessages handles fallback from streaming to finalized gracefully", () => {
907
+ type M = {
908
+ order: number;
909
+ stepOrder: number;
910
+ status: "pending" | "success" | "failed" | "streaming";
911
+ text: string;
912
+ };
913
+
914
+ // Simulate: full messages from DB include finalized versions, streaming
915
+ // messages are still around from the delta stream
916
+ const dbMessages: M[] = [
917
+ { order: 1, stepOrder: 0, status: "success", text: "Final answer" },
918
+ { order: 2, stepOrder: 0, status: "pending", text: "Thinking..." },
919
+ ];
920
+ const streamMessages: M[] = [
921
+ { order: 1, stepOrder: 0, status: "streaming", text: "Final ans..." },
922
+ { order: 2, stepOrder: 0, status: "streaming", text: "Thinking..." },
923
+ ];
924
+
925
+ const result = dedupeMessages(dbMessages, streamMessages);
926
+
927
+ // Order 1: finalized DB version preferred over streaming
928
+ expect(result[0].status).toBe("success");
929
+ expect(result[0].text).toBe("Final answer");
930
+
931
+ // Order 2: streaming preferred over pending DB version
932
+ expect(result[1].status).toBe("streaming");
933
+ });
934
+
935
+ test("mergeTransforms adds smoothStream when streaming is enabled", () => {
936
+ // No streaming options - returns existing transforms
937
+ expect(mergeTransforms(undefined, undefined)).toBeUndefined();
938
+
939
+ // Boolean true - adds smoothStream
940
+ const transforms = mergeTransforms(true, undefined);
941
+ expect(transforms).toBeDefined();
942
+ expect(Array.isArray(transforms)).toBe(true);
943
+ expect((transforms as any[]).length).toBe(1);
944
+
945
+ // With existing transforms - appends
946
+ const existing = [(chunk: any) => chunk];
947
+ const merged = mergeTransforms(true, existing);
948
+ expect(Array.isArray(merged)).toBe(true);
949
+ expect((merged as any[]).length).toBe(2);
950
+
951
+ // Custom chunking
952
+ const custom = mergeTransforms({ chunking: "word" }, undefined);
953
+ expect(custom).toBeDefined();
954
+ expect(Array.isArray(custom)).toBe(true);
955
+ });
956
+ });
957
+
958
+ // ============================================================================
959
+ // Stream Lifecycle Integration
960
+ // ============================================================================
961
+
962
+ describe("Stream Lifecycle Integration", () => {
963
+ let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
964
+ let threadId: string;
965
+
966
+ beforeEach(async () => {
967
+ t = initConvexTest();
968
+ await t.run(async (ctx) => {
969
+ threadId = await createThread(ctx, components.agent, {});
970
+ });
971
+ });
972
+
973
+ test("full lifecycle: create -> stream -> finish -> derive messages", async () => {
974
+ await t.run(async (ctx) => {
975
+ // 1. Create the stream
976
+ const streamer = new DeltaStreamer(
977
+ components.agent,
978
+ ctx,
979
+ { ...defaultTestOptions },
980
+ { ...testMetadata, threadId },
981
+ );
982
+
983
+ // 2. Stream content
984
+ const result = streamText({
985
+ model: mockModel({
986
+ content: [
987
+ { type: "text", text: "Once upon a time" },
988
+ { type: "reasoning", text: "I should tell a story" },
989
+ ],
990
+ }),
991
+ prompt: "Tell me a story",
992
+ });
993
+ await streamer.consumeStream(result.toUIMessageStream());
994
+ const streamId = streamer.streamId!;
995
+
996
+ // 3. Verify finish state
997
+ const finished = await ctx.runQuery(components.agent.streams.list, {
998
+ threadId,
999
+ statuses: ["finished"],
1000
+ });
1001
+ expect(finished).toHaveLength(1);
1002
+
1003
+ // 4. Derive UI messages from stored deltas
1004
+ const deltas = await ctx.runQuery(
1005
+ components.agent.streams.listDeltas,
1006
+ { threadId, cursors: [{ cursor: 0, streamId }] },
1007
+ );
1008
+ const uiMessages = await deriveUIMessagesFromDeltas(
1009
+ threadId,
1010
+ finished,
1011
+ deltas,
1012
+ );
1013
+
1014
+ expect(uiMessages).toHaveLength(1);
1015
+ const msg = uiMessages[0];
1016
+ expect(msg.role).toBe("assistant");
1017
+ expect(msg.text).toContain("Once");
1018
+ expect(msg.text).toContain("upon");
1019
+ expect(msg.text).toContain("time");
1020
+ expect(msg.status).toBe("success");
1021
+
1022
+ // Check that reasoning parts are present
1023
+ const reasoningParts = msg.parts.filter(
1024
+ (p: any) => p.type === "reasoning",
1025
+ );
1026
+ expect(reasoningParts.length).toBeGreaterThan(0);
1027
+ });
1028
+ });
1029
+
1030
+ test("full lifecycle: create -> partial stream -> abort -> derive aborted messages", async () => {
1031
+ await t.run(async (ctx) => {
1032
+ const streamer = new DeltaStreamer(
1033
+ components.agent,
1034
+ ctx,
1035
+ { ...defaultTestOptions },
1036
+ { ...testMetadata, threadId },
1037
+ );
1038
+
1039
+ // Stream some content then abort
1040
+ await streamer.addParts([
1041
+ { type: "start" },
1042
+ { type: "start-step" },
1043
+ { type: "text-start", id: "txt-0" },
1044
+ { type: "text-delta", id: "txt-0", delta: "Partial" },
1045
+ ]);
1046
+ await streamer.fail("User aborted");
1047
+
1048
+ const streamId = streamer.streamId!;
1049
+
1050
+ // Verify aborted state
1051
+ const aborted = await ctx.runQuery(components.agent.streams.list, {
1052
+ threadId,
1053
+ statuses: ["aborted"],
1054
+ });
1055
+ expect(aborted).toHaveLength(1);
1056
+ expect(aborted[0].status).toBe("aborted");
1057
+
1058
+ // Even aborted streams have their deltas stored
1059
+ const deltas = await ctx.runQuery(
1060
+ components.agent.streams.listDeltas,
1061
+ { threadId, cursors: [{ cursor: 0, streamId }] },
1062
+ );
1063
+ expect(deltas.length).toBeGreaterThan(0);
1064
+ });
1065
+ });
1066
+
1067
+ test("multiple concurrent streams in same thread", async () => {
1068
+ await t.run(async (ctx) => {
1069
+ const streamers = [];
1070
+ for (let i = 0; i < 3; i++) {
1071
+ const streamer = new DeltaStreamer(
1072
+ components.agent,
1073
+ ctx,
1074
+ { ...defaultTestOptions },
1075
+ { ...testMetadata, threadId, order: i },
1076
+ );
1077
+ const r = streamText({
1078
+ model: mockModel({
1079
+ content: [{ type: "text", text: `Message ${i}` }],
1080
+ }),
1081
+ prompt: "Test",
1082
+ });
1083
+ await streamer.consumeStream(r.toUIMessageStream());
1084
+ streamers.push(streamer);
1085
+ }
1086
+
1087
+ // All should be finished
1088
+ const finished = await ctx.runQuery(components.agent.streams.list, {
1089
+ threadId,
1090
+ statuses: ["finished"],
1091
+ });
1092
+ expect(finished).toHaveLength(3);
1093
+
1094
+ // Derive all messages
1095
+ const allDeltas = await ctx.runQuery(
1096
+ components.agent.streams.listDeltas,
1097
+ {
1098
+ threadId,
1099
+ cursors: streamers.map((s) => ({
1100
+ cursor: 0,
1101
+ streamId: s.streamId!,
1102
+ })),
1103
+ },
1104
+ );
1105
+ const uiMessages = await deriveUIMessagesFromDeltas(
1106
+ threadId,
1107
+ finished,
1108
+ allDeltas,
1109
+ );
1110
+ expect(uiMessages).toHaveLength(3);
1111
+ });
1112
+ });
1113
+
1114
+ test("stream deletion removes both stream and its deltas", async () => {
1115
+ await t.run(async (ctx) => {
1116
+ const streamer = new DeltaStreamer(
1117
+ components.agent,
1118
+ ctx,
1119
+ { ...defaultTestOptions },
1120
+ { ...testMetadata, threadId },
1121
+ );
1122
+ const r = streamText({
1123
+ model: mockModel({ content: [{ type: "text", text: "Delete me" }] }),
1124
+ prompt: "Test",
1125
+ });
1126
+ await streamer.consumeStream(r.toUIMessageStream());
1127
+ const streamId = streamer.streamId!;
1128
+
1129
+ // Verify deltas exist
1130
+ const beforeDeltas = await ctx.runQuery(
1131
+ components.agent.streams.listDeltas,
1132
+ { threadId, cursors: [{ cursor: 0, streamId }] },
1133
+ );
1134
+ expect(beforeDeltas.length).toBeGreaterThan(0);
1135
+
1136
+ // Delete the stream
1137
+ await ctx.runMutation(components.agent.streams.deleteStreamSync, {
1138
+ streamId,
1139
+ });
1140
+
1141
+ // Both stream and deltas should be gone
1142
+ const afterStreams = await ctx.runQuery(components.agent.streams.list, {
1143
+ threadId,
1144
+ statuses: ["streaming", "finished", "aborted"],
1145
+ });
1146
+ expect(afterStreams).toHaveLength(0);
1147
+
1148
+ const afterDeltas = await ctx.runQuery(
1149
+ components.agent.streams.listDeltas,
1150
+ { threadId, cursors: [{ cursor: 0, streamId }] },
1151
+ );
1152
+ expect(afterDeltas).toHaveLength(0);
1153
+ });
1154
+ });
1155
+ });
1156
+
1157
+ // ============================================================================
1158
+ // Compression
1159
+ // ============================================================================
1160
+
1161
+ describe("Compression", () => {
1162
+ test("compressUIMessageChunks merges consecutive text-delta parts", () => {
1163
+ const parts = [
1164
+ { type: "text-delta" as const, id: "1", delta: "Hello" },
1165
+ { type: "text-delta" as const, id: "1", delta: " " },
1166
+ { type: "text-delta" as const, id: "1", delta: "World" },
1167
+ ];
1168
+ const compressed = compressUIMessageChunks(parts);
1169
+ expect(compressed).toHaveLength(1);
1170
+ expect(compressed[0]).toEqual({
1171
+ type: "text-delta",
1172
+ id: "1",
1173
+ delta: "Hello World",
1174
+ });
1175
+ });
1176
+
1177
+ test("compressUIMessageChunks does not merge different IDs", () => {
1178
+ const parts = [
1179
+ { type: "text-delta" as const, id: "1", delta: "Hello" },
1180
+ { type: "text-delta" as const, id: "2", delta: "World" },
1181
+ ];
1182
+ const compressed = compressUIMessageChunks(parts);
1183
+ expect(compressed).toHaveLength(2);
1184
+ });
1185
+
1186
+ test("compressUIMessageChunks merges consecutive reasoning-delta parts", () => {
1187
+ const parts = [
1188
+ { type: "reasoning-delta" as const, id: "r1", delta: "Think" },
1189
+ { type: "reasoning-delta" as const, id: "r1", delta: "ing" },
1190
+ ];
1191
+ const compressed = compressUIMessageChunks(parts);
1192
+ expect(compressed).toHaveLength(1);
1193
+ expect((compressed[0] as { delta: string }).delta).toBe("Thinking");
1194
+ });
1195
+
1196
+ test("compressUIMessageChunks preserves non-delta parts", () => {
1197
+ const parts = [
1198
+ { type: "start" as const },
1199
+ { type: "text-delta" as const, id: "1", delta: "A" },
1200
+ { type: "text-delta" as const, id: "1", delta: "B" },
1201
+ { type: "finish" as const },
1202
+ ];
1203
+ const compressed = compressUIMessageChunks(parts as any);
1204
+ expect(compressed).toHaveLength(3); // start, merged text, finish
1205
+ });
1206
+ });