@assistant-ui/react-ag-ui 0.0.53 → 0.0.56

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 (49) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +23 -2
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +183 -39
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +19 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +197 -36
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +15 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +62 -10
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +6 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +10 -0
  18. package/dist/runtime/adapter/subscriber.js.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts +56 -0
  20. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
  21. package/dist/runtime/adapter/tool-approval.js +244 -0
  22. package/dist/runtime/adapter/tool-approval.js.map +1 -0
  23. package/dist/runtime/event-parser.d.ts.map +1 -1
  24. package/dist/runtime/event-parser.js +15 -1
  25. package/dist/runtime/event-parser.js.map +1 -1
  26. package/dist/runtime/interrupt-internals.d.ts +8 -0
  27. package/dist/runtime/interrupt-internals.d.ts.map +1 -0
  28. package/dist/runtime/interrupt-internals.js +25 -0
  29. package/dist/runtime/interrupt-internals.js.map +1 -0
  30. package/dist/runtime/types.d.ts +5 -0
  31. package/dist/runtime/types.d.ts.map +1 -1
  32. package/dist/useAgUiRuntime.js +4 -1
  33. package/dist/useAgUiRuntime.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/index.ts +8 -2
  36. package/src/runtime/AgUiThreadRuntimeCore.ts +335 -42
  37. package/src/runtime/adapter/conversions.content.test.ts +278 -0
  38. package/src/runtime/adapter/conversions.ts +298 -50
  39. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  40. package/src/runtime/adapter/run-aggregator.ts +96 -5
  41. package/src/runtime/adapter/subscriber.ts +17 -0
  42. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  43. package/src/runtime/adapter/tool-approval.ts +366 -0
  44. package/src/runtime/event-parser.ts +23 -1
  45. package/src/runtime/interrupt-internals.ts +35 -0
  46. package/src/runtime/types.ts +6 -0
  47. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  48. package/src/useAgUiRuntime.queue.test.tsx +68 -1
  49. package/src/useAgUiRuntime.ts +5 -1
@@ -3,6 +3,7 @@
3
3
  import type { InputContent, RunAgentParameters } from "@ag-ui/client";
4
4
  import type {
5
5
  ThreadMessageLike as CoreThreadMessageLike,
6
+ PartProviderMetadata,
6
7
  ToolCallMessagePartMcpMetadata,
7
8
  ToolModelContentPart,
8
9
  } from "@assistant-ui/core";
@@ -17,6 +18,7 @@ import {
17
18
  AG_UI_METADATA_NAMESPACE,
18
19
  A2UI_SURFACE_ACTIVITY_TYPE,
19
20
  type AgUiCustomMetadata,
21
+ type AgUiOpaqueReasoning,
20
22
  } from "./run-aggregator";
