@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
@@ -4,6 +4,7 @@ import {
4
4
  serializeDataOrUrl,
5
5
  toModelMessageDataOrUrl,
6
6
  serializeMessage,
7
+ serializeNewMessagesInStep,
7
8
  toModelMessage,
8
9
  serializeContent,
9
10
  toModelMessageContent,
@@ -16,7 +17,7 @@ import fs from "fs";
16
17
  import path from "path";
17
18
  import type { SerializedContent } from "./mapping.js";
18
19
  import { validate } from "convex-helpers/validators";
19
- import type { ToolResultPart } from "ai";
20
+ import type { ModelMessage, StepResult, ToolResultPart, ToolSet } from "ai";
20
21
  import type { Infer } from "convex/values";
21
22
 
22
23
  const testAssetsDir = path.join(__dirname, "../test-assets");
@@ -259,6 +260,245 @@ describe("mapping", () => {
259
260
  expect((content as unknown[])[0]).toMatchObject(approvalResponse);
260
261
  });
261
262
 
263
+ describe("serializeNewMessagesInStep", () => {
264
+ const ctx = {
265
+ runAction: async () => undefined,
266
+ runMutation: async () => undefined,
267
+ storage: {
268
+ store: async () => "storageId",
269
+ getUrl: async () => "https://example.com/file",
270
+ delete: async () => undefined,
271
+ },
272
+ } as unknown as ActionCtx;
273
+ const component = api as unknown as AgentComponent;
274
+
275
+ const step0Messages: ModelMessage[] = [
276
+ {
277
+ role: "assistant",
278
+ content: [
279
+ { type: "tool-call", toolCallId: "c1", toolName: "search", input: {} },
280
+ ],
281
+ },
282
+ {
283
+ role: "tool",
284
+ content: [
285
+ {
286
+ type: "tool-result",
287
+ toolCallId: "c1",
288
+ toolName: "search",
289
+ output: { type: "text", value: "ok" },
290
+ },
291
+ ],
292
+ },
293
+ ];
294
+ const step1Messages: ModelMessage[] = [
295
+ ...step0Messages,
296
+ { role: "assistant", content: [{ type: "text", text: "thinking" }] },
297
+ ];
298
+ const step2Messages: ModelMessage[] = [
299
+ ...step1Messages,
300
+ {
301
+ role: "assistant",
302
+ content: [
303
+ { type: "tool-call", toolCallId: "c2", toolName: "search", input: {} },
304
+ ],
305
+ },
306
+ {
307
+ role: "tool",
308
+ content: [
309
+ {
310
+ type: "tool-result",
311
+ toolCallId: "c2",
312
+ toolName: "search",
313
+ output: { type: "text", value: "done" },
314
+ },
315
+ ],
316
+ },
317
+ ];
318
+
319
+ const makeStep = (messages: ModelMessage[]): StepResult<ToolSet> =>
320
+ ({
321
+ content: [],
322
+ text: "",
323
+ reasoning: [],
324
+ reasoningText: undefined,
325
+ files: [],
326
+ sources: [],
327
+ toolCalls: [],
328
+ staticToolCalls: [],
329
+ dynamicToolCalls: [],
330
+ toolResults: [],
331
+ staticToolResults: [],
332
+ dynamicToolResults: [],
333
+ finishReason: "stop",
334
+ rawFinishReason: undefined,
335
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
336
+ warnings: undefined,
337
+ request: {},
338
+ response: {
339
+ id: "resp",
340
+ timestamp: new Date(),
341
+ modelId: "test",
342
+ messages,
343
+ },
344
+ providerMetadata: undefined,
345
+ }) as unknown as StepResult<ToolSet>;
346
+
347
+ const contentTypes = (msg: { content: unknown }): string[] => {
348
+ const c = msg.content;
349
+ if (!Array.isArray(c)) return ["text"];
350
+ return c.map((p: { type?: string }) => p.type ?? "?");
351
+ };
352
+
353
+ test("first step (count=0) serializes all response messages", async () => {
354
+ const res = await serializeNewMessagesInStep(
355
+ ctx,
356
+ component,
357
+ makeStep(step0Messages),
358
+ undefined,
359
+ 0,
360
+ );
361
+ expect(res.messages).toHaveLength(2);
362
+ expect(res.messages[0].message.role).toBe("assistant");
363
+ expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
364
+ expect(res.messages[1].message.role).toBe("tool");
365
+ expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
366
+ });
367
+
368
+ test("middle step (count=2) serializes only the new text message", async () => {
369
+ const res = await serializeNewMessagesInStep(
370
+ ctx,
371
+ component,
372
+ makeStep(step1Messages),
373
+ undefined,
374
+ 2,
375
+ );
376
+ expect(res.messages).toHaveLength(1);
377
+ expect(res.messages[0].message.role).toBe("assistant");
378
+ expect(contentTypes(res.messages[0].message)).toEqual(["text"]);
379
+ });
380
+
381
+ test("multi-message step (count=3) serializes the new tool-call + tool-result pair", async () => {
382
+ const res = await serializeNewMessagesInStep(
383
+ ctx,
384
+ component,
385
+ makeStep(step2Messages),
386
+ undefined,
387
+ 3,
388
+ );
389
+ expect(res.messages).toHaveLength(2);
390
+ expect(res.messages[0].message.role).toBe("assistant");
391
+ expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
392
+ expect(res.messages[1].message.role).toBe("tool");
393
+ expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
394
+ });
395
+
396
+ // Regression test for the actually-broken shape: a single step appended
397
+ // assistant(text) + assistant(tool-call) + tool(tool-result), so the new
398
+ // tail has length 3 and the last message is a tool message. The old
399
+ // heuristic took `slice(-2)` whenever the last role was "tool" and would
400
+ // have dropped the leading text. The watermark returns all three.
401
+ test("returns all three messages when a step adds text + tool-call + tool-result", async () => {
402
+ const stepMessages: ModelMessage[] = [
403
+ ...step0Messages, // length 2
404
+ { role: "assistant", content: [{ type: "text", text: "Let me check..." }] },
405
+ {
406
+ role: "assistant",
407
+ content: [
408
+ { type: "tool-call", toolCallId: "c3", toolName: "search", input: {} },
409
+ ],
410
+ },
411
+ {
412
+ role: "tool",
413
+ content: [
414
+ {
415
+ type: "tool-result",
416
+ toolCallId: "c3",
417
+ toolName: "search",
418
+ output: { type: "text", value: "done" },
419
+ },
420
+ ],
421
+ },
422
+ ];
423
+ const res = await serializeNewMessagesInStep(
424
+ ctx,
425
+ component,
426
+ makeStep(stepMessages),
427
+ undefined,
428
+ step0Messages.length,
429
+ );
430
+ expect(res.messages).toHaveLength(3);
431
+ expect(res.messages[0].message.role).toBe("assistant");
432
+ expect(contentTypes(res.messages[0].message)).toEqual(["text"]);
433
+ expect(res.messages[1].message.role).toBe("assistant");
434
+ expect(contentTypes(res.messages[1].message)).toEqual(["tool-call"]);
435
+ expect(res.messages[2].message.role).toBe("tool");
436
+ expect(contentTypes(res.messages[2].message)).toEqual(["tool-result"]);
437
+ });
438
+
439
+ test("empty response messages slice falls back to synthetic empty assistant", async () => {
440
+ const res = await serializeNewMessagesInStep(
441
+ ctx,
442
+ component,
443
+ makeStep(step1Messages),
444
+ undefined,
445
+ step1Messages.length,
446
+ );
447
+ expect(res.messages).toHaveLength(1);
448
+ expect(res.messages[0].message.role).toBe("assistant");
449
+ expect(res.messages[0].message.content).toEqual([]);
450
+ });
451
+
452
+ // Pin the caller-drift behavior: if the watermark is past the end of
453
+ // response.messages (e.g. the caller mistracked), the slice is empty and
454
+ // we fall through to the synthetic anchor. Future "fixes" should not
455
+ // accidentally change this without intent.
456
+ test("watermark beyond response.messages.length returns the synthetic fallback", async () => {
457
+ const res = await serializeNewMessagesInStep(
458
+ ctx,
459
+ component,
460
+ makeStep(step1Messages),
461
+ undefined,
462
+ step1Messages.length + 5,
463
+ );
464
+ expect(res.messages).toHaveLength(1);
465
+ expect(res.messages[0].message.role).toBe("assistant");
466
+ expect(res.messages[0].message.content).toEqual([]);
467
+ });
468
+
469
+ // AI SDK v6 makes step.response.messages cumulative across steps:
470
+ // step N's array contains all messages from steps 0..N. Without the
471
+ // previousResponseMessageCount watermark, every multi-step save duplicates
472
+ // all prior messages. These tests demonstrate the bug and the fix.
473
+ describe("multi-step loop — previousStep watermark", () => {
474
+ test("without watermark, step 2 re-saves all cumulative messages (demonstrates the bug)", async () => {
475
+ // step2Messages = step0 (2 msgs) + step1 (1 msg) + step2 new (2 msgs) = 5 total
476
+ const res = await serializeNewMessagesInStep(
477
+ ctx,
478
+ component,
479
+ makeStep(step2Messages),
480
+ undefined,
481
+ 0,
482
+ );
483
+ expect(res.messages).toHaveLength(5);
484
+ });
485
+
486
+ test("with watermark, step 2 saves only its 2 new messages", async () => {
487
+ const step1 = makeStep(step1Messages);
488
+ const res = await serializeNewMessagesInStep(
489
+ ctx,
490
+ component,
491
+ makeStep(step2Messages),
492
+ undefined,
493
+ step1.response.messages.length,
494
+ );
495
+ expect(res.messages).toHaveLength(2);
496
+ expect(contentTypes(res.messages[0].message)).toEqual(["tool-call"]);
497
+ expect(contentTypes(res.messages[1].message)).toEqual(["tool-result"]);
498
+ });
499
+ });
500
+ });
501
+
262
502
  describe("autoDenyUnresolvedApprovals", () => {
263
503
  test("returns messages unchanged when no unresolved approvals", () => {
264
504
  const messages = [
@@ -267,13 +507,21 @@ describe("mapping", () => {
267
507
  role: "assistant" as const,
268
508
  content: [
269
509
  { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
270
- { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
510
+ {
511
+ type: "tool-approval-request",
512
+ approvalId: "ap1",
513
+ toolCallId: "tc1",
514
+ },
271
515
  ],
272
516
  },
273
517
  {
274
518
  role: "tool" as const,
275
519
  content: [
276
- { type: "tool-approval-response", approvalId: "ap1", approved: true },
520
+ {
521
+ type: "tool-approval-response",
522
+ approvalId: "ap1",
523
+ approved: true,
524
+ },
277
525
  ],
278
526
  },
279
527
  ] as any;
@@ -289,7 +537,11 @@ describe("mapping", () => {
289
537
  role: "assistant" as const,
290
538
  content: [
291
539
  { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
292
- { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
540
+ {
541
+ type: "tool-approval-request",
542
+ approvalId: "ap1",
543
+ toolCallId: "tc1",
544
+ },
293
545
  ],
294
546
  },
295
547
  { role: "user" as const, content: "new message" },
@@ -304,7 +556,9 @@ describe("mapping", () => {
304
556
  expect(denialContent[0].type).toBe("tool-approval-response");
305
557
  expect(denialContent[0].approvalId).toBe("ap1");
306
558
  expect(denialContent[0].approved).toBe(false);
307
- expect(denialContent[0].reason).toBe("auto-denied: new generation started");
559
+ expect(denialContent[0].reason).toBe(
560
+ "auto-denied: new generation started",
561
+ );
308
562
  // The new user message should follow
309
563
  expect(result[3].role).toBe("user");
310
564
  expect(result[3].content).toBe("new message");
@@ -317,8 +571,16 @@ describe("mapping", () => {
317
571
  content: [
318
572
  { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
319
573
  { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
320
- { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
321
- { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
574
+ {
575
+ type: "tool-approval-request",
576
+ approvalId: "ap1",
577
+ toolCallId: "tc1",
578
+ },
579
+ {
580
+ type: "tool-approval-request",
581
+ approvalId: "ap2",
582
+ toolCallId: "tc2",
583
+ },
322
584
  ],
323
585
  },
324
586
  ] as any;
@@ -341,14 +603,26 @@ describe("mapping", () => {
341
603
  content: [
342
604
  { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
343
605
  { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
344
- { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
345
- { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
606
+ {
607
+ type: "tool-approval-request",
608
+ approvalId: "ap1",
609
+ toolCallId: "tc1",
610
+ },
611
+ {
612
+ type: "tool-approval-request",
613
+ approvalId: "ap2",
614
+ toolCallId: "tc2",
615
+ },
346
616
  ],
347
617
  },
348
618
  {
349
619
  role: "tool" as const,
350
620
  content: [
351
- { type: "tool-approval-response", approvalId: "ap1", approved: true },
621
+ {
622
+ type: "tool-approval-response",
623
+ approvalId: "ap1",
624
+ approved: true,
625
+ },
352
626
  ],
353
627
  },
354
628
  { role: "user" as const, content: "next question" },
@@ -381,8 +655,16 @@ describe("mapping", () => {
381
655
  content: [
382
656
  { type: "tool-call", toolCallId: "tc1", toolName: "a", input: {} },
383
657
  { type: "tool-call", toolCallId: "tc2", toolName: "b", input: {} },
384
- { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" },
385
- { type: "tool-approval-request", approvalId: "ap2", toolCallId: "tc2" },
658
+ {
659
+ type: "tool-approval-request",
660
+ approvalId: "ap1",
661
+ toolCallId: "tc1",
662
+ },
663
+ {
664
+ type: "tool-approval-request",
665
+ approvalId: "ap2",
666
+ toolCallId: "tc2",
667
+ },
386
668
  ],
387
669
  },
388
670
  ] as any;
@@ -390,12 +672,8 @@ describe("mapping", () => {
390
672
  autoDenyUnresolvedApprovals(messages);
391
673
 
392
674
  expect(warnSpy).toHaveBeenCalledTimes(2);
393
- expect(warnSpy).toHaveBeenCalledWith(
394
- expect.stringContaining("ap1"),
395
- );
396
- expect(warnSpy).toHaveBeenCalledWith(
397
- expect.stringContaining("ap2"),
398
- );
675
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ap1"));
676
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ap2"));
399
677
  warnSpy.mockRestore();
400
678
  });
401
679
  });
package/src/mapping.ts CHANGED
@@ -313,24 +313,30 @@ export async function serializeResponseMessages<TOOLS extends ToolSet>(
313
313
  }
314
314
 
315
315
  /**
316
- * Serialize the new messages from a step using a heuristic to determine
317
- * which response messages are new (last 1-2 messages).
316
+ * Serialize the new response messages produced by this step.
317
+ *
318
+ * `step.response.messages` is cumulative across steps in AI SDK v6 — each
319
+ * step's array contains all messages from prior steps too. Pass
320
+ * `previousResponseMessageCount` (the prior step's `response.messages.length`,
321
+ * or `0` for the first step) so we slice only the new tail. The parameter is
322
+ * required: defaulting it would silently duplicate every prior message on
323
+ * every multi-step save.
318
324
  */
319
325
  export async function serializeNewMessagesInStep<TOOLS extends ToolSet>(
320
326
  ctx: ActionCtx,
321
327
  component: AgentComponent,
322
328
  step: StepResult<TOOLS>,
323
329
  model: ModelOrMetadata | undefined,
330
+ previousResponseMessageCount: number,
324
331
  ): Promise<{ messages: MessageWithMetadata[] }> {
325
- const hasToolMessage = step.response.messages.at(-1)?.role === "tool";
326
- let messagesToSerialize: ModelMessage[];
327
- if (hasToolMessage) {
328
- messagesToSerialize = step.response.messages.slice(-2);
329
- } else if (step.content.length) {
330
- messagesToSerialize = step.response.messages.slice(-1);
331
- } else {
332
- messagesToSerialize = [{ role: "assistant" as const, content: [] }];
333
- }
332
+ const newMessages = step.response.messages.slice(previousResponseMessageCount);
333
+ // Keep at least one message in the output so the step still anchors an
334
+ // order slot — downstream `addMessages` relies on each step contributing a
335
+ // row even when AI SDK produced no response messages.
336
+ const messagesToSerialize: ModelMessage[] =
337
+ newMessages.length > 0
338
+ ? newMessages
339
+ : [{ role: "assistant" as const, content: [] }];
334
340
  return serializeStepMessages(ctx, component, step, model, messagesToSerialize);
335
341
  }
336
342
 
@@ -4,10 +4,12 @@ import { type UIDataTypes, type UIMessageChunk, type UITools } from "ai";
4
4
  import type { StreamQuery, StreamQueryArgs } from "./types.js";
5
5
  import { type UIMessage } from "../UIMessages.js";
6
6
  import {
7
+ applyUIMessageChunksIncremental,
7
8
  blankUIMessage,
9
+ emptyIncrementalStreamState,
8
10
  getParts,
9
- updateFromUIMessageChunks,
10
- deriveUIMessagesFromTextStreamParts,
11
+ statusFromStreamStatus,
12
+ type IncrementalStreamState,
11
13
  } from "../deltas.js";
12
14
  import { useDeltaStreams } from "./useDeltaStreams.js";
13
15
 
@@ -53,6 +55,7 @@ export function useStreamingUIMessages<
53
55
  {
54
56
  uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
55
57
  cursor: number;
58
+ streamState: IncrementalStreamState;
56
59
  }
57
60
  >
58
61
  >({});
@@ -63,16 +66,23 @@ export function useStreamingUIMessages<
63
66
 
64
67
  useEffect(() => {
65
68
  if (!streams) return;
66
- // return if there are no new deltas beyond the cursors
67
69
  let noNewDeltas = true;
68
70
  for (const stream of streams) {
69
- const lastDelta = stream.deltas.at(-1);
70
- const cursor = messageState[stream.streamMessage.streamId]?.cursor;
71
- if (!cursor) {
71
+ const existingStreamState = messageState[stream.streamMessage.streamId];
72
+ const cursor = existingStreamState?.cursor;
73
+ if (existingStreamState === undefined || cursor === undefined) {
72
74
  noNewDeltas = false;
73
75
  break;
74
76
  }
75
- if (lastDelta && lastDelta.start >= cursor) {
77
+ if (stream.deltas.some((d) => d.parts.length > 0 && d.end > cursor)) {
78
+ noNewDeltas = false;
79
+ break;
80
+ }
81
+ if (
82
+ existingStreamState &&
83
+ existingStreamState.uiMessage.status !==
84
+ statusFromStreamStatus(stream.streamMessage.status)
85
+ ) {
76
86
  noNewDeltas = false;
77
87
  break;
78
88
  }
@@ -87,40 +97,58 @@ export function useStreamingUIMessages<
87
97
  {
88
98
  uiMessage: UIMessage<METADATA, DATA_PARTS, TOOLS>;
89
99
  cursor: number;
100
+ streamState: IncrementalStreamState;
90
101
  }
91
102
  > = Object.fromEntries(
92
103
  await Promise.all(
93
104
  streams.map(async ({ deltas, streamMessage }) => {
94
- const { parts, cursor } = getParts<UIMessageChunk>(deltas, 0);
95
- if (streamMessage.format === "UIMessageChunk") {
96
- // Unfortunately this can't handle resuming from a UIMessage and
97
- // adding more chunks, so we re-create it from scratch each time.
98
- const uiMessage = await updateFromUIMessageChunks(
99
- blankUIMessage(streamMessage, threadId),
100
- parts,
101
- );
102
- return [
103
- streamMessage.streamId,
104
- {
105
- uiMessage,
106
- cursor,
107
- },
108
- ];
109
- } else {
110
- const [uiMessages] = deriveUIMessagesFromTextStreamParts(
111
- threadId,
112
- [streamMessage],
113
- [],
114
- deltas,
115
- );
105
+ const streamId = streamMessage.streamId;
106
+ const existing = messageState[streamId];
107
+ const fromCursor = existing?.cursor ?? 0;
108
+ const status = statusFromStreamStatus(streamMessage.status);
109
+ const prevState =
110
+ existing?.streamState ?? emptyIncrementalStreamState();
111
+
112
+ const { parts: newParts, cursor } = getParts<UIMessageChunk>(
113
+ deltas,
114
+ fromCursor,
115
+ );
116
+
117
+ const base =
118
+ existing?.uiMessage ??
119
+ blankUIMessage(streamMessage, threadId as string);
120
+
121
+ if (newParts.length === 0) {
122
+ if (existing && existing.uiMessage.status !== status) {
123
+ return [
124
+ streamId,
125
+ {
126
+ uiMessage: { ...existing.uiMessage, status },
127
+ cursor: existing.cursor,
128
+ streamState: prevState,
129
+ },
130
+ ];
131
+ }
116
132
  return [
117
- streamMessage.streamId,
118
- {
119
- uiMessage: uiMessages[0],
120
- cursor,
121
- },
133
+ streamId,
134
+ existing ?? { uiMessage: base, cursor: 0, streamState: prevState },
122
135
  ];
123
136
  }
137
+
138
+ const { message, streamState } = applyUIMessageChunksIncremental(
139
+ base as UIMessage,
140
+ newParts,
141
+ prevState,
142
+ );
143
+ message.status = status;
144
+ return [
145
+ streamId,
146
+ {
147
+ uiMessage: message as UIMessage<METADATA, DATA_PARTS, TOOLS>,
148
+ cursor,
149
+ streamState,
150
+ },
151
+ ];
124
152
  }),
125
153
  ),
126
154
  );
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { dedupeMessages } from "./useUIMessages.js";
2
+ import {
3
+ dedupeMessages,
4
+ mergeUIMessages,
5
+ type UIMessageLike,
6
+ } from "./useUIMessages.js";
3
7
 
4
8
  type TestMessage = {
5
9
  order: number;
@@ -8,6 +12,39 @@ type TestMessage = {
8
12
  id: string;
9
13
  };
10
14
 
15
+ type TestUIMessage = UIMessageLike & {
16
+ id: string;
17
+ key: string;
18
+ text: string;
19
+ _creationTime: number;
20
+ };
21
+
22
+ function testUIMessage({
23
+ id,
24
+ order,
25
+ stepOrder,
26
+ status,
27
+ text,
28
+ }: {
29
+ id: string;
30
+ order: number;
31
+ stepOrder: number;
32
+ status: TestUIMessage["status"];
33
+ text: string;
34
+ }): TestUIMessage {
35
+ return {
36
+ id,
37
+ key: `thread-${order}-${stepOrder}`,
38
+ order,
39
+ stepOrder,
40
+ status,
41
+ role: "assistant",
42
+ parts: [{ type: "text", text }],
43
+ text,
44
+ _creationTime: 0,
45
+ };
46
+ }
47
+
11
48
  describe("dedupeMessages", () => {
12
49
  it("should prefer messages from messages list when streaming messages are absent", () => {
13
50
  const messages: TestMessage[] = [
@@ -253,3 +290,45 @@ describe("dedupeMessages", () => {
253
290
  expect(result[0].id).toBe("messages-success");
254
291
  });
255
292
  });
293
+
294
+ describe("mergeUIMessages", () => {
295
+ it("dedupes streaming steps before combining assistant messages", () => {
296
+ const messages = [
297
+ testUIMessage({
298
+ id: "persisted-step-1",
299
+ order: 1,
300
+ stepOrder: 1,
301
+ status: "success",
302
+ text: "7",
303
+ }),
304
+ testUIMessage({
305
+ id: "persisted-step-2",
306
+ order: 1,
307
+ stepOrder: 2,
308
+ status: "success",
309
+ text: "8",
310
+ }),
311
+ ];
312
+ const streamMessages = [
313
+ testUIMessage({
314
+ id: "stream-step-2",
315
+ order: 1,
316
+ stepOrder: 2,
317
+ status: "streaming",
318
+ text: "8",
319
+ }),
320
+ ];
321
+
322
+ const result = mergeUIMessages(messages, streamMessages);
323
+
324
+ expect(result).toHaveLength(1);
325
+ expect(result[0].id).toBe("persisted-step-1");
326
+ expect(result[0].stepOrder).toBe(1);
327
+ expect(result[0].status).toBe("success");
328
+ expect(result[0].text).toBe("7 8");
329
+ expect(result[0].parts).toEqual([
330
+ { type: "text", text: "7" },
331
+ { type: "text", text: "8" },
332
+ ]);
333
+ });
334
+ });