@assistant-ui/react-ag-ui 0.0.59 → 0.0.61

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 (45) hide show
  1. package/dist/agUiExtras.d.ts +2 -5
  2. package/dist/agUiExtras.d.ts.map +1 -1
  3. package/dist/hooks.d.ts +9 -12
  4. package/dist/hooks.d.ts.map +1 -1
  5. package/dist/index.d.ts +7 -5
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +153 -146
  8. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  9. package/dist/runtime/AgUiThreadRuntimeCore.js +146 -26
  10. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  11. package/dist/runtime/adapter/conversions.d.ts +54 -56
  12. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  13. package/dist/runtime/adapter/conversions.js +164 -23
  14. package/dist/runtime/adapter/conversions.js.map +1 -1
  15. package/dist/runtime/adapter/run-aggregator.d.ts +98 -98
  16. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  17. package/dist/runtime/adapter/run-aggregator.js +35 -2
  18. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  19. package/dist/runtime/adapter/subscriber.d.ts +109 -111
  20. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  21. package/dist/runtime/adapter/tool-approval.d.ts +7 -9
  22. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.d.ts +5 -8
  24. package/dist/runtime/event-parser.d.ts.map +1 -1
  25. package/dist/runtime/interrupt-internals.d.ts +4 -7
  26. package/dist/runtime/interrupt-internals.d.ts.map +1 -1
  27. package/dist/runtime/logger.d.ts +4 -7
  28. package/dist/runtime/logger.d.ts.map +1 -1
  29. package/dist/runtime/mcp-tool-result.d.ts +7 -10
  30. package/dist/runtime/mcp-tool-result.d.ts.map +1 -1
  31. package/dist/runtime/types.d.ts +248 -250
  32. package/dist/runtime/types.d.ts.map +1 -1
  33. package/dist/useAgUiRuntime.d.ts +15 -18
  34. package/dist/useAgUiRuntime.d.ts.map +1 -1
  35. package/dist/useAgUiRuntime.js +9 -2
  36. package/dist/useAgUiRuntime.js.map +1 -1
  37. package/package.json +12 -11
  38. package/src/runtime/AgUiThreadRuntimeCore.ts +246 -30
  39. package/src/runtime/adapter/conversions.content.test.ts +56 -0
  40. package/src/runtime/adapter/conversions.ts +278 -36
  41. package/src/runtime/adapter/run-aggregator.ts +53 -2
  42. package/src/useAgUiRuntime.history.test.tsx +93 -2
  43. package/src/useAgUiRuntime.thread-switch.test.tsx +84 -4
  44. package/src/useAgUiRuntime.ts +12 -3
  45. package/src/useAgUiRuntime.voice.test.tsx +402 -0
