@convex-dev/agent 0.6.3 → 0.6.4

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 (71) hide show
  1. package/dist/UIMessages.d.ts.map +1 -1
  2. package/dist/UIMessages.js.map +1 -1
  3. package/dist/client/definePlaygroundAPI.d.ts +69 -64
  4. package/dist/client/definePlaygroundAPI.d.ts.map +1 -1
  5. package/dist/client/definePlaygroundAPI.js +8 -5
  6. package/dist/client/definePlaygroundAPI.js.map +1 -1
  7. package/dist/client/index.d.ts +12 -1
  8. package/dist/client/index.d.ts.map +1 -1
  9. package/dist/client/index.js +11 -2
  10. package/dist/client/index.js.map +1 -1
  11. package/dist/client/saveInputMessages.d.ts.map +1 -1
  12. package/dist/client/saveInputMessages.js.map +1 -1
  13. package/dist/client/types.d.ts.map +1 -1
  14. package/dist/component/apiKeys.js +5 -5
  15. package/dist/component/apiKeys.js.map +1 -1
  16. package/dist/component/files.d.ts.map +1 -1
  17. package/dist/component/files.js +13 -11
  18. package/dist/component/files.js.map +1 -1
  19. package/dist/component/messages.d.ts.map +1 -1
  20. package/dist/component/messages.js +37 -27
  21. package/dist/component/messages.js.map +1 -1
  22. package/dist/component/streams.d.ts.map +1 -1
  23. package/dist/component/streams.js +22 -17
  24. package/dist/component/streams.js.map +1 -1
  25. package/dist/component/threads.js +7 -7
  26. package/dist/component/threads.js.map +1 -1
  27. package/dist/component/users.js +2 -2
  28. package/dist/component/users.js.map +1 -1
  29. package/dist/component/vector/index.d.ts.map +1 -1
  30. package/dist/component/vector/index.js +14 -8
  31. package/dist/component/vector/index.js.map +1 -1
  32. package/dist/deltas.d.ts +16 -27
  33. package/dist/deltas.d.ts.map +1 -1
  34. package/dist/deltas.js +269 -286
  35. package/dist/deltas.js.map +1 -1
  36. package/dist/mapping.d.ts +9 -3
  37. package/dist/mapping.d.ts.map +1 -1
  38. package/dist/mapping.js +16 -14
  39. package/dist/mapping.js.map +1 -1
  40. package/dist/react/useStreamingUIMessages.d.ts.map +1 -1
  41. package/dist/react/useStreamingUIMessages.js +42 -26
  42. package/dist/react/useStreamingUIMessages.js.map +1 -1
  43. package/dist/react/useUIMessages.d.ts +1 -0
  44. package/dist/react/useUIMessages.d.ts.map +1 -1
  45. package/dist/react/useUIMessages.js +7 -3
  46. package/dist/react/useUIMessages.js.map +1 -1
  47. package/package.json +2 -1
  48. package/src/UIMessages.ts +1 -2
  49. package/src/client/approval.test.ts +25 -6
  50. package/src/client/createTool.ts +1 -1
  51. package/src/client/definePlaygroundAPI.ts +33 -17
  52. package/src/client/index.test.ts +91 -0
  53. package/src/client/index.ts +25 -1
  54. package/src/client/saveInputMessages.ts +4 -1
  55. package/src/client/streaming.integration.test.ts +39 -117
  56. package/src/client/types.ts +4 -17
  57. package/src/component/apiKeys.ts +5 -5
  58. package/src/component/files.test.ts +1 -1
  59. package/src/component/files.ts +14 -12
  60. package/src/component/messages.ts +40 -28
  61. package/src/component/streams.ts +33 -17
  62. package/src/component/threads.ts +7 -7
  63. package/src/component/users.ts +2 -2
  64. package/src/component/vector/index.ts +14 -7
  65. package/src/deltas.test.ts +373 -392
  66. package/src/deltas.ts +339 -378
  67. package/src/mapping.test.ts +296 -18
  68. package/src/mapping.ts +17 -11
  69. package/src/react/useStreamingUIMessages.ts +62 -34
  70. package/src/react/useUIMessages.test.ts +80 -1
  71. package/src/react/useUIMessages.ts +11 -3