21
23
  import {
22
24
  applyA2uiOperations,
@@ -25,6 +27,7 @@ import {
25
27
  type A2uiSurfaceState,
26
28
  } from "@assistant-ui/react-generative-ui/a2ui";
27
29
  import type { AgUiInterrupt } from "../types";
30
+ import { projectAgUiToolApprovals } from "./tool-approval";
28
31
  import {
29
32
  parseMcpToolCallResult,
30
33
  readMcpAppResourceUri,
@@ -39,15 +42,18 @@ type AttachmentLike = {
39
42
  };
40
43
 
41
44
  type ThreadMessageLike = {
42
- id: string;
45
+ id?: string;
43
46
  role: string;
44
47
  content: unknown;
48
+ metadata?: unknown;
45
49
  name?: string;
46
50
  toolCallId?: string;
47
51
  error?: string;
48
52
  attachments?: readonly AttachmentLike[];
49
53
  };
50
54
 
55
+ type NormalizedThreadMessageLike = ThreadMessageLike & { id: string };
56
+
51
57
  type AgUiToolCall = {
52
58
  id: string;
53
59
  type: "function";
@@ -57,11 +63,29 @@ type AgUiToolCall = {
57
63
  export type AgUiMessage =
58
64
  | {
59
65
  id: string;
60
- role: string;
66
+ role: "user";
61
67
  content: string | InputContent[];
62
68
  name?: string;
69
+ }
70
+ | {
71
+ id: string;
72
+ role: "assistant";
73
+ content: string;
74
+ name?: string;
63
75
  toolCalls?: AgUiToolCall[];
64
76
  }
77
+ | {
78
+ id: string;
79
+ role: "system" | "developer";
80
+ content: string;
81
+ name?: string;
82
+ }
83
+ | {
84
+ id: string;
85
+ role: "reasoning";
86
+ content: string;
87
+ encryptedValue?: string;
88
+ }
65
89
  | {
66
90
  id: string;
67
91
  role: "tool";
@@ -94,6 +118,56 @@ const getString = (record: Record<string, unknown>, key: string) => {
94
118
  const getToolCallId = (record: Record<string, unknown>) =>
95
119
  getString(record, "toolCallId") ?? getString(record, "tool_call_id");
96
120
 
121
+ const readAgUiNamespace = (providerMetadata: unknown) => {
122
+ if (!isObject(providerMetadata)) return undefined;
123
+ const namespaced = providerMetadata[AG_UI_METADATA_NAMESPACE];
124
+ return isObject(namespaced) ? namespaced : undefined;
125
+ };
126
+
127
+ const readAgUiReasoningMeta = (providerMetadata: unknown) => {
128
+ const namespaced = readAgUiNamespace(providerMetadata);
129
+ if (!namespaced) return {};
130
+ return {
131
+ encryptedValue: getString(namespaced, "encryptedValue"),
132
+ reasoningId: getString(namespaced, "reasoningId"),
133
+ };
134
+ };
135
+
136
+ // AG-UI carries per-item extras in `metadata`; anything else on the item is
137
+ // stripped by its schema. The part's own `filename` wins over a key of the
138
+ // same name in the bag.
139
+ const buildInputMetadata = (
140
+ part: Record<string, unknown>,
141
+ filename?: string | undefined,
142
+ ) => {
143
+ const metadata = {
144
+ ...readAgUiNamespace(part.providerMetadata),
145
+ ...(filename !== undefined && { filename }),
146
+ };
147
+ return Object.keys(metadata).length > 0 ? metadata : undefined;
148
+ };
149
+
150
+ // The inverse: `filename` lands on the part's own field, everything else the
151
+ // item carried goes back into the namespace it came from, so a snapshot echo
152
+ // resends what the host attached.
153
+ const readInputMetadata = (
154
+ metadata: unknown,
155
+ ): {
156
+ filename?: string | undefined;
157
+ providerMetadata?: PartProviderMetadata | undefined;
158
+ } => {
159
+ if (!isObject(metadata)) return {};
160
+ const { filename: _filename, ...rest } = metadata;
161
+ return {
162
+ filename: getString(metadata, "filename"),
163
+ ...(Object.keys(rest).length > 0 && {
164
+ providerMetadata: {
165
+ [AG_UI_METADATA_NAMESPACE]: rest as PartProviderMetadata[string],
166
+ },
167
+ }),
168
+ };
169
+ };
170
+
97
171
  function parseJSONText(value: string): unknown {
98
172
  if (!value) return value;
99
173
  try {
@@ -198,7 +272,12 @@ function toInputContent(
198
272
  if (type === "image") {
199
273
  const image = getString(part, "image");
200
274
  if (image === undefined) return null;
201
- return { type: "image", source: buildInputSource(image, fallbackMimeType) };
275
+ const metadata = buildInputMetadata(part, getString(part, "filename"));
276
+ return {
277
+ type: "image",
278
+ source: buildInputSource(image, fallbackMimeType),
279
+ ...(metadata && { metadata }),
280
+ };
202
281
  }
203
282
 
204
283
  if (type === "file") {
@@ -211,7 +290,7 @@ function toInputContent(
211
290
  declaredMimeType,
212
291
  getString(part, "sourceType"),
213
292
  );
214
- const metadata = filename !== undefined ? { filename } : undefined;
293
+ const metadata = buildInputMetadata(part, filename);
215
294
  switch (mediaTypeForMime(source.mimeType)) {
216
295
  case "image":
217
296
  return { type: "image", source, ...(metadata && { metadata }) };
@@ -312,9 +391,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
312
391
  const source = inputSourceToString(part.source);
313
392
  if (!source) continue;
314
393
 
315
- const filename = isObject(part.metadata)
316
- ? getString(part.metadata, "filename")
317
- : undefined;
394
+ const { filename, providerMetadata } = readInputMetadata(part.metadata);
318
395
  const id = attachments.length.toString();
319
396
 
320
397
  if (type === "image") {
@@ -329,6 +406,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
329
406
  type: "image",
330
407
  image: source.value,
331
408
  ...(filename !== undefined && { filename }),
409
+ ...(providerMetadata && { providerMetadata }),
332
410
  },
333
411
  ],
334
412
  });
@@ -349,6 +427,7 @@ function toSnapshotAttachments(content: unknown): SnapshotAttachment[] {
349
427
  mimeType,
350
428
  ...(source.isUrl && { sourceType: "url" as const }),
351
429
  ...(filename !== undefined && { filename }),
430
+ ...(providerMetadata && { providerMetadata }),
352
431
  },
353
432
  ],
354
433
  });
@@ -498,17 +577,71 @@ function readPersistedInterrupts(
498
577
  return validateInterrupts(namespaced.interrupts);
499
578
  }
500
579
 
580
+ function readOpaqueReasoning(metadata: unknown): AgUiOpaqueReasoning[] {
581
+ if (!isObject(metadata)) return [];
582
+ const custom = metadata.custom;
583
+ if (!isObject(custom)) return [];
584
+ const namespaced = custom[AG_UI_METADATA_NAMESPACE];
585
+ if (!isObject(namespaced)) return [];
586
+ const entries = namespaced.opaqueReasoning;
587
+ if (!Array.isArray(entries)) return [];
588
+ return entries.flatMap((entry) => {
589
+ if (!isObject(entry)) return [];
590
+ const id = getString(entry, "id");
591
+ const encryptedValue = getString(entry, "encryptedValue");
592
+ if (!id?.trim() || !encryptedValue?.trim()) return [];
593
+ return [
594
+ { id, encryptedValue, ...(entry.after === true ? { after: true } : {}) },
595
+ ];
596
+ });
597
+ }
598
+
599
+ function withOpaqueReasoning(
600
+ message: CoreThreadMessageLike,
601
+ opaqueReasoning: AgUiOpaqueReasoning[],
602
+ ): CoreThreadMessageLike {
603
+ const metadata = isObject(message.metadata) ? message.metadata : {};
604
+ const custom = isObject(metadata.custom) ? metadata.custom : {};
605
+ const namespaced = isObject(custom[AG_UI_METADATA_NAMESPACE])
606
+ ? (custom[AG_UI_METADATA_NAMESPACE] as Record<string, unknown>)
607
+ : {};
608
+ return {
609
+ ...message,
610
+ metadata: {
611
+ ...metadata,
612
+ custom: {
613
+ ...custom,
614
+ [AG_UI_METADATA_NAMESPACE]: { ...namespaced, opaqueReasoning },
615
+ },
616
+ },
617
+ } as CoreThreadMessageLike;
618
+ }
619
+
501
620
  function toAssistantSnapshotMessage(
502
621
  rawMessage: Record<string, unknown>,
503
622
  ): CoreThreadMessageLike {
504
623
  const text = extractText(rawMessage.content);
505
- const toolCallParts = extractAssistantToolCalls(rawMessage);
624
+ const interrupts = readPersistedInterrupts(rawMessage.metadata);
625
+ const restoredToolCalls = extractAssistantToolCalls(rawMessage);
626
+ const approvals = projectAgUiToolApprovals(
627
+ interrupts,
628
+ new Set(
629
+ restoredToolCalls
630
+ .map((part) => part.toolCallId)
631
+ .filter((id): id is string => !!id),
632
+ ),
633
+ );
634
+ const toolCallParts = restoredToolCalls.map((part) => {
635
+ const approval = part.toolCallId
636
+ ? approvals.get(part.toolCallId)
637
+ : undefined;
638
+ return approval ? { ...part, approval } : part;
639
+ });
506
640
  const assistantContent = [
507
641
  ...(text.length > 0 ? [{ type: "text" as const, text }] : []),
508
642
  ...toolCallParts,
509
643
  ];
510
644
  const messageName = getString(rawMessage, "name");
511
- const interrupts = readPersistedInterrupts(rawMessage.metadata);
512
645
  return {
513
646
  id: getString(rawMessage, "id") ?? generateId(),
514
647
  role: "assistant",
@@ -596,6 +729,11 @@ export function fromAgUiMessages(
596
729
  const converted: CoreThreadMessageLike[] = [];
597
730
  const a2uiBuckets = new Map<string, A2uiState>();
598
731
  const a2uiBucketOwnerIndices = new Map<string, number>();
732
+ // A zero-data-retention run carries its payload in encryptedValue with no
733
+ // readable content, so the record has no part to become and rides on the
734
+ // neighbouring message instead of being dropped. The anchor is the index the
735
+ // next pushed message will occupy, which is where it sat on the wire.
736
+ const opaqueReasoning: (AgUiOpaqueReasoning & { anchor: number })[] = [];
599
737
 
600
738
  for (const rawMessage of messages) {
601
739
  if (!isObject(rawMessage)) continue;
@@ -750,15 +888,53 @@ export function fromAgUiMessages(
750
888
  }
751
889
 
752
890
  if (role === "reasoning") {
753
- // Gate on showThinking so a cold reload matches the live run: the
754
- // aggregator never stores reasoning parts when showThinking is false.
755
- if (!showThinking) continue;
756
891
  const text = extractText(rawMessage.content);
757
- if (text.trim().length === 0) continue;
892
+ const encryptedValue = getString(rawMessage, "encryptedValue");
893
+ if (text.trim().length === 0) {
894
+ // showThinking hides reasoning; this record is never rendered anyway,
895
+ // and dropping it would deny the provider the payload it needs to
896
+ // accept the next run.
897
+ const opaqueId = getString(rawMessage, "id");
898
+ if (opaqueId?.trim() && encryptedValue?.trim()) {
899
+ opaqueReasoning.push({
900
+ id: opaqueId,
901
+ encryptedValue,
902
+ anchor: converted.length,
903
+ });
904
+ }
905
+ continue;
906
+ }
907
+ // Gate visible reasoning on showThinking so a cold reload matches the
908
+ // live run: the aggregator never stores reasoning parts when it is off.
909
+ // A signature on a hidden record is still transport state, so it is kept
910
+ // opaque rather than discarded with the text.
911
+ if (!showThinking) {
912
+ const hiddenId = getString(rawMessage, "id");
913
+ if (hiddenId?.trim() && encryptedValue?.trim()) {
914
+ opaqueReasoning.push({
915
+ id: hiddenId,
916
+ encryptedValue,
917
+ anchor: converted.length,
918
+ });
919
+ }
920
+ continue;
921
+ }
758
922
  converted.push({
759
923
  id: getString(rawMessage, "id") ?? generateId(),
760
924
  role: "assistant",
761
- content: [{ type: "reasoning", text }],
925
+ content: [
926
+ {
927
+ type: "reasoning",
928
+ text,
929
+ ...(encryptedValue !== undefined
930
+ ? {
931
+ providerMetadata: {
932
+ [AG_UI_METADATA_NAMESPACE]: { encryptedValue },
933
+ },
934
+ }
935
+ : {}),
936
+ },
937
+ ],
762
938
  });
763
939
  continue;
764
940
  }
@@ -768,6 +944,18 @@ export function fromAgUiMessages(
768
944
  }
769
945
  }
770
946
 
947
+ for (const { anchor, ...entry } of opaqueReasoning) {
948
+ // Nothing followed it on the wire, so it trails the last message instead.
949
+ const trailing = anchor >= converted.length;
950
+ const index = trailing ? converted.length - 1 : anchor;
951
+ const target = converted[index];
952
+ if (!target) continue;
953
+ converted[index] = withOpaqueReasoning(target, [
954
+ ...readOpaqueReasoning(target.metadata),
955
+ ...(trailing ? [{ ...entry, after: true }] : [entry]),
956
+ ]);
957
+ }
958
+
771
959
  for (let i = 0; i < converted.length; i++) {
772
960
  const message = converted[i]!;
773
961
  if (message.role !== "assistant") continue;
@@ -801,7 +989,7 @@ export function fromAgUiMessages(
801
989
  }
802
990
 
803
991
  function convertAssistantMessage(
804
- message: ThreadMessageLike,
992
+ message: NormalizedThreadMessageLike,
805
993
  converted: AgUiMessage[],
806
994
  ): void {
807
995
  const content = extractText(message.content);
@@ -821,24 +1009,47 @@ function convertAssistantMessage(
821
1009
  part,
822
1010
  }));
823
1011
 
1012
+ // An AG-UI assistant record has no reasoning field, so reasoning leaves as
1013
+ // the standalone record it arrived as, ahead of the assistant it belongs to.
1014
+ const shellIsDropped = content.length === 0 && toolCalls.length === 0;
1015
+ let reasoningIndex = 0;
1016
+ for (const part of contentArray) {
1017
+ if (!isObject(part) || part.type !== "reasoning") continue;
1018
+ const text = getString(part, "text") ?? "";
1019
+ if (text.trim().length === 0) continue;
1020
+ const { encryptedValue, reasoningId } = readAgUiReasoningMeta(
1021
+ part.providerMetadata,
1022
+ );
1023
+ converted.push({
1024
+ // The wire id is what a signature was issued against, so it wins over a
1025
+ // synthesized one whenever the run carried it.
1026
+ id: reasoningId?.trim()
1027
+ ? reasoningId
1028
+ : shellIsDropped && reasoningIndex === 0
1029
+ ? message.id
1030
+ : `${message.id}:reasoning-${reasoningIndex}`,
1031
+ role: "reasoning",
1032
+ content: text,
1033
+ ...(encryptedValue !== undefined ? { encryptedValue } : {}),
1034
+ });
1035
+ reasoningIndex++;
1036
+ }
1037
+
824
1038
  // Drop assistant messages with no text or tool calls (e.g. an imported
825
1039
  // reasoning-only entry) so they are not re-sent as a blank assistant turn.
826
- if (content.length === 0 && toolCalls.length === 0) {
1040
+ if (shellIsDropped) {
827
1041
  return;
828
1042
  }
829
1043
 
830
- const assistantMessage: AgUiMessage = {
1044
+ converted.push({
831
1045
  id: message.id,
832
1046
  role: "assistant",
833
1047
  content,
834
- };
835
- if (message.name) {
836
- assistantMessage.name = message.name;
837
- }
838
- if (toolCalls.length > 0) {
839
- assistantMessage.toolCalls = toolCalls.map((entry) => entry.call);
840
- }
841
- converted.push(assistantMessage);
1048
+ ...(message.name ? { name: message.name } : {}),
1049
+ ...(toolCalls.length > 0
1050
+ ? { toolCalls: toolCalls.map((entry) => entry.call) }
1051
+ : {}),
1052
+ });
842
1053
 
843
1054
  for (const { id: toolCallId, part } of toolCalls) {
844
1055
  if (part.result === undefined) continue;
@@ -850,36 +1061,30 @@ function convertAssistantMessage(
850
1061
  ? part.result
851
1062
  : JSON.stringify(part.result);
852
1063
 
853
- const toolMessage: AgUiMessage = {
1064
+ converted.push({
854
1065
  id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
855
1066
  role: "tool",
856
1067
  content: resultContent,
857
1068
  toolCallId,
858
- };
859
- if (part.isError) {
860
- toolMessage.error = resultContent;
861
- }
862
- converted.push(toolMessage);
1069
+ ...(part.isError ? { error: resultContent } : {}),
1070
+ });
863
1071
  }
864
1072
  }
865
1073
 
866
1074
  function convertToolMessage(
867
- message: ThreadMessageLike,
1075
+ message: NormalizedThreadMessageLike,
868
1076
  converted: AgUiMessage[],
869
1077
  ): void {
870
1078
  const content = extractText(message.content);
871
1079
  const toolCallId = message.toolCallId ?? generateId();
872
1080
 
873
- const toolMessage: AgUiMessage = {
1081
+ converted.push({
874
1082
  id: message.id,
875
1083
  role: "tool",
876
1084
  content,
877
1085
  toolCallId,
878
- };
879
- if (typeof message.error === "string") {
880
- toolMessage.error = message.error;
881
- }
882
- converted.push(toolMessage);
1086
+ ...(typeof message.error === "string" ? { error: message.error } : {}),
1087
+ });
883
1088
  }
884
1089
 
885
1090
  export function toAgUiMessages(
@@ -887,29 +1092,72 @@ export function toAgUiMessages(
887
1092
  ): AgUiMessage[] {
888
1093
  const converted: AgUiMessage[] = [];
889
1094
 
890
- for (const message of messages) {
1095
+ for (const rawMessage of messages) {
1096
+ const message: NormalizedThreadMessageLike = {
1097
+ ...rawMessage,
1098
+ id: rawMessage.id ?? generateId(),
1099
+ };
1100
+ const opaqueReasoning = readOpaqueReasoning(message.metadata);
1101
+ const toOpaqueRecord = (entry: AgUiOpaqueReasoning): AgUiMessage => ({
1102
+ id: entry.id,
1103
+ role: "reasoning",
1104
+ content: "",
1105
+ encryptedValue: entry.encryptedValue,
1106
+ });
1107
+ for (const entry of opaqueReasoning) {
1108
+ if (entry.after !== true) converted.push(toOpaqueRecord(entry));
1109
+ }
1110
+ const flushTrailingOpaqueReasoning = () => {
1111
+ for (const entry of opaqueReasoning) {
1112
+ if (entry.after === true) converted.push(toOpaqueRecord(entry));
1113
+ }
1114
+ };
1115
+
891
1116
  if (message.role === "assistant") {
892
1117
  convertAssistantMessage(message, converted);
1118
+ flushTrailingOpaqueReasoning();
893
1119
  continue;
894
1120
  }
895
1121
 
896
1122
  if (message.role === "tool") {
897
1123
  convertToolMessage(message, converted);
1124
+ flushTrailingOpaqueReasoning();
898
1125
  continue;
899
1126
  }
900
1127
 
901
- const genericMessage: AgUiMessage = {
902
- id: message.id,
903
- role: message.role,
904
- content:
905
- message.role === "user"
906
- ? buildUserContent(message)
907
- : extractText(message.content),
908
- };
909
- if (message.name) {
910
- genericMessage.name = message.name;
1128
+ if (message.role === "user") {
1129
+ converted.push({
1130
+ id: message.id,
1131
+ role: "user",
1132
+ content: buildUserContent(message),
1133
+ ...(message.name ? { name: message.name } : {}),
1134
+ });
1135
+ flushTrailingOpaqueReasoning();
1136
+ continue;
1137
+ }
1138
+
1139
+ if (message.role === "system" || message.role === "developer") {
1140
+ converted.push({
1141
+ id: message.id,
1142
+ role: message.role,
1143
+ content: extractText(message.content),
1144
+ ...(message.name ? { name: message.name } : {}),
1145
+ });
1146
+ flushTrailingOpaqueReasoning();
1147
+ continue;
911
1148
  }
912
- converted.push(genericMessage);
1149
+
1150
+ if (message.role === "reasoning") {
1151
+ converted.push({
1152
+ id: message.id,
1153
+ role: "reasoning",
1154
+ content: extractText(message.content),
1155
+ });
1156
+ flushTrailingOpaqueReasoning();
1157
+ continue;
1158
+ }
1159
+
1160
+ flushTrailingOpaqueReasoning();
913
1161
  }
914
1162
 
915
1163
  return converted;
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ChatModelRunResult } from "@assistant-ui/core";
3
+ import { RunAggregator } from "./run-aggregator";
4
+ import type { AgUiInterrupt } from "../types";
5
+
6
+ const GATE: AgUiInterrupt = {
7
+ id: "int-1",
8
+ reason: "tool_call",
9
+ toolCallId: "tc-1",
10
+ message: "Delete /tmp/a?",
11
+ };
12
+
13
+ const noopLogger = {
14
+ debug: vi.fn(),
15
+ info: vi.fn(),
16
+ warn: vi.fn(),
17
+ error: vi.fn(),
18
+ } as any;
19
+
20
+ const gatedAggregator = () => {
21
+ const emitted: ChatModelRunResult[] = [];
22
+ const aggregator = new RunAggregator({
23
+ showThinking: false,
24
+ logger: noopLogger,
25
+ emit: (result) => {
26
+ emitted.push(result);
27
+ },
28
+ });
29
+
30
+ aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
31
+ aggregator.handle({
32
+ type: "TOOL_CALL_START",
33
+ toolCallId: "tc-1",
34
+ toolCallName: "delete_file",
35
+ });
36
+ aggregator.handle({
37
+ type: "TOOL_CALL_ARGS",
38
+ toolCallId: "tc-1",
39
+ delta: '{"path":"/tmp/a"}',
40
+ });
41
+ aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "tc-1" });
42
+ aggregator.handle({
43
+ type: "RUN_FINISHED",
44
+ runId: "run-1",
45
+ outcome: { type: "interrupt", interrupts: [GATE] },
46
+ } as any);
47
+
48
+ return { aggregator, emitted, last: () => emitted[emitted.length - 1]! };
49
+ };
50
+
51
+ const approvalOf = (result: ChatModelRunResult) => {
52
+ const part = (result.content ?? []).find((p) => p.type === "tool-call");
53
+ return (part as { approval?: unknown } | undefined)?.approval;
54
+ };
55
+
56
+ describe("RunAggregator tool approval projection", () => {
57
+ it("projects the gate while the run awaits the interrupt", () => {
58
+ const { last } = gatedAggregator();
59
+
60
+ expect(last().status).toMatchObject({
61
+ type: "requires-action",
62
+ reason: "interrupt",
63
+ });
64
+ expect(approvalOf(last())).toMatchObject({ id: "int-1" });
65
+ });
66
+
67
+ /**
68
+ * The gate cannot be clicked once the run is unresumable, but the interrupts
69
+ * stay on the message: the bespoke hooks read that payload, and dropping it
70
+ * would take a shipped surface away with the projection.
71
+ */
72
+ it("drops the gate when the run then errors, keeping the interrupts", () => {
73
+ const { aggregator, last } = gatedAggregator();
74
+
75
+ aggregator.handle({ type: "RUN_ERROR", message: "stream dropped" } as any);
76
+
77
+ expect(last().status).toMatchObject({
78
+ type: "incomplete",
79
+ reason: "error",
80
+ });
81
+ expect(approvalOf(last())).toBeUndefined();
82
+ expect(
83
+ (last().metadata?.custom as Record<string, any> | undefined)?.agui
84
+ ?.interrupts,
85
+ ).toEqual([GATE]);
86
+ });
87
+
88
+ it("drops the gate when the run is then cancelled, keeping the interrupts", () => {
89
+ const { aggregator, last } = gatedAggregator();
90
+
91
+ aggregator.handle({ type: "RUN_CANCELLED" } as any);
92
+
93
+ expect(last().status).toMatchObject({
94
+ type: "incomplete",
95
+ reason: "cancelled",
96
+ });
97
+ expect(approvalOf(last())).toBeUndefined();
98
+ expect(
99
+ (last().metadata?.custom as Record<string, any> | undefined)?.agui
100
+ ?.interrupts,
101
+ ).toEqual([GATE]);
102
+ });
103
+ });