@@ -5,6 +5,7 @@ import { generateId } from "@assistant-ui/core";
5
5
  import type {
6
6
  ThreadMessageLike as CoreThreadMessageLike,
7
7
  PartProviderMetadata,
8
+ ReasoningMessagePart,
8
9
  ThreadMessage,
9
10
  ToolCallMessagePartMcpMetadata,
10
11
  ToolModelContentPart,
@@ -19,12 +20,14 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
19
20
  import {
20
21
  AG_UI_METADATA_NAMESPACE,
21
22
  A2UI_SURFACE_ACTIVITY_TYPE,
23
+ MCP_APPS_ACTIVITY_TYPE,
22
24
  type AgUiCustomMetadata,
23
25
  type AgUiOpaqueReasoning,
24
26
  } from "./run-aggregator";
25
27
  import {
26
28
  applyA2uiOperations,
27
29
  convertSurfaceToUISpec,
30
+ surfaceToOperations,
28
31
  type A2uiState,
29
32
  type A2uiSurfaceState,
30
33
  } from "@assistant-ui/react-generative-ui/a2ui";
@@ -44,14 +47,14 @@ type AttachmentLike = {
44
47
  };
45
48
 
46
49
  type ThreadMessageLike = {
47
- id?: string;
50
+ id?: string | undefined;
48
51
  role: string;
49
52
  content: unknown;
50
53
  metadata?: unknown;
51
- name?: string;
52
- toolCallId?: string;
53
- error?: string;
54
- attachments?: readonly AttachmentLike[];
54
+ name?: string | undefined;
55
+ toolCallId?: string | undefined;
56
+ error?: string | undefined;
57
+ attachments?: readonly AttachmentLike[] | undefined;
55
58
  };
56
59
 
57
60
  type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
@@ -103,6 +106,7 @@ type ToolCallPart = {
103
106
  argsText?: string;
104
107
  args?: ReadonlyJSONObject;
105
108
  result?: unknown;
109
+ artifact?: unknown;
106
110
  isError?: boolean;
107
111
  modelContent?: readonly ToolModelContentPart[];
108
112
  unstable_toolMessageId?: string;
@@ -240,7 +244,9 @@ function mediaTypeForMime(mimeType: string | undefined): MediaInputType {
240
244
  // Build an AG-UI multimodal source from a data URL, raw base64 payload, or an
241
245
  // http(s) URL. A `url` source may omit the mime type; a `data` source always
242
246
  // resolves one (falling back to application/octet-stream). An explicit
243
- // `sourceType: "url"` on the part forces the url leg for non-http references.
247
+ // `sourceType: "url"` on the part forces the url leg for non-http references,
248
+ // and a data URL that is not base64 takes it too, since a `data` source
249
+ // carries base64 bytes.
244
250
  function buildInputSource(
245
251
  value: string,
246
252
  declaredMimeType: string | undefined,
@@ -249,7 +255,8 @@ function buildInputSource(
249
255
  const source = resolveFilePartSource({
250
256
  data: value,
251
257
  mimeType: declaredMimeType ?? "application/octet-stream",
252
- sourceType,
258
+ sourceType:
259
+ /^data:/i.test(value) && !parseDataUrl(value) ? "url" : sourceType,
253
260
  });
254
261
  if (source.kind === "url") {
255
262
  return declaredMimeType !== undefined
@@ -717,6 +724,7 @@ function attachA2uiSurfaces(
717
724
  args: spec as unknown as ReadonlyJSONObject,
718
725
  argsText: JSON.stringify(spec),
719
726
  result: {},
727
+ artifact: { a2ui: surfaceToOperations(surface) },
720
728
  });
721
729
  }
722
730
 
@@ -733,6 +741,128 @@ function attachA2uiSurfaces(
733
741
  return { ...message, content: [...preserved, ...a2uiParts] };
734
742
  }
735
743
 
744
+ // A tool call that arrives without a `parentMessageId` has no record to join, so
745
+ // the client opens one keyed by the call id. An assistant record whose own id is
746
+ // a call it carries is that container: an id the client invented for the call
747
+ // rather than a boundary an agent drew. The prose of the turn lands in records
748
+ // of its own on either side of it, and nothing on the wire binds them to the
749
+ // run that produced them.
750
+ function isSyntheticToolCallContainer(message: CoreThreadMessageLike): boolean {
751
+ if (message.role !== "assistant" || !Array.isArray(message.content))
752
+ return false;
753
+ let carriesOwnId = false;
754
+ for (const part of message.content) {
755
+ if (!isObject(part)) continue;
756
+ // The container is opened empty, so text on the record means an agent
757
+ // addressed it and the id is one it chose.
758
+ if (part.type === "text") return false;
759
+ if (
760
+ part.type === "tool-call" &&
761
+ getString(part, "toolCallId") === message.id
762
+ ) {
763
+ carriesOwnId = true;
764
+ }
765
+ }
766
+ return carriesOwnId;
767
+ }
768
+
769
+ function carriesPart(message: CoreThreadMessageLike, type: string): boolean {
770
+ return (
771
+ Array.isArray(message.content) &&
772
+ message.content.some((part) => isObject(part) && part.type === type)
773
+ );
774
+ }
775
+
776
+ function carriesText(message: CoreThreadMessageLike): boolean {
777
+ return carriesPart(message, "text");
778
+ }
779
+
780
+ // A container joins the assistant record ahead of it when that record holds
781
+ // prose or calls of the turn. A released reasoning record holds neither: its
782
+ // part leaves the export under the record's own id, so a message merged under
783
+ // that id would put two records with one id on the wire.
784
+ function opensTurn(message: CoreThreadMessageLike): boolean {
785
+ return (
786
+ message.role === "assistant" &&
787
+ (carriesText(message) || carriesPart(message, "tool-call"))
788
+ );
789
+ }
790
+
791
+ // The parts of the answer fold onto the container ahead of them. A record
792
+ // carrying a tool call an agent addressed is a boundary of its own and keeps
793
+ // its message.
794
+ function answersToolCallContainer(message: CoreThreadMessageLike): boolean {
795
+ if (message.role !== "assistant" || !Array.isArray(message.content))
796
+ return false;
797
+ if (message.content.length === 0) return false;
798
+ return !message.content.some(
799
+ (part) => isObject(part) && part.type === "tool-call",
800
+ );
801
+ }
802
+
803
+ function readCustomMetadata(metadata: unknown): Record<string, unknown> {
804
+ if (!isObject(metadata) || !isObject(metadata.custom)) return {};
805
+ return metadata.custom;
806
+ }
807
+
808
+ function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
809
+ const namespaced = readCustomMetadata(metadata)[AG_UI_METADATA_NAMESPACE];
810
+ return isObject(namespaced) ? namespaced : {};
811
+ }
812
+
813
+ function foldTurnRecords(
814
+ previous: CoreThreadMessageLike,
815
+ message: CoreThreadMessageLike,
816
+ ): CoreThreadMessageLike {
817
+ const interrupts = [
818
+ ...(readPersistedInterrupts(previous.metadata) ?? []),
819
+ ...(readPersistedInterrupts(message.metadata) ?? []),
820
+ ];
821
+ // An entry that rode a record behind the turn's calls sat ahead of content
822
+ // that now follows them, so it is replayed after the merged record and its
823
+ // tool results rather than ahead of a message that no longer starts there.
824
+ // One that rode the container opening the turn sat ahead of every call and
825
+ // stays ahead of the record.
826
+ const trailsCalls = carriesPart(previous, "tool-call");
827
+ const opaqueReasoning = [
828
+ ...readOpaqueReasoning(previous.metadata),
829
+ ...readOpaqueReasoning(message.metadata).map((entry) =>
830
+ trailsCalls ? { ...entry, after: true } : entry,
831
+ ),
832
+ ];
833
+ const namespaced = {
834
+ ...readNamespacedMetadata(previous.metadata),
835
+ ...readNamespacedMetadata(message.metadata),
836
+ ...(interrupts.length > 0 ? { interrupts } : {}),
837
+ ...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
838
+ } satisfies AgUiCustomMetadata & Record<string, unknown>;
839
+ const custom = {
840
+ ...readCustomMetadata(previous.metadata),
841
+ ...readCustomMetadata(message.metadata),
842
+ ...(Object.keys(namespaced).length > 0
843
+ ? { [AG_UI_METADATA_NAMESPACE]: namespaced }
844
+ : {}),
845
+ };
846
+ const metadata = {
847
+ ...(isObject(previous.metadata) ? previous.metadata : {}),
848
+ ...(isObject(message.metadata) ? message.metadata : {}),
849
+ ...(Object.keys(custom).length > 0 ? { custom } : {}),
850
+ };
851
+ return {
852
+ ...previous,
853
+ ...message,
854
+ // A container's id names a call rather than the turn, so the record the
855
+ // agent addressed its prose to wins: that is the id the next run has to
856
+ // carry for the agent to recognize the message it wrote.
857
+ id: carriesText(message) ? message.id : previous.id,
858
+ content: [
859
+ ...(Array.isArray(previous.content) ? previous.content : []),
860
+ ...(Array.isArray(message.content) ? message.content : []),
861
+ ],
862
+ ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
863
+ } as CoreThreadMessageLike;
864
+ }
865
+
736
866
  export type FromAgUiMessagesOptions = {
737
867
  /**
738
868
  * Whether to convert `reasoning` messages into visible reasoning parts.
@@ -749,11 +879,49 @@ export function fromAgUiMessages(
749
879
  const converted: CoreThreadMessageLike[] = [];
750
880
  const a2uiBuckets = new Map<string, A2uiState>();
751
881
  const a2uiBucketOwnerIndices = new Map<string, number>();
882
+ const activityParts = new Map<
883
+ string,
884
+ { ownerIndex: number; part: { type: "data"; name: string; data: unknown } }
885
+ >();
752
886
  // A zero-data-retention run carries its payload in encryptedValue with no
753
887
  // readable content, so the record has no part to become and rides on the
754
888
  // neighbouring message instead of being dropped. The anchor is the index the
755
889
  // next pushed message will occupy, which is where it sat on the wire.
756
890
  const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
891
+ // A reasoning record belongs to the assistant record that follows it, which is
892
+ // the binding the export writes and the one the reference integrations read
893
+ // back. Holding it until that assistant arrives rebuilds the pair as the
894
+ // single message the live run produced.
895
+ const pendingReasoning: {
896
+ id: string | undefined;
897
+ part: ReasoningMessagePart;
898
+ }[] = [];
899
+
900
+ const flushPendingReasoning = () => {
901
+ for (const { id, part } of pendingReasoning) {
902
+ converted.push({
903
+ id: id ?? generateId(),
904
+ role: "assistant",
905
+ content: [part],
906
+ });
907
+ }
908
+ pendingReasoning.length = 0;
909
+ };
910
+
911
+ const withPendingReasoning = (
912
+ message: CoreThreadMessageLike,
913
+ ): CoreThreadMessageLike => {
914
+ if (pendingReasoning.length === 0) return message;
915
+ const parts = pendingReasoning.map(({ part }) => part);
916
+ pendingReasoning.length = 0;
917
+ return {
918
+ ...message,
919
+ content: [
920
+ ...parts,
921
+ ...(Array.isArray(message.content) ? message.content : []),
922
+ ],
923
+ };
924
+ };
757
925
 
758
926
  for (const rawMessage of messages) {
759
927
  if (!isObject(rawMessage)) continue;
@@ -761,6 +929,7 @@ export function fromAgUiMessages(
761
929
  if (!role) continue;
762
930
 
763
931
  if (role === "tool") {
932
+ flushPendingReasoning();
764
933
  const toolCallId = getToolCallId(rawMessage) ?? `tool-${generateId()}`;
765
934
  const toolMessageId = getString(rawMessage, "id");
766
935
  const modelContent = extractText(rawMessage.content);
@@ -862,15 +1031,14 @@ export function fromAgUiMessages(
862
1031
  }
863
1032
 
864
1033
  if (role === "activity") {
865
- // Only a2ui-surface activity messages have an assistant-part equivalent
866
- // to rehydrate; other activity types still have no surface to repaint.
867
1034
  const activityType = getString(rawMessage, "activityType");
868
- if (activityType !== A2UI_SURFACE_ACTIVITY_TYPE) continue;
869
- const activityContent = isObject(rawMessage.content)
870
- ? (rawMessage.content as Record<string, unknown>)
871
- : null;
872
- const operations = activityContent?.["a2ui_operations"];
873
- if (!Array.isArray(operations)) continue;
1035
+ if (activityType === undefined || activityType === MCP_APPS_ACTIVITY_TYPE)
1036
+ continue;
1037
+
1038
+ // A surface belongs to the turn that painted it, and held reasoning is a
1039
+ // nearer antecedent than the previous turn's assistant record, so it is
1040
+ // released here rather than folded past this record.
1041
+ flushPendingReasoning();
874
1042
 
875
1043
  let ownerIndex = -1;
876
1044
  for (let i = converted.length - 1; i >= 0; i--) {
@@ -883,6 +1051,48 @@ export function fromAgUiMessages(
883
1051
  if (ownerIndex === -1) continue;
884
1052
 
885
1053
  const owner = converted[ownerIndex]!;
1054
+ if (activityType !== A2UI_SURFACE_ACTIVITY_TYPE) {
1055
+ const bucketKey =
1056
+ getString(rawMessage, "id") ?? `agui-activity:${activityType}`;
1057
+ const part = {
1058
+ type: "data" as const,
1059
+ name: `agui-activity/${activityType}`,
1060
+ data: rawMessage.content,
1061
+ };
1062
+ const existing = activityParts.get(bucketKey);
1063
+ const existingOwner = existing && converted[existing.ownerIndex];
1064
+ const existingIndex =
1065
+ existingOwner && Array.isArray(existingOwner.content)
1066
+ ? existingOwner.content.indexOf(existing.part)
1067
+ : -1;
1068
+ if (existing && existingOwner && existingIndex !== -1) {
1069
+ const content = [...(existingOwner.content as unknown[])];
1070
+ content[existingIndex] = part;
1071
+ converted[existing.ownerIndex] = {
1072
+ ...existingOwner,
1073
+ content: content as typeof existingOwner.content,
1074
+ };
1075
+ activityParts.set(bucketKey, {
1076
+ ownerIndex: existing.ownerIndex,
1077
+ part,
1078
+ });
1079
+ continue;
1080
+ }
1081
+
1082
+ const content = Array.isArray(owner.content) ? owner.content : [];
1083
+ activityParts.set(bucketKey, { ownerIndex, part });
1084
+ converted[ownerIndex] = {
1085
+ ...owner,
1086
+ content: [...content, part],
1087
+ };
1088
+ continue;
1089
+ }
1090
+
1091
+ const activityContent = isObject(rawMessage.content)
1092
+ ? (rawMessage.content as Record<string, unknown>)
1093
+ : null;
1094
+ const operations = activityContent?.["a2ui_operations"];
1095
+ if (!Array.isArray(operations)) continue;
886
1096
  const bucketKey = getString(rawMessage, "id") ?? "a2ui:anonymous";
887
1097
  const { state } = applyA2uiOperations(new Map(), operations);
888
1098
  a2uiBuckets.delete(bucketKey);
@@ -903,7 +1113,9 @@ export function fromAgUiMessages(
903
1113
  }
904
1114
 
905
1115
  if (role === "assistant") {
906
- converted.push(toAssistantSnapshotMessage(rawMessage));
1116
+ converted.push(
1117
+ withPendingReasoning(toAssistantSnapshotMessage(rawMessage)),
1118
+ );
907
1119
  continue;
908
1120
  }
909
1121
 
@@ -916,6 +1128,9 @@ export function fromAgUiMessages(
916
1128
  // accept the next run.
917
1129
  const opaqueId = getString(rawMessage, "id");
918
1130
  if (opaqueId?.trim() && encryptedValue?.trim()) {
1131
+ // The anchor counts materialized messages, so anything still held
1132
+ // takes its own slot rather than folding past this record.
1133
+ flushPendingReasoning();
919
1134
  opaqueReasoning.push({
920
1135
  id: opaqueId,
921
1136
  encryptedValue,
@@ -939,31 +1154,37 @@ export function fromAgUiMessages(
939
1154
  }
940
1155
  continue;
941
1156
  }
942
- converted.push({
943
- id: getString(rawMessage, "id") ?? generateId(),
944
- role: "assistant",
945
- content: [
946
- {
947
- type: "reasoning",
948
- text,
949
- ...(encryptedValue !== undefined
950
- ? {
951
- providerMetadata: {
952
- [AG_UI_METADATA_NAMESPACE]: { encryptedValue },
953
- },
954
- }
955
- : {}),
956
- },
957
- ],
1157
+ const rawReasoningId = getString(rawMessage, "id");
1158
+ // A blank id still wins over a synthesized one on export, which would put
1159
+ // an unaddressable record on the wire.
1160
+ const reasoningId = rawReasoningId?.trim() ? rawReasoningId : undefined;
1161
+ // The fold costs the record its own message id, so it rides the part
1162
+ // instead: the export re-emits each block under the id it arrived with.
1163
+ const meta = {
1164
+ ...(reasoningId !== undefined ? { reasoningId } : {}),
1165
+ ...(encryptedValue !== undefined ? { encryptedValue } : {}),
1166
+ };
1167
+ pendingReasoning.push({
1168
+ id: reasoningId,
1169
+ part: {
1170
+ type: "reasoning",
1171
+ text,
1172
+ ...(Object.keys(meta).length > 0
1173
+ ? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
1174
+ : {}),
1175
+ },
958
1176
  });
959
1177
  continue;
960
1178
  }
961
1179
 
962
1180
  if (role === "user" || role === "system" || role === "developer") {
1181
+ flushPendingReasoning();
963
1182
  converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
964
1183
  }
965
1184
  }
966
1185
 
1186
+ flushPendingReasoning();
1187
+
967
1188
  for (const { anchor, ...entry } of opaqueReasoning) {
968
1189
  // Nothing followed it on the wire, so it trails the last message instead.
969
1190
  const trailing = anchor >= converted.length;
@@ -976,8 +1197,29 @@ export function fromAgUiMessages(
976
1197
  ]);
977
1198
  }
978
1199
 
979
- for (let i = 0; i < converted.length; i++) {
980
- const message = converted[i]!;
1200
+ // A turn that called a tool without addressing a record sits on the wire as
1201
+ // a container beside the records around it, which the live run renders as
1202
+ // one message: the container joins the assistant record ahead of it, and
1203
+ // what follows a container joins it until the turn carries text, where a
1204
+ // further text record opens a message of its own as it does live.
1205
+ const folded: CoreThreadMessageLike[] = [];
1206
+ for (const message of converted) {
1207
+ const previous = folded[folded.length - 1];
1208
+ if (
1209
+ previous !== undefined &&
1210
+ (isSyntheticToolCallContainer(message)
1211
+ ? opensTurn(previous)
1212
+ : isSyntheticToolCallContainer(previous) &&
1213
+ answersToolCallContainer(message))
1214
+ ) {
1215
+ folded[folded.length - 1] = foldTurnRecords(previous, message);
1216
+ continue;
1217
+ }
1218
+ folded.push(message);
1219
+ }
1220
+
1221
+ for (let i = 0; i < folded.length; i++) {
1222
+ const message = folded[i]!;
981
1223
  if (message.role !== "assistant") continue;
982
1224
 
983
1225
  const hasInterrupt =
@@ -992,7 +1234,7 @@ export function fromAgUiMessages(
992
1234
  );
993
1235
 
994
1236
  if (hasInterrupt || hasPendingToolCall) {
995
- converted[i] = {
1237
+ folded[i] = {
996
1238
  ...message,
997
1239
  status: getAutoStatus(
998
1240
  false,
@@ -1005,7 +1247,7 @@ export function fromAgUiMessages(
1005
1247
  }
1006
1248
  }
1007
1249
 
1008
- return converted;
1250
+ return folded;
1009
1251
  }
1010
1252
 
1011
1253
  function convertAssistantMessage(
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  applyA2uiOperations,
15
15
  convertSurfaceToUISpec,
16
+ surfaceToOperations,
16
17
  type A2uiState,
17
18
  type A2uiSurfaceState,
18
19
  } from "@assistant-ui/react-generative-ui/a2ui";
@@ -49,7 +50,7 @@ type PartOrderEntry =
49
50
  | { kind: "text"; key: string; subagentRunId?: string }
50
51
  | { kind: "reasoning"; key: string; subagentRunId?: string }
51
52
  | { kind: "tool-call"; toolCallId: string }
52
- | { kind: "data"; name: string; value: unknown };
53
+ | { kind: "data"; name: string; value: unknown; subagentRunId?: string };
53
54
 
54
55
  type BuildContext = {
55
56
  subagentsByParentToolCallId: Map<string, string[]>;
@@ -95,6 +96,7 @@ type ToolCallState = {
95
96
  toolMessageId?: string;
96
97
  mcpAppResourceUri?: string;
97
98
  mcpAppServerId?: string;
99
+ artifact?: unknown;
98
100
  modelContent?: ToolModelContentPart[];
99
101
  snapshotResultApplied: boolean;
100
102
  subagentRunId?: string;
@@ -246,6 +248,10 @@ export class RunAggregator {
246
248
  private readonly toolCalls = new Map<string, ToolCallState>();
247
249
  private readonly a2uiBuckets = new Map<string, A2uiState>();
248
250
  private readonly a2uiToolCallIds = new Set<string>();
251
+ private readonly activityParts = new Map<
252
+ string,
253
+ { kind: "data"; name: string; value: unknown; subagentRunId?: string }
254
+ >();
249
255
  private readonly lastResolvedToolCallIdByScope = new Map<string, string>();
250
256
  private readonly partOrder: PartOrderEntry[] = [];
251
257
  private textPartCounter = 0;
@@ -319,6 +325,9 @@ export class RunAggregator {
319
325
  break;
320
326
  }
321
327
  case "RUN_CANCELLED": {
328
+ // A cancel can land after RUN_FINISHED while the response is still
329
+ // closing, and the answer it finished is already persisted as complete.
330
+ if (this.status?.type === "complete") break;
322
331
  this.status = { type: "incomplete", reason: "cancelled" };
323
332
  this.closeOpenSubagentRuns(this.status);
324
333
  this.emit();
@@ -503,7 +512,10 @@ export class RunAggregator {
503
512
  this.handleA2uiActivitySnapshot(event);
504
513
  break;
505
514
  }
506
- if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) break;
515
+ if (event.activityType !== MCP_APPS_ACTIVITY_TYPE) {
516
+ this.handleActivitySnapshot(event);
517
+ break;
518
+ }
507
519
  const activityScope = this.scopeOf(event);
508
520
  const toolCallId = event.content.toolCallId;
509
521
  const fallbackId =
@@ -644,6 +656,42 @@ export class RunAggregator {
644
656
  this.emit();
645
657
  }
646
658
 
659
+ private handleActivitySnapshot(
660
+ event: Extract<AgUiEvent, { type: "ACTIVITY_SNAPSHOT" }>,
661
+ ): void {
662
+ const scope = this.scopeOf(event);
663
+ const key = this.partKey(
664
+ scope,
665
+ event.messageId !== undefined
666
+ ? `message:${event.messageId}`
667
+ : `type:${event.activityType}`,
668
+ );
669
+ const existing = this.activityParts.get(key);
670
+ if (existing) {
671
+ if (event.replace === false) return;
672
+ existing.name = `agui-activity/${event.activityType}`;
673
+ existing.value = event.content;
674
+ } else {
675
+ const part =
676
+ scope === ROOT_SCOPE
677
+ ? {
678
+ kind: "data" as const,
679
+ name: `agui-activity/${event.activityType}`,
680
+ value: event.content,
681
+ }
682
+ : {
683
+ kind: "data" as const,
684
+ name: `agui-activity/${event.activityType}`,
685
+ value: event.content,
686
+ subagentRunId: scope,
687
+ };
688
+ this.activityParts.set(key, part);
689
+ this.partOrder.push(part);
690
+ this.activeTextMessageIdByScope.delete(scope);
691
+ }
692
+ this.emit();
693
+ }
694
+
647
695
  private synthesizeA2uiToolCalls(): void {
648
696
  const surfaces = new Map<string, A2uiSurfaceState>();
649
697
 
@@ -671,6 +719,7 @@ export class RunAggregator {
671
719
  parsedArgs: spec,
672
720
  result: {},
673
721
  isError: undefined,
722
+ artifact: { a2ui: surfaceToOperations(surface) },
674
723
  snapshotResultApplied: false,
675
724
  };
676
725
  if (!this.toolCalls.has(toolCallId)) {
@@ -729,6 +778,7 @@ export class RunAggregator {
729
778
  this.toolCalls.clear();
730
779
  this.a2uiBuckets.clear();
731
780
  this.a2uiToolCallIds.clear();
781
+ this.activityParts.clear();
732
782
  this.lastResolvedToolCallIdByScope.clear();
733
783
  this.partOrder.length = 0;
734
784
  this.textPartCounter = 0;
@@ -1097,6 +1147,7 @@ export class RunAggregator {
1097
1147
  argsText: entry.argsText,
1098
1148
  ...(approval ? { approval } : {}),
1099
1149
  ...(entry.result !== undefined ? { result: entry.result } : {}),
1150
+ ...(entry.artifact !== undefined ? { artifact: entry.artifact } : {}),
1100
1151
  ...(entry.modelContent !== undefined
1101
1152
  ? { modelContent: entry.modelContent }
1102
1153
  : {}),
@@ -1,8 +1,11 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { describe, it, expect, vi, afterEach } from "vitest";
4
- import { cleanup, renderHook, waitFor } from "@testing-library/react";
5
- import type { ThreadHistoryAdapter } from "@assistant-ui/core";
4
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
5
+ import type {
6
+ ThreadAssistantMessage,
7
+ ThreadHistoryAdapter,
8
+ } from "@assistant-ui/core";
6
9
  import type { HttpAgent } from "@ag-ui/client";
7
10
  import { useAgUiRuntime } from "./useAgUiRuntime";
8
11
 
@@ -73,4 +76,92 @@ describe("useAgUiRuntime history", () => {
73
76
  rerender();
74
77
  expect(history.load).toHaveBeenCalledOnce();
75
78
  });
79
+
80
+ it("records a settled tool interaction through the message part runtime", async () => {
81
+ const assistant: ThreadAssistantMessage = {
82
+ id: "assistant-1",
83
+ role: "assistant",
84
+ createdAt: new Date(0),
85
+ status: { type: "complete", reason: "unknown" },
86
+ content: [
87
+ {
88
+ type: "tool-call",
89
+ toolCallId: "call-1",
90
+ toolName: "present",
91
+ args: {},
92
+ argsText: "{}",
93
+ result: {},
94
+ },
95
+ ],
96
+ metadata: {
97
+ unstable_state: null,
98
+ unstable_annotations: [],
99
+ unstable_data: [],
100
+ steps: [],
101
+ custom: {},
102
+ },
103
+ };
104
+ const update = vi.fn(async () => {});
105
+ const history: ThreadHistoryAdapter = {
106
+ load: vi.fn().mockResolvedValue({
107
+ headId: assistant.id,
108
+ messages: [{ parentId: null, message: assistant }],
109
+ }),
110
+ append: vi.fn().mockResolvedValue(undefined),
111
+ update,
112
+ };
113
+ const { result } = renderHook(() =>
114
+ useAgUiRuntime({ agent, adapters: { history } }),
115
+ );
116
+
117
+ await waitFor(() =>
118
+ expect(
119
+ result.current.thread.getState().messages.map((message) => message.id),
120
+ ).toEqual([assistant.id]),
121
+ );
122
+ const part = result.current.thread
123
+ .getMessageById(assistant.id)
124
+ .getMessagePartByToolCallId("call-1");
125
+ const recordInteraction = part.unstable_recordInteraction;
126
+ expect(recordInteraction).toBeDefined();
127
+
128
+ await act(async () => {
129
+ await recordInteraction!({
130
+ type: "action",
131
+ payload: { action: "confirm" },
132
+ });
133
+ });
134
+
135
+ expect(part.getState()).toMatchObject({
136
+ unstable_interactions: {
137
+ entries: [
138
+ {
139
+ type: "action",
140
+ payload: { action: "confirm" },
141
+ },
142
+ ],
143
+ },
144
+ });
145
+ expect(update).toHaveBeenCalledWith(
146
+ expect.objectContaining({
147
+ parentId: null,
148
+ message: expect.objectContaining({
149
+ id: assistant.id,
150
+ content: [
151
+ expect.objectContaining({
152
+ toolCallId: "call-1",
153
+ unstable_interactions: expect.objectContaining({
154
+ entries: [
155
+ expect.objectContaining({
156
+ type: "action",
157
+ payload: { action: "confirm" },
158
+ }),
159
+ ],
160
+ }),
161
+ }),
162
+ ],
163
+ }),
164
+ }),
165
+ );
166
+ });
76
167
  });