@assistant-ui/react-ag-ui 0.0.70 → 0.1.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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/runtime/{AgUiThreadRuntimeCore.d.ts → AgUiThreadController.d.ts} +8 -12
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -0
  4. package/dist/runtime/{AgUiThreadRuntimeCore.js → AgUiThreadController.js} +302 -162
  5. package/dist/runtime/AgUiThreadController.js.map +1 -0
  6. package/dist/runtime/adapter/conversions.d.ts +4 -4
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +111 -33
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +6 -5
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +50 -62
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +0 -24
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +1 -22
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/agUiThreadState.d.ts +60 -0
  19. package/dist/runtime/agUiThreadState.d.ts.map +1 -0
  20. package/dist/runtime/agUiThreadState.js +95 -0
  21. package/dist/runtime/agUiThreadState.js.map +1 -0
  22. package/dist/runtime/event-parser.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.js +45 -48
  24. package/dist/runtime/event-parser.js.map +1 -1
  25. package/dist/runtime/mcp-tool-result.d.ts +3 -1
  26. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  27. package/dist/runtime/mcp-tool-result.js +6 -3
  28. package/dist/runtime/mcp-tool-result.js.map +1 -1
  29. package/dist/runtime/types.d.ts +13 -35
  30. package/dist/runtime/types.d.ts.map +1 -1
  31. package/dist/runtime/types.js.map +1 -1
  32. package/dist/useAgUiRuntime.d.ts.map +1 -1
  33. package/dist/useAgUiRuntime.js +4 -2
  34. package/dist/useAgUiRuntime.js.map +1 -1
  35. package/package.json +13 -12
  36. package/src/runtime/{AgUiThreadRuntimeCore.interrupt-cancel.test.ts → AgUiThreadController.interrupt-cancel.test.ts} +4 -4
  37. package/src/runtime/{AgUiThreadRuntimeCore.test.ts → AgUiThreadController.test.ts} +35 -6
  38. package/src/runtime/{AgUiThreadRuntimeCore.ts → AgUiThreadController.ts} +471 -169
  39. package/src/runtime/adapter/conversions.content.test.ts +37 -5
  40. package/src/runtime/adapter/conversions.tools.test.ts +2 -1
  41. package/src/runtime/adapter/conversions.ts +235 -58
  42. package/src/runtime/adapter/run-aggregator.ts +84 -114
  43. package/src/runtime/adapter/subscriber.ts +1 -44
  44. package/src/runtime/agUiThreadState.test.ts +205 -0
  45. package/src/runtime/agUiThreadState.ts +130 -0
  46. package/src/runtime/event-parser.ts +40 -58
  47. package/src/runtime/mcp-tool-result.ts +16 -2
  48. package/src/runtime/types.ts +18 -28
  49. package/src/useAgUiRuntime.external-store.test.tsx +39 -0
  50. package/src/useAgUiRuntime.fast-refresh.test.tsx +3 -3
  51. package/src/useAgUiRuntime.thread-switch.test.tsx +4 -4
  52. package/src/useAgUiRuntime.ts +5 -3
  53. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +0 -1
  54. package/dist/runtime/AgUiThreadRuntimeCore.js.map +0 -1
@@ -1,9 +1,6 @@
1
1
  import { describe, expect, expectTypeOf, it } from "vitest";
2
- import {
3
- MessageSchema,
4
- UserMessageSchema,
5
- type Message as AgUiWireMessage,
6
- } from "@ag-ui/client";
2
+ import type { Message as AgUiWireMessage } from "@ag-ui/client";
3
+ import { MessageSchema, UserMessageSchema } from "@ag-ui/core/schemas";
7
4
  import type { AppendMessage } from "@assistant-ui/core";