@@ -1,13 +1,13 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
+ applyUIMessageChunksIncremental,
3
4
  blankUIMessage,
4
- deriveUIMessagesFromTextStreamParts,
5
- updateFromTextStreamParts,
5
+ emptyIncrementalStreamState,
6
+ getParts,
6
7
  updateFromUIMessageChunks,
7
8
  } from "./deltas.js";
8
- import type { StreamMessage, StreamDelta } from "./validators.js";
9
- import { omit } from "convex-helpers";
10
- import type { Tool, ToolUIPart, TypedToolResult } from "ai";
9
+ import type { StreamDelta } from "./validators.js";
10
+ import type { ToolUIPart, UIMessageChunk } from "ai";
11
11
 
12
12
  describe("UIMessageChunks", () => {
13
13
  it("updates a UIMessage with a tool call and follow up", async () => {
@@ -200,427 +200,408 @@ describe("UIMessageChunks - continuation stream", () => {
200
200
  });
201
201
 
202
202
  describe("mergeDeltas", () => {
203
- it("merges a single text-delta into a message", () => {
204
- const streamId = "s1";
205
- const deltas = [
206
- {
207
- streamId,
208
- start: 0,
209
- end: 5,
210
- parts: [{ type: "text-delta", id: "1", text: "Hello" }],
211
- } satisfies StreamDelta,
212
- ];
213
- const [messages, newStreams, changed] = deriveUIMessagesFromTextStreamParts(
214
- "thread1",
215
- [{ streamId, order: 1, stepOrder: 0, status: "streaming" }],
216
- [],
217
- deltas,
218
- );
219
- expect(messages).toHaveLength(1);
220
- expect(messages[0].text).toBe("Hello");
221
- expect(messages[0].role).toBe("assistant");
222
- expect(changed).toBe(true);
223
- expect(newStreams[0].cursor).toBe(5);
224
- });
225
-
226
- it("merges multiple deltas for the same stream", () => {
227
- const streamId = "s1";
228
- const deltas = [
229
- {
230
- streamId,
231
- start: 0,
232
- end: 5,
233
- parts: [{ type: "text-delta", id: "1", text: "Hello" }],
234
- },
235
- {
236
- streamId,
237
- start: 5,
238
- end: 11,
239
- parts: [{ type: "text-delta", id: "2", text: " World!" }],
240
- },
241
- ];
242
- const [messages, newStreams, changed] = deriveUIMessagesFromTextStreamParts(
243
- "thread1",
244
- [{ streamId, order: 1, stepOrder: 0, status: "streaming" }],
245
- [],
246
- deltas,
247
- );
248
- expect(messages).toHaveLength(1);
249
- expect(messages[0].text).toBe("Hello World!");
250
- expect(changed).toBe(true);
251
- expect(newStreams[0].cursor).toBe(11);
252
- });
203
+ it("incremental apply only consumes parts past the cursor (no re-processing)", () => {
204
+ const N = 500;
205
+ const streamId = "s-perf";
206
+ const toolCallId = "tool-0";
207
+ const streamMessage = {
208
+ streamId,
209
+ status: "streaming" as const,
210
+ order: 0,
211
+ stepOrder: 0,
212
+ format: "UIMessageChunk" as const,
213
+ agentName: "agent1",
214
+ };
253
215
 
254
- it("handles tool-call and tool-result parts", () => {
255
- const streamId = "s2";
256
- const deltas = [
216
+ // One StreamDelta with preamble, then N deltas each with one tool-input-delta
217
+ const allDeltas: StreamDelta[] = [
257
218
  {
258
219
  streamId,
259
220
  start: 0,
260
221
  end: 1,
261
222
  parts: [
262
- {
263
- type: "tool-call",
264
- toolCallId: "call1",
265
- toolName: "myTool",
266
- input: "What's the meaning of life?",
267
- },
268
- ],
269
- } satisfies StreamDelta,
270
- {
223
+ { type: "start" },
224
+ { type: "start-step" },
225
+ { type: "tool-input-start", toolCallId, toolName: "myTool" },
226
+ ] as UIMessageChunk[],
227
+ },
228
+ ...Array.from({ length: N }, (_, i) => ({
271
229
  streamId,
272
- start: 1,
273
- end: 2,
230
+ start: i + 1,
231
+ end: i + 2,
274
232
  parts: [
275
233
  {
276
- type: "tool-result",
277
- toolCallId: "call1",
278
- toolName: "myTool",
279
- input: undefined,
280
- output: "42",
281
- } satisfies TypedToolResult<{ myTool: Tool }>,
234
+ type: "tool-input-delta",
235
+ toolCallId,
236
+ inputTextDelta: "x",
237
+ } as UIMessageChunk,
282
238
  ],
283
- } satisfies StreamDelta,
239
+ })),
284
240
  ];
285
- const [[message], _, changed] = deriveUIMessagesFromTextStreamParts(
286
- "thread1",
287
- [{ streamId, order: 2, stepOrder: 0, status: "streaming" }],
288
- [],
289
- deltas,
290
- );
291
- expect(message).toBeDefined();
292
- expect(message.role).toBe("assistant");
293
- const content = message.parts;
294
- expect(content).toEqual([
295
- {
296
- type: "tool-myTool",
297
- toolCallId: "call1",
298
- input: "What's the meaning of life?",
299
- output: "42",
300
- state: "output-available",
301
- } satisfies ToolUIPart,
302
- ]);
303
- expect(changed).toBe(true);
304
- });
305
241
 
306
- it("returns changed=false if no new deltas", () => {
307
- const streamId = "s3";
308
- const deltas: StreamDelta[] = [];
309
- const [, newStreams, changed] = deriveUIMessagesFromTextStreamParts(
310
- "thread1",
311
- [{ streamId, order: 3, stepOrder: 0, status: "streaming" }],
312
- [],
313
- deltas,
242
+ // Simulate the hook: process one delta at a time, tracking cursor + prior message
243
+ let cursor = 0;
244
+ let uiMessage = blankUIMessage(streamMessage, "thread-perf");
245
+ let streamState = emptyIncrementalStreamState();
246
+ let totalPartsProcessed = 0;
247
+
248
+ for (let i = 0; i <= N; i++) {
249
+ const available = allDeltas.slice(0, i + 1);
250
+ const { parts: newParts, cursor: newCursor } = getParts<UIMessageChunk>(
251
+ available,
252
+ cursor,
253
+ );
254
+ if (newParts.length > 0) {
255
+ totalPartsProcessed += newParts.length;
256
+ ({ message: uiMessage, streamState } = applyUIMessageChunksIncremental(
257
+ structuredClone(uiMessage),
258
+ newParts,
259
+ streamState,
260
+ ));
261
+ cursor = newCursor;
262
+ }
263
+ }
264
+
265
+ // Each delta part is handed to applyUIMessageChunksIncremental exactly
266
+ // once across all batches (cursor slicing — no re-processing of prior
267
+ // parts). N tool-input-deltas + 3 preamble parts. The end-to-end O(N)
268
+ // claim is proven by the PR's 21,000 ms → 73 ms benchmark, not by this
269
+ // unit test.
270
+ expect(totalPartsProcessed).toBe(N + 3);
271
+
272
+ // Correctness: the raw accumulator holds "x" repeated N times across batches
273
+ expect(streamState.toolInputText[toolCallId]).toBe("x".repeat(N));
274
+ const toolPart = uiMessage.parts.find(
275
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === toolCallId,
314
276
  );
315
- expect(changed).toBe(false);
316
- expect(newStreams[0].cursor).toBe(0);
277
+ expect(toolPart).toBeDefined();
317
278
  });
318
279
 
319
- it("handles multiple streams and sorts by order/stepOrder", () => {
320
- const deltas = [
321
- {
322
- streamId: "s2",
323
- start: 0,
324
- end: 3,
325
- parts: [{ type: "text-delta", id: "1", text: "B" }],
326
- } satisfies StreamDelta,
327
- {
328
- streamId: "s1",
329
- start: 0,
330
- end: 3,
331
- parts: [{ type: "text-delta", id: "2", text: "A" }],
332
- } satisfies StreamDelta,
333
- ];
334
- const [messages, _, changed] = deriveUIMessagesFromTextStreamParts(
335
- "thread1",
280
+ it("applyUIMessageChunksIncremental: text-delta accumulation across calls", () => {
281
+ const streamMessage = {
282
+ streamId: "s-text",
283
+ status: "streaming" as const,
284
+ order: 0,
285
+ stepOrder: 0,
286
+ format: "UIMessageChunk" as const,
287
+ agentName: "a",
288
+ };
289
+ let msg = blankUIMessage(streamMessage, "thread-text");
290
+ let state = emptyIncrementalStreamState();
291
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
292
+ msg,
336
293
  [
337
- { streamId: "s1", order: 1, stepOrder: 0, status: "streaming" },
338
- { streamId: "s2", order: 2, stepOrder: 0, status: "streaming" },
339
- ],
340
- [],
341
- deltas,
342
- );
343
- expect(messages).toHaveLength(2);
344
- expect(messages[0].text).toBe("A");
345
- expect(messages[1].text).toBe("B");
346
- expect(changed).toBe(true);
347
- // Sorted by order
348
- expect(messages[0].order).toBe(1);
349
- expect(messages[1].order).toBe(2);
350
- });
294
+ { type: "start" },
295
+ { type: "start-step" },
296
+ { type: "text-start", id: "t0" },
297
+ { type: "text-delta", id: "t0", delta: "Hello " },
298
+ ] as UIMessageChunk[],
299
+ state,
300
+ ));
301
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
302
+ msg,
303
+ [{ type: "text-delta", id: "t0", delta: "world" }] as UIMessageChunk[],
304
+ state,
305
+ ));
306
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
307
+ msg,
308
+ [
309
+ { type: "text-delta", id: "t0", delta: "!" },
310
+ { type: "text-end", id: "t0" },
311
+ ] as UIMessageChunk[],
312
+ state,
313
+ ));
351
314
 
352
- it("does not duplicate text content when merging sequential text-deltas", () => {
353
- const streamId = "s4";
354
- const deltas = [
355
- {
356
- streamId,
357
- start: 0,
358
- end: 5,
359
- parts: [{ type: "text-delta", id: "1", text: "Hello" }],
360
- },
361
- {
362
- streamId,
363
- start: 5,
364
- end: 11,
365
- parts: [{ type: "text-delta", id: "2", text: " World!" }],
366
- },
367
- {
368
- streamId,
369
- start: 11,
370
- end: 12,
371
- parts: [{ type: "text-delta", id: "3", text: "!" }],
372
- },
373
- ] satisfies StreamDelta[];
374
- const [messages] = deriveUIMessagesFromTextStreamParts(
375
- "thread1",
376
- [{ streamId, order: 4, stepOrder: 0, status: "streaming" }],
377
- [],
378
- deltas,
379
- );
380
- expect(messages).toHaveLength(1);
381
- expect(messages[0].text).toBe("Hello World!!");
382
- // There should only be one text part per message
383
- const content = messages[0].parts;
384
- if (Array.isArray(content)) {
385
- const textParts = content.filter((p) => p.type === "text");
386
- expect(textParts).toHaveLength(1);
387
- expect(textParts[0].text).toBe("Hello World!!");
388
- }
315
+ const textPart = msg.parts.find((p) => p.type === "text") as
316
+ | { text: string; state: string }
317
+ | undefined;
318
+ expect(textPart?.text).toBe("Hello world!");
319
+ expect(textPart?.state).toBe("done");
320
+ expect(msg.text).toBe("Hello world!");
389
321
  });
390
322
 
391
- it("does not duplicate reasoning parts", () => {
392
- const streamId = "s6";
393
- const deltas = [
394
- {
395
- streamId,
396
- start: 0,
397
- end: 1,
398
- parts: [
399
- { type: "reasoning-start", id: "1" },
400
- { type: "reasoning-delta", id: "1", text: "I'm thinking..." },
401
- ],
402
- },
403
- {
404
- streamId,
405
- start: 1,
406
- end: 2,
407
- parts: [
408
- { type: "reasoning-delta", id: "1", text: " Still thinking..." },
409
- ],
410
- },
411
- {
412
- streamId,
413
- start: 2,
414
- end: 3,
415
- parts: [{ type: "reasoning-end", id: "1" }],
416
- },
417
- ];
418
- const [messages] = deriveUIMessagesFromTextStreamParts(
419
- "thread1",
420
- [{ streamId, order: 6, stepOrder: 0, status: "streaming" }],
421
- [],
422
- deltas,
323
+ it("applyUIMessageChunksIncremental: tool-output-available preserves input and sets fields", async () => {
324
+ const streamMessage = {
325
+ streamId: "s-tool-out",
326
+ status: "streaming" as const,
327
+ order: 0,
328
+ stepOrder: 0,
329
+ format: "UIMessageChunk" as const,
330
+ agentName: "a",
331
+ };
332
+ let msg = blankUIMessage(streamMessage, "thread-tool-out");
333
+ let state = emptyIncrementalStreamState();
334
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
335
+ msg,
336
+ [
337
+ { type: "start" },
338
+ { type: "start-step" },
339
+ { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" },
340
+ {
341
+ type: "tool-input-available",
342
+ toolCallId: "c1",
343
+ toolName: "myTool",
344
+ input: { q: "hi" },
345
+ },
346
+ ] as UIMessageChunk[],
347
+ state,
348
+ ));
349
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
350
+ msg,
351
+ [
352
+ {
353
+ type: "tool-output-available",
354
+ toolCallId: "c1",
355
+ output: { result: "ok" },
356
+ preliminary: true,
357
+ providerExecuted: true,
358
+ },
359
+ ] as UIMessageChunk[],
360
+ state,
361
+ ));
362
+
363
+ const toolPart = msg.parts.find(
364
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
365
+ );
366
+ expect(toolPart?.state).toBe("output-available");
367
+ expect(toolPart?.input).toEqual({ q: "hi" });
368
+ expect((toolPart as { output?: unknown }).output).toEqual({ result: "ok" });
369
+ expect((toolPart as { preliminary?: boolean }).preliminary).toBe(true);
370
+ expect((toolPart as { providerExecuted?: boolean }).providerExecuted).toBe(
371
+ true,
423
372
  );
424
- expect(messages).toHaveLength(1);
425
- if (Array.isArray(messages[0].parts)) {
426
- const reasoningParts = messages[0].parts.filter(
427
- (p) => p.type === "reasoning",
428
- );
429
- expect(reasoningParts).toHaveLength(1);
430
- expect(reasoningParts[0].text).toBe("I'm thinking... Still thinking...");
431
- expect(reasoningParts[0].state).toBe("done");
432
- }
433
373
  });
434
374
 
435
- it("applyDeltasToStreamMessage is idempotent and does not duplicate content", () => {
436
- const streamId = "s7";
375
+ it("applyUIMessageChunksIncremental: tool-input-error sets rawInput and clears input for static tools", async () => {
437
376
  const streamMessage = {
438
- streamId,
439
- order: 7,
377
+ streamId: "s-tool-err",
378
+ status: "streaming" as const,
379
+ order: 0,
440
380
  stepOrder: 0,
441
- status: "streaming",
442
- } satisfies StreamMessage;
443
- const deltas = [
444
- {
445
- streamId,
446
- start: 0,
447
- end: 5,
448
- parts: [{ type: "text-delta", id: "1", text: "Hello" }],
449
- },
450
- {
451
- streamId,
452
- start: 5,
453
- end: 11,
454
- parts: [{ type: "text-delta", id: "2", text: " World!" }],
455
- },
456
- ];
457
- // First call: apply both deltas
458
- let [result, changed] = updateFromTextStreamParts(
459
- "thread1",
460
- streamMessage,
461
- undefined,
462
- deltas,
463
- );
464
- expect(result.message.text).toBe("Hello World!");
465
- // Second call: re-apply the same deltas (should not duplicate)
466
- [result, changed] = updateFromTextStreamParts(
467
- "thread1",
468
- streamMessage,
469
- result,
470
- deltas,
471
- );
472
- expect(result.message.text).toBe("Hello World!");
473
- // Third call: add a new delta
474
- const moreDeltas = [
475
- ...deltas,
476
- {
477
- streamId,
478
- start: 11,
479
- end: 12,
480
- parts: [{ type: "text-delta", id: "3", text: "!" }],
481
- },
482
- ];
483
- [result, changed] = updateFromTextStreamParts(
484
- "thread1",
485
- streamMessage,
486
- result,
487
- moreDeltas,
381
+ format: "UIMessageChunk" as const,
382
+ agentName: "a",
383
+ };
384
+ let msg = blankUIMessage(streamMessage, "thread-tool-err");
385
+ let state = emptyIncrementalStreamState();
386
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
387
+ msg,
388
+ [
389
+ { type: "start" },
390
+ { type: "start-step" },
391
+ { type: "tool-input-start", toolCallId: "c2", toolName: "myTool" },
392
+ ] as UIMessageChunk[],
393
+ state,
394
+ ));
395
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
396
+ msg,
397
+ [
398
+ {
399
+ type: "tool-input-error",
400
+ toolCallId: "c2",
401
+ toolName: "myTool",
402
+ input: { bad: "args" },
403
+ errorText: "validation failed",
404
+ },
405
+ ] as UIMessageChunk[],
406
+ state,
407
+ ));
408
+
409
+ const toolPart = msg.parts.find(
410
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c2",
488
411
  );
489
- expect(changed).toBe(true);
490
- expect(result.message.text).toBe("Hello World!!");
491
- // Re-apply all deltas again (should still not duplicate)
492
- [result, changed] = updateFromTextStreamParts(
493
- "thread1",
494
- streamMessage,
495
- result,
496
- moreDeltas,
412
+ expect(toolPart?.state).toBe("output-error");
413
+ expect((toolPart as { errorText?: string }).errorText).toBe(
414
+ "validation failed",
497
415
  );
498
- expect(changed).toBe(false);
499
- expect(result.message.text).toBe("Hello World!!");
416
+ expect(toolPart?.input).toBeUndefined();
417
+ expect((toolPart as { rawInput?: unknown }).rawInput).toEqual({
418
+ bad: "args",
419
+ });
500
420
  });
501
421
 
502
- it("mergeDeltas is pure and does not mutate inputs", () => {
503
- const streamId = "s8";
504
- const streamMessages = [
505
- { streamId, order: 8, stepOrder: 0, status: "streaming" },
506
- ] satisfies StreamMessage[];
507
- const deltas = [
508
- {
509
- streamId,
510
- start: 0,
511
- end: 5,
512
- parts: [{ type: "text-delta", id: "1", text: "Hello" }],
513
- },
514
- {
515
- streamId,
516
- start: 5,
517
- end: 11,
518
- parts: [{ type: "text-delta", id: "2", text: " World!" }],
519
- },
520
- ];
521
- // Deep freeze inputs to catch mutation
522
- function deepFreeze(obj: unknown): unknown {
523
- if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
524
- Object.freeze(obj);
525
- for (const key of Object.keys(obj)) {
526
- deepFreeze((obj as Record<string, unknown>)[key]);
527
- }
528
- }
529
- return obj;
530
- }
531
- deepFreeze(streamMessages);
532
- deepFreeze(deltas);
533
- const [messages1, streams1, changed1] = deriveUIMessagesFromTextStreamParts(
534
- "thread1",
535
- streamMessages,
536
- [],
537
- deltas,
538
- );
539
- const [messages2, streams2, changed2] = deriveUIMessagesFromTextStreamParts(
540
- "thread1",
541
- streamMessages,
542
- [],
543
- deltas,
544
- );
545
- expect(messages1.map((m) => omit(m, ["_creationTime"]))).toEqual(
546
- messages2.map((m) => omit(m, ["_creationTime"])),
422
+ it("accumulates tool input across a batch boundary", async () => {
423
+ const streamMessage = {
424
+ streamId: "s-tool-split",
425
+ status: "streaming" as const,
426
+ order: 0,
427
+ stepOrder: 0,
428
+ format: "UIMessageChunk" as const,
429
+ agentName: "a",
430
+ };
431
+ let msg = blankUIMessage(streamMessage, "thread-tool-split");
432
+ let state = emptyIncrementalStreamState();
433
+
434
+ // Batch A: preamble + the first half of the JSON input.
435
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
436
+ msg,
437
+ [
438
+ { type: "start" },
439
+ { type: "start-step" },
440
+ { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" },
441
+ {
442
+ type: "tool-input-delta",
443
+ toolCallId: "c1",
444
+ inputTextDelta: '{"a":1',
445
+ },
446
+ ] as UIMessageChunk[],
447
+ state,
448
+ ));
449
+ const afterA = msg.parts.find(
450
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
547
451
  );
548
- expect(
549
- streams1.map((s) => ({
550
- ...s,
551
- message: omit(s.message, ["_creationTime"]),
552
- })),
553
- ).toEqual(
554
- streams2.map((s) => ({
555
- ...s,
556
- message: omit(s.message, ["_creationTime"]),
557
- })),
452
+ // Mid-stream: JSON is incomplete, input stays unset.
453
+ expect(afterA?.input).toBeUndefined();
454
+
455
+ // Batch B: the remainder of the JSON input.
456
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
457
+ msg,
458
+ [
459
+ {
460
+ type: "tool-input-delta",
461
+ toolCallId: "c1",
462
+ inputTextDelta: ',"b":2}',
463
+ },
464
+ ] as UIMessageChunk[],
465
+ state,
466
+ ));
467
+ const afterB = msg.parts.find(
468
+ (p): p is ToolUIPart => "toolCallId" in p && p.toolCallId === "c1",
558
469
  );
559
- expect(changed1).toBe(changed2);
560
- // Inputs should remain unchanged
561
- expect(streamMessages).toMatchObject([
562
- { streamId, order: 8, stepOrder: 0, status: "streaming" },
563
- ]);
564
- expect(deltas).toEqual([
565
- {
566
- streamId,
567
- start: 0,
568
- end: 5,
569
- parts: [{ type: "text-delta", id: "1", text: "Hello" }],
570
- },
571
- {
572
- streamId,
573
- start: 5,
574
- end: 11,
575
- parts: [{ type: "text-delta", id: "2", text: " World!" }],
576
- },
577
- ]);
470
+ // Complete JSON is parsed once the accumulator is valid.
471
+ expect(afterB?.input).toEqual({ a: 1, b: 2 });
472
+ expect(state.toolInputText["c1"]).toBe('{"a":1,"b":2}');
578
473
  });
579
474
 
580
- it("handles streaming tool-approval-request and updates tool state", () => {
581
- const streamId = "s10";
582
- const deltas = [
583
- {
584
- streamId,
585
- start: 0,
586
- end: 1,
587
- parts: [
588
- {
589
- type: "tool-call",
590
- toolCallId: "call1",
591
- toolName: "dangerousTool",
592
- input: { action: "delete" },
593
- },
594
- ],
595
- } satisfies StreamDelta,
596
- {
597
- streamId,
598
- start: 1,
599
- end: 2,
600
- parts: [
601
- {
602
- type: "tool-approval-request",
603
- toolCallId: "call1",
604
- approvalId: "approval1",
605
- },
606
- ],
607
- } satisfies StreamDelta,
475
+ it("pushes file parts and merges message metadata in later batches", async () => {
476
+ const streamMessage = {
477
+ streamId: "s-file-meta",
478
+ status: "streaming" as const,
479
+ order: 0,
480
+ stepOrder: 0,
481
+ format: "UIMessageChunk" as const,
482
+ agentName: "a",
483
+ };
484
+ let msg = blankUIMessage(streamMessage, "thread-file-meta");
485
+ let state = emptyIncrementalStreamState();
486
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
487
+ msg,
488
+ [{ type: "start" }, { type: "start-step" }] as UIMessageChunk[],
489
+ state,
490
+ ));
491
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
492
+ msg,
493
+ [
494
+ {
495
+ type: "file",
496
+ mediaType: "image/png",
497
+ url: "https://example.com/a.png",
498
+ },
499
+ { type: "message-metadata", messageMetadata: { foo: "bar" } },
500
+ ] as UIMessageChunk[],
501
+ state,
502
+ ));
503
+
504
+ const filePart = msg.parts.find((p) => p.type === "file") as
505
+ | { mediaType: string; url: string }
506
+ | undefined;
507
+ expect(filePart?.mediaType).toBe("image/png");
508
+ expect(filePart?.url).toBe("https://example.com/a.png");
509
+ expect(msg.metadata).toEqual({ foo: "bar" });
510
+ });
511
+
512
+ it("tracks concurrent text parts by id across batches", async () => {
513
+ const streamMessage = {
514
+ streamId: "s-multi-text",
515
+ status: "streaming" as const,
516
+ order: 0,
517
+ stepOrder: 0,
518
+ format: "UIMessageChunk" as const,
519
+ agentName: "a",
520
+ };
521
+ let msg = blankUIMessage(streamMessage, "thread-multi-text");
522
+ let state = emptyIncrementalStreamState();
523
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
524
+ msg,
525
+ [
526
+ { type: "start" },
527
+ { type: "start-step" },
528
+ { type: "text-start", id: "t0" },
529
+ { type: "text-start", id: "t1" },
530
+ { type: "text-delta", id: "t0", delta: "A" },
531
+ ] as UIMessageChunk[],
532
+ state,
533
+ ));
534
+ // Deltas in a later batch must land on the part matching their id.
535
+ ({ message: msg, streamState: state } = applyUIMessageChunksIncremental(
536
+ msg,
537
+ [
538
+ { type: "text-delta", id: "t1", delta: "B" },
539
+ { type: "text-delta", id: "t0", delta: "C" },
540
+ ] as UIMessageChunk[],
541
+ state,
542
+ ));
543
+
544
+ const textParts = msg.parts.filter((p) => p.type === "text") as Array<{
545
+ text: string;
546
+ }>;
547
+ expect(textParts.map((p) => p.text)).toEqual(["AC", "B"]);
548
+ });
549
+
550
+ it("incremental batches match the SDK processing the full stream", async () => {
551
+ const streamMessage = {
552
+ streamId: "s-equiv",
553
+ status: "streaming" as const,
554
+ order: 0,
555
+ stepOrder: 0,
556
+ format: "UIMessageChunk" as const,
557
+ agentName: "a",
558
+ };
559
+ const batches: UIMessageChunk[][] = [
560
+ [
561
+ { type: "start" },
562
+ { type: "start-step" },
563
+ { type: "text-start", id: "t0" },
564
+ { type: "text-delta", id: "t0", delta: "Hello " },
565
+ ] as UIMessageChunk[],
566
+ [
567
+ { type: "text-delta", id: "t0", delta: "world" },
568
+ { type: "text-end", id: "t0" },
569
+ { type: "tool-input-start", toolCallId: "c1", toolName: "myTool" },
570
+ { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '{"q":' },
571
+ ] as UIMessageChunk[],
572
+ [
573
+ { type: "tool-input-delta", toolCallId: "c1", inputTextDelta: '"hi"}' },
574
+ {
575
+ type: "tool-input-available",
576
+ toolCallId: "c1",
577
+ toolName: "myTool",
578
+ input: { q: "hi" },
579
+ },
580
+ {
581
+ type: "tool-output-available",
582
+ toolCallId: "c1",
583
+ output: { ok: true },
584
+ },
585
+ { type: "finish-step" },
586
+ { type: "finish" },
587
+ ] as UIMessageChunk[],
608
588
  ];
609
- const [[message], _, changed] = deriveUIMessagesFromTextStreamParts(
610
- "thread1",
611
- [{ streamId, order: 10, stepOrder: 0, status: "streaming" }],
612
- [],
613
- deltas,
589
+
590
+ // SDK: process the entire stream at once.
591
+ const sdkMsg = await updateFromUIMessageChunks(
592
+ blankUIMessage(streamMessage, "thread-equiv"),
593
+ batches.flat(),
614
594
  );
615
- expect(message).toBeDefined();
616
- expect(message.role).toBe("assistant");
617
- expect(changed).toBe(true);
618
595
 
619
- const toolPart = message.parts.find(
620
- (p) => p.type === "tool-dangerousTool",
621
- ) as any;
622
- expect(toolPart).toBeDefined();
623
- expect(toolPart.state).toBe("approval-requested");
624
- expect(toolPart.approval).toEqual({ id: "approval1" });
596
+ // Incremental: process batch by batch, threading state.
597
+ let incMsg = blankUIMessage(streamMessage, "thread-equiv");
598
+ let state = emptyIncrementalStreamState();
599
+ for (const batch of batches) {
600
+ ({ message: incMsg, streamState: state } =
601
+ applyUIMessageChunksIncremental(incMsg, batch, state));
602
+ }
603
+
604
+ expect(incMsg.parts).toEqual(sdkMsg.parts);
605
+ expect(incMsg.text).toBe(sdkMsg.text);
625
606
  });
626
607
  });