8
5
  import {
9
6
  fromAgUiMessages,
@@ -82,6 +79,41 @@ describe("toAgUiMessages data URLs", () => {
82
79
  });
83
80
 
84
81
  describe("toAgUiMessages content metadata", () => {
82
+ it.each(["image", "document"] as const)(
83
+ "preserves a %s file source and its metadata through a snapshot round trip",
84
+ (type) => {
85
+ const part = {
86
+ type,
87
+ source: {
88
+ type: "file",
89
+ value: `${type}-handle`,
90
+ provider: "openai",
91
+ mimeType: type === "image" ? "image/png" : "application/pdf",
92
+ },
93
+ metadata: { filename: `${type}.bin`, file_id: `${type}-1` },
94
+ };
95
+ const original = {
96
+ id: "u-file",
97
+ role: "user",
98
+ content: [
99
+ { type: "text", text: "label" },
100
+ {
101
+ type: "image",
102
+ source: { type: "url", value: "https://example.com/other.png" },
103
+ },
104
+ part,
105
+ ],
106
+ metadata: { trace: "kept" },
107
+ };
108
+
109
+ const imported = fromAgUiMessages([original]);
110
+ const exported = toAgUiMessages(imported);
111
+
112
+ expect(MessageSchema.parse(exported[0])).toEqual(original);
113
+ expect(exported).toEqual([original]);
114
+ },
115
+ );
116
+
85
117
  it("emits AgUiMessage values assignable to AG-UI Message", () => {
86
118
  expectTypeOf<AgUiMessage>().toExtend<AgUiWireMessage>();
87
119
  });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, expectTypeOf, it } from "vitest";
2
- import { ToolSchema, type RunAgentParameters } from "@ag-ui/client";
2
+ import type { RunAgentParameters } from "@ag-ui/client";
3
+ import { ToolSchema } from "@ag-ui/core/schemas";
3
4
  import { toAgUiTools } from "./conversions";
4
5
 
5
6
  describe("toAgUiTools", () => {
@@ -1,6 +1,10 @@
1
1
  "use client";
2
2
 
3
- import type { InputContent, RunAgentParameters } from "@ag-ui/client";
3
+ import type {
4
+ ContentPart,
5
+ PartSource,
6
+ RunAgentParameters,
7
+ } from "@ag-ui/client";
4
8
  import { generateId } from "@assistant-ui/core";
5
9
  import type {
6
10
  ThreadMessageLike as CoreThreadMessageLike,
@@ -34,10 +38,9 @@ import { projectAgUiToolApprovals } from "./tool-approval";
34
38
  import {
35
39
  parseMcpToolCallResult,
36
40
  readMcpAppResourceUri,
41
+ toolContentText,
37
42
  } from "../mcp-tool-result";
38
43
 
39
- export type { InputContent };
40
-
41
44
  type AttachmentLike = {
42
45
  name?: string | undefined;
43
46
  contentType?: string | undefined;
@@ -67,7 +70,7 @@ export type AgUiMessage =
67
70
  | {
68
71
  id: string;
69
72
  role: "user";
70
- content: string | InputContent[];
73
+ content: string | ContentPart[];
71
74
  name?: string;
72
75
  metadata?: Record<string, unknown>;
73
76
  }
@@ -96,7 +99,7 @@ export type AgUiMessage =
96
99
  | {
97
100
  id: string;
98
101
  role: "tool";
99
- content: string;
102
+ content: string | ContentPart[];
100
103
  toolCallId: string;
101
104
  error?: string;
102
105
  metadata?: Record<string, unknown>;
@@ -137,14 +140,22 @@ function readLegacyAgUiNamespace(metadata: unknown): Record<string, unknown> {
137
140
  : {};
138
141
  }
139
142
 
140
- function withoutPersistedInterrupts(
143
+ function withoutPersistedStatusMetadata(
141
144
  wire: Record<string, unknown>,
142
145
  ): Record<string, unknown> {
143
146
  if (!isObject(wire.custom)) return wire;
144
147
  const namespaced = wire.custom[AG_UI_METADATA_NAMESPACE];
145
- if (!isObject(namespaced) || !("interrupts" in namespaced)) return wire;
146
-
147
- const { interrupts: _interrupts, ...remainingNamespace } = namespaced;
148
+ if (
149
+ !isObject(namespaced) ||
150
+ (!("interrupts" in namespaced) && !("pendingToolCallIds" in namespaced))
151
+ )
152
+ return wire;
153
+
154
+ const {
155
+ interrupts: _interrupts,
156
+ pendingToolCallIds: _pendingToolCallIds,
157
+ ...remainingNamespace
158
+ } = namespaced;
148
159
  const { [AG_UI_METADATA_NAMESPACE]: _namespace, ...remainingCustom } =
149
160
  wire.custom;
150
161
  if (Object.keys(remainingNamespace).length > 0) {
@@ -160,12 +171,17 @@ function toSnapshotCustom(
160
171
  metadata: unknown,
161
172
  state: Record<string, unknown> = {},
162
173
  ): Record<string, unknown> {
163
- const wire = withoutPersistedInterrupts(toAgUiWireMetadata(metadata) ?? {});
174
+ const wire = withoutPersistedStatusMetadata(
175
+ toAgUiWireMetadata(metadata) ?? {},
176
+ );
164
177
  const needsWireSlot =
165
178
  isObject(metadata) &&
166
179
  (Object.keys(metadata).length === 0 || AG_UI_METADATA_NAMESPACE in wire);
167
180
  const namespaced = {
168
181
  ...readLegacyAgUiNamespace(metadata),
182
+ ...(readPersistedPendingToolCallIds(metadata) !== undefined
183
+ ? { pendingToolCallIds: readPersistedPendingToolCallIds(metadata) }
184
+ : {}),
169
185
  ...state,
170
186
  ...(needsWireSlot ? { messageMetadata: wire } : {}),
171
187
  };
@@ -246,7 +262,7 @@ const readAgUiReasoningMeta = (providerMetadata: unknown) => {
246
262
  // AG-UI carries per-item extras in `metadata`; anything else on the item is
247
263
  // stripped by its schema. The part's own `filename` wins over a key of the
248
264
  // same name in the bag.
249
- const buildInputMetadata = (
265
+ const buildPartMetadata = (
250
266
  part: Record<string, unknown>,
251
267
  filename?: string | undefined,
252
268
  ) => {
@@ -260,7 +276,7 @@ const buildInputMetadata = (
260
276
  // The inverse: `filename` lands on the part's own field, everything else the
261
277
  // item carried goes back into the namespace it came from, so a snapshot echo
262
278
  // resends what the host attached.
263
- const readInputMetadata = (
279
+ const readPartMetadata = (
264
280
  metadata: unknown,
265
281
  ): {
266
282
  filename?: string | undefined;
@@ -326,9 +342,7 @@ function extractText(content: unknown): string {
326
342
  .join("\n");
327
343
  }
328
344
 
329
- type InputContentSource =
330
- | { type: "data"; value: string; mimeType: string }
331
- | { type: "url"; value: string; mimeType?: string };
345
+ type EncodedPartSource = Extract<PartSource, { type: "data" | "url" }>;
332
346
 
333
347
  type MediaInputType = "image" | "audio" | "video" | "document";
334
348
 
@@ -345,11 +359,11 @@ function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
345
359
  // `sourceType: "url"` on the part forces the url leg for non-http references,
346
360
  // and a data URL that is not base64 takes it too, since a `data` source
347
361
  // carries base64 bytes.
348
- function buildInputSource(
362
+ function buildPartSource(
349
363
  value: string,
350
364
  declaredMimeType: string | undefined,
351
365
  sourceType?: string,
352
- ): InputContentSource {
366
+ ): EncodedPartSource {
353
367
  const source = resolveFilePartSource({
354
368
  data: value,
355
369
  mimeType: declaredMimeType ?? "application/octet-stream",
@@ -368,10 +382,10 @@ function buildInputSource(
368
382
  };
369
383
  }
370
384
 
371
- function toInputContent(
385
+ function toContentPart(
372
386
  part: unknown,
373
387
  fallbackMimeType: string | undefined,
374
- ): InputContent | null {
388
+ ): ContentPart | null {
375
389
  if (!isObject(part)) return null;
376
390
  const type = getString(part, "type");
377
391
 
@@ -384,8 +398,8 @@ function toInputContent(
384
398
  if (type === "image") {
385
399
  const image = getString(part, "image");
386
400
  if (image === undefined) return null;
387
- const metadata = buildInputMetadata(part, getString(part, "filename"));
388
- const source = buildInputSource(image, fallbackMimeType);
401
+ const metadata = buildPartMetadata(part, getString(part, "filename"));
402
+ const source = buildPartSource(image, fallbackMimeType);
389
403
  return {
390
404
  type: "image",
391
405
  source:
@@ -404,12 +418,12 @@ function toInputContent(
404
418
  if (data === undefined) return null;
405
419
  const declaredMimeType = getString(part, "mimeType") || fallbackMimeType;
406
420
  const filename = getString(part, "filename");
407
- const source = buildInputSource(
421
+ const source = buildPartSource(
408
422
  data,
409
423
  declaredMimeType,
410
424
  getString(part, "sourceType"),
411
425
  );
412
- const metadata = buildInputMetadata(part, filename);
426
+ const metadata = buildPartMetadata(part, filename);
413
427
  switch (mediaTypeForMime(source.mimeType)) {
414
428
  case "image":
415
429
  return { type: "image", source, ...(metadata && { metadata }) };
@@ -430,7 +444,7 @@ function toInputContent(
430
444
  if (data === undefined || format === undefined) return null;
431
445
  return {
432
446
  type: "audio",
433
- source: buildInputSource(
447
+ source: buildPartSource(
434
448
  parseDataUrl(data)?.data ?? data,
435
449
  `audio/${format}`,
436
450
  ),
@@ -446,8 +460,7 @@ type SnapshotAttachment = NonNullable<
446
460
 
447
461
  const mediaInputTypes = new Set(["image", "audio", "video", "document"]);
448
462
 
449
- // Inverse of buildInputSource.
450
- function inputSourceToString(
463
+ function partSourceToString(
451
464
  value: unknown,
452
465
  ): { value: string; mimeType?: string; isUrl?: boolean } | null {
453
466
  if (!isObject(value)) return null;
@@ -494,23 +507,73 @@ function upgradeBinaryInputPart(
494
507
  };
495
508
  }
496
509
 
510
+ function normalizeInputPart(
511
+ rawPart: Record<string, unknown>,
512
+ ): Record<string, unknown> | null {
513
+ return getString(rawPart, "type") === "binary"
514
+ ? upgradeBinaryInputPart(rawPart)
515
+ : rawPart;
516
+ }
517
+
518
+ type ContentSlot<T> = { index: number } & T;
519
+
520
+ // Text imports as one string and encoded media as attachments, so export
521
+ // needs the wire layout to restore the original order: each file source part
522
+ // keeps its rank among media, and each text part its position among all kept
523
+ // parts unless the text is one non-empty leading part.
524
+ function toUserContentLayout(content: unknown): {
525
+ fileSourceParts: ContentSlot<{ part: Record<string, unknown> }>[];
526
+ textParts: ContentSlot<{ text: string }>[];
527
+ } {
528
+ const fileSourceParts: ContentSlot<{ part: Record<string, unknown> }>[] = [];
529
+ const textParts: ContentSlot<{ text: string }>[] = [];
530
+ if (!Array.isArray(content)) return { fileSourceParts, textParts };
531
+
532
+ let mediaIndex = 0;
533
+ for (const rawPart of content) {
534
+ if (!isObject(rawPart)) continue;
535
+ if (rawPart.type === "text" && typeof rawPart.text === "string") {
536
+ textParts.push({
537
+ index: textParts.length + mediaIndex,
538
+ text: rawPart.text,
539
+ });
540
+ continue;
541
+ }
542
+ const part = normalizeInputPart(rawPart);
543
+ if (!part || !mediaInputTypes.has(getString(part, "type") ?? "")) continue;
544
+ if (
545
+ isObject(part.source) &&
546
+ part.source.type === "file" &&
547
+ typeof part.source.value === "string"
548
+ ) {
549
+ fileSourceParts.push({ index: mediaIndex++, part });
550
+ } else if (partSourceToString(part.source)) {
551
+ mediaIndex++;
552
+ }
553
+ }
554
+
555
+ const leadingText =
556
+ textParts.length === 0 ||
557
+ (textParts.length === 1 &&
558
+ textParts[0]!.index === 0 &&
559
+ textParts[0]!.text !== "");
560
+ return { fileSourceParts, textParts: leadingText ? [] : textParts };
561
+ }
562
+
497
563
  function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
498
564
  if (!Array.isArray(content)) return [];
499
565
 
500
566
  const attachments: SnapshotAttachment[] = [];
501
567
  for (const rawPart of content) {
502
568
  if (!isObject(rawPart)) continue;
503
- const part =
504
- getString(rawPart, "type") === "binary"
505
- ? upgradeBinaryInputPart(rawPart)
506
- : rawPart;
569
+ const part = normalizeInputPart(rawPart);
507
570
  if (!part) continue;
508
571
  const type = getString(part, "type");
509
572
  if (type === undefined || !mediaInputTypes.has(type)) continue;
510
- const source = inputSourceToString(part.source);
573
+ const source = partSourceToString(part.source);
511
574
  if (!source) continue;
512
575
 
513
- const { filename, providerMetadata } = readInputMetadata(part.metadata);
576
+ const { filename, providerMetadata } = readPartMetadata(part.metadata);
514
577
  const id = attachments.length.toString();
515
578
 
516
579
  if (type === "image") {
@@ -554,12 +617,26 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
554
617
  return attachments;
555
618
  }
556
619
 
557
- function buildUserContent(message: ThreadMessageLike): string | InputContent[] {
620
+ function readContentSlots(
621
+ value: unknown,
622
+ ): { index: number; entry: Record<string, unknown> }[] {
623
+ if (!Array.isArray(value)) return [];
624
+ return value.flatMap((entry) =>
625
+ isObject(entry) &&
626
+ typeof entry.index === "number" &&
627
+ Number.isInteger(entry.index) &&
628
+ entry.index >= 0
629
+ ? [{ index: entry.index, entry }]
630
+ : [],
631
+ );
632
+ }
633
+
634
+ function buildUserContent(message: ThreadMessageLike): string | ContentPart[] {
558
635
  const contentParts = Array.isArray(message.content) ? message.content : [];
559
636
 
560
637
  const attachments = message.attachments ?? [];
561
638
 
562
- const converted: InputContent[] = [];
639
+ const converted: ContentPart[] = [];
563
640
 
564
641
  // Promote string-form content to a leading text part so it survives when
565
642
  // non-text attachments are present (fromAgUiMessages emits string content).
@@ -568,7 +645,7 @@ function buildUserContent(message: ThreadMessageLike): string | InputContent[] {
568
645
  }
569
646
 
570
647
  for (const part of contentParts) {
571
- const input = toInputContent(part, undefined);
648
+ const input = toContentPart(part, undefined);
572
649
  if (input) converted.push(input);
573
650
  }
574
651
  for (const attachment of attachments) {
@@ -577,13 +654,53 @@ function buildUserContent(message: ThreadMessageLike): string | InputContent[] {
577
654
  if (!Array.isArray(attachmentContent)) continue;
578
655
  const fallbackMime = getString(attachment, "contentType");
579
656
  for (const part of attachmentContent) {
580
- const input = toInputContent(part, fallbackMime);
657
+ const input = toContentPart(part, fallbackMime);
581
658
  if (input) converted.push(input);
582
659
  }
583
660
  }
584
661
 
585
- const hasNonText = converted.some((part) => part.type !== "text");
586
- if (hasNonText) return converted;
662
+ const namespaced = readNamespacedMetadata(message.metadata);
663
+ const textSlots = readContentSlots(namespaced.textParts).flatMap(
664
+ ({ index, entry }) =>
665
+ typeof entry.text === "string"
666
+ ? [{ index, part: { type: "text" as const, text: entry.text } }]
667
+ : [],
668
+ );
669
+ const mergedText = converted
670
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
671
+ .join("\n");
672
+ const restoresText =
673
+ textSlots.length > 0 &&
674
+ textSlots.map(({ part }) => part.text).join("\n") === mergedText;
675
+ const fileSourceSlots = readContentSlots(namespaced.fileSourceParts).flatMap(
676
+ ({ index, entry: { part } }) =>
677
+ isObject(part) &&
678
+ mediaInputTypes.has(getString(part, "type") ?? "") &&
679
+ isObject(part.source) &&
680
+ part.source.type === "file" &&
681
+ typeof part.source.value === "string"
682
+ ? [{ index, part: part as ContentPart }]
683
+ : [],
684
+ );
685
+ const ordered = restoresText
686
+ ? converted.filter((part) => part.type !== "text")
687
+ : converted;
688
+ for (const { index, part } of fileSourceSlots.sort(
689
+ (a, b) => a.index - b.index,
690
+ )) {
691
+ const mediaPositions = ordered.flatMap((contentPart, position) =>
692
+ contentPart.type !== "text" ? [position] : [],
693
+ );
694
+ ordered.splice(mediaPositions[index] ?? ordered.length, 0, part);
695
+ }
696
+ if (restoresText) {
697
+ for (const { index, part } of textSlots.sort((a, b) => a.index - b.index)) {
698
+ ordered.splice(Math.min(index, ordered.length), 0, part);
699
+ }
700
+ }
701
+
702
+ if (restoresText || ordered.some((part) => part.type !== "text"))
703
+ return ordered;
587
704
 
588
705
  // All-text path: collapse to plain string. Join text parts collected from
589
706
  // both content and attachments so attachment-sourced text is not dropped.
@@ -695,6 +812,17 @@ function readPersistedInterrupts(
695
812
  return validateInterrupts(namespaced.interrupts);
696
813
  }
697
814
 
815
+ function readPersistedPendingToolCallIds(
816
+ metadata: unknown,
817
+ ): string[] | undefined {
818
+ const value = readNamespacedMetadata(metadata).pendingToolCallIds;
819
+ return Array.isArray(value) &&
820
+ value.length > 0 &&
821
+ value.every((id): id is string => typeof id === "string")
822
+ ? value
823
+ : undefined;
824
+ }
825
+
698
826
  function readOpaqueReasoning(metadata: unknown): AgUiOpaqueReasoning[] {
699
827
  if (!isObject(metadata)) return [];
700
828
  const custom = metadata.custom;
@@ -794,10 +922,15 @@ function toUserOrSystemSnapshotMessage(
794
922
  rawMessage: Record<string, unknown>,
795
923
  ): CoreThreadMessageLike {
796
924
  const messageName = getString(rawMessage, "name");
797
- const custom = toSnapshotCustom(
798
- rawMessage.metadata,
799
- role === "developer" ? { role: "developer" } : {},
800
- );
925
+ const { fileSourceParts, textParts } =
926
+ role === "user"
927
+ ? toUserContentLayout(rawMessage.content)
928
+ : { fileSourceParts: [], textParts: [] };
929
+ const custom = toSnapshotCustom(rawMessage.metadata, {
930
+ ...(role === "developer" ? { role: "developer" } : {}),
931
+ ...(fileSourceParts.length > 0 ? { fileSourceParts } : {}),
932
+ ...(textParts.length > 0 ? { textParts } : {}),
933
+ });
801
934
  const attachments =
802
935
  role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
803
936
  return {
@@ -1030,18 +1163,27 @@ export function fromAgUiMessages(
1030
1163
  flushPendingReasoning();
1031
1164
  const toolCallId = getToolCallId(rawMessage) ?? `tool-${generateId()}`;
1032
1165
  const toolMessageId = getString(rawMessage, "id");
1033
- const modelContent = extractText(rawMessage.content);
1034
- const mcpResult = parseMcpToolCallResult(rawMessage, modelContent);
1035
- const mcpModelContent = mcpResult
1166
+ const modelContent = Array.isArray(rawMessage.content)
1167
+ ? toolContentText(rawMessage.content as ContentPart[])
1168
+ : extractText(rawMessage.content);
1169
+ const mcpResult = parseMcpToolCallResult(
1170
+ rawMessage,
1171
+ Array.isArray(rawMessage.content)
1172
+ ? (rawMessage.content as ContentPart[])
1173
+ : modelContent,
1174
+ );
1175
+ const toolModelContent = mcpResult
1036
1176
  ? [{ type: "text" as const, text: modelContent }]
1037
1177
  : undefined;
1038
1178
  const result =
1039
1179
  mcpResult ??
1040
- (rawMessage.result !== undefined
1041
- ? rawMessage.result
1042
- : typeof rawMessage.content === "string"
1043
- ? parseJSONText(rawMessage.content)
1044
- : rawMessage.content);
1180
+ (Array.isArray(rawMessage.content)
1181
+ ? rawMessage.content
1182
+ : rawMessage.result !== undefined
1183
+ ? rawMessage.result
1184
+ : typeof rawMessage.content === "string"
1185
+ ? parseJSONText(rawMessage.content)
1186
+ : rawMessage.content);
1045
1187
  const isError =
1046
1188
  typeof rawMessage.error === "string" ||
1047
1189
  rawMessage.isError === true ||
@@ -1078,10 +1220,14 @@ export function fromAgUiMessages(
1078
1220
  if (!isObject(part) || part.type !== "tool-call") continue;
1079
1221
  if (getString(part, "toolCallId") !== toolCallId) continue;
1080
1222
 
1223
+ const { modelContent: _modelContent, ...partWithoutModelContent } =
1224
+ part as ToolCallPart;
1081
1225
  const updatedPart: ToolCallPart = {
1082
- ...(part as ToolCallPart),
1226
+ ...(Array.isArray(rawMessage.content) && !mcpResult
1227
+ ? partWithoutModelContent
1228
+ : (part as ToolCallPart)),
1083
1229
  result,
1084
- ...(mcpModelContent ? { modelContent: mcpModelContent } : {}),
1230
+ ...(toolModelContent ? { modelContent: toolModelContent } : {}),
1085
1231
  ...(isError !== undefined ? { isError } : {}),
1086
1232
  ...(toolMessageId !== undefined
1087
1233
  ? { unstable_toolMessageId: toolMessageId }
@@ -1125,7 +1271,7 @@ export function fromAgUiMessages(
1125
1271
  args: {},
1126
1272
  argsText: "{}",
1127
1273
  result,
1128
- ...(mcpModelContent ? { modelContent: mcpModelContent } : {}),
1274
+ ...(toolModelContent ? { modelContent: toolModelContent } : {}),
1129
1275
  ...(isError !== undefined ? { isError } : {}),
1130
1276
  ...(toolMessageId !== undefined
1131
1277
  ? { unstable_toolMessageId: toolMessageId }
@@ -1345,13 +1491,18 @@ export function fromAgUiMessages(
1345
1491
 
1346
1492
  const hasInterrupt =
1347
1493
  readPersistedInterrupts(message.metadata) !== undefined;
1494
+ const pendingToolCallIds = readPersistedPendingToolCallIds(
1495
+ message.metadata,
1496
+ );
1348
1497
  const hasPendingToolCall =
1349
1498
  Array.isArray(message.content) &&
1350
1499
  message.content.some(
1351
1500
  (part) =>
1352
1501
  isObject(part) &&
1353
1502
  part.type === "tool-call" &&
1354
- part.result === undefined,
1503
+ part.result === undefined &&
1504
+ (pendingToolCallIds === undefined ||
1505
+ pendingToolCallIds.includes(getString(part, "toolCallId") ?? "")),
1355
1506
  );
1356
1507
 
1357
1508
  if (hasInterrupt || hasPendingToolCall) {
@@ -1478,6 +1629,22 @@ function convertAssistantMessage(
1478
1629
  }
1479
1630
  }
1480
1631
 
1632
+ function isToolContentParts(value: unknown): value is ContentPart[] {
1633
+ return (
1634
+ Array.isArray(value) &&
1635
+ value.length > 0 &&
1636
+ value.every(
1637
+ (part) =>
1638
+ isObject(part) &&
1639
+ (part.type === "text"
1640
+ ? typeof part.text === "string"
1641
+ : mediaInputTypes.has(getString(part, "type") ?? "") &&
1642
+ isObject(part.source) &&
1643
+ typeof part.source.value === "string"),
1644
+ )
1645
+ );
1646
+ }
1647
+
1481
1648
  function emitToolResult(
1482
1649
  toolCallId: string,
1483
1650
  part: ToolCallPart,
@@ -1488,9 +1655,11 @@ function emitToolResult(
1488
1655
  const resultContent =
1489
1656
  part.modelContent !== undefined
1490
1657
  ? extractText(part.modelContent)
1491
- : typeof part.result === "string"
1658
+ : isToolContentParts(part.result)
1492
1659
  ? part.result
1493
- : JSON.stringify(part.result);
1660
+ : typeof part.result === "string"
1661
+ ? part.result
1662
+ : JSON.stringify(part.result);
1494
1663
  const messageMetadata = readAgUiPartMessageMetadata(part.providerMetadata);
1495
1664
 
1496
1665
  converted.push({
@@ -1498,7 +1667,13 @@ function emitToolResult(
1498
1667
  role: "tool",
1499
1668
  content: resultContent,
1500
1669
  toolCallId,
1501
- ...(part.isError ? { error: resultContent } : {}),
1670
+ ...(part.isError
1671
+ ? {
1672
+ error: Array.isArray(resultContent)
1673
+ ? toolContentText(resultContent)
1674
+ : resultContent,
1675
+ }
1676
+ : {}),
1502
1677
  ...(messageMetadata ? { metadata: messageMetadata } : {}),
1503
1678
  });
1504
1679
  }
@@ -1507,7 +1682,9 @@ function convertToolMessage(
1507
1682
  message: NormalizedThreadMessageLike,
1508
1683
  converted: AgUiMessage[],
1509
1684
  ): void {
1510
- const content = extractText(message.content);
1685
+ const content = isToolContentParts(message.content)
1686
+ ? message.content
1687
+ : extractText(message.content);
1511
1688
  const toolCallId = message.toolCallId ?? generateId();
1512
1689
 
1513
1690
  converted.push({