@assistant-ui/react-ag-ui 0.0.69 → 0.0.70

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.
@@ -69,6 +69,7 @@ export type AgUiMessage =
69
69
  role: "user";
70
70
  content: string | InputContent[];
71
71
  name?: string;
72
+ metadata?: Record<string, unknown>;
72
73
  }
73
74
  | {
74
75
  id: string;
@@ -76,18 +77,21 @@ export type AgUiMessage =
76
77
  content: string;
77
78
  name?: string;
78
79
  toolCalls?: AgUiToolCall[];
80
+ metadata?: Record<string, unknown>;
79
81
  }
80
82
  | {
81
83
  id: string;
82
84
  role: "system" | "developer";
83
85
  content: string;
84
86
  name?: string;
87
+ metadata?: Record<string, unknown>;
85
88
  }
86
89
  | {
87
90
  id: string;
88
91
  role: "reasoning";
89
92
  content: string;
90
93
  encryptedValue?: string;
94
+ metadata?: Record<string, unknown>;
91
95
  }
92
96
  | {
93
97
  id: string;
@@ -95,6 +99,7 @@ export type AgUiMessage =
95
99
  content: string;
96
100
  toolCallId: string;
97
101
  error?: string;
102
+ metadata?: Record<string, unknown>;
98
103
  };
99
104
 
100
105
  type CoreToolCallPart = Extract<
@@ -117,6 +122,108 @@ const getString = (record: Record<string, unknown>, key: string) => {
117
122
  return typeof value === "string" ? value : undefined;
118
123
  };
119
124
 
125
+ function toAgUiWireMetadata(
126
+ metadata: unknown,
127
+ ): Record<string, unknown> | undefined {
128
+ return isObject(metadata) ? { ...metadata } : undefined;
129
+ }
130
+
131
+ function readLegacyAgUiNamespace(metadata: unknown): Record<string, unknown> {
132
+ if (!isObject(metadata) || !isObject(metadata.custom)) return {};
133
+ const namespaced = metadata.custom[AG_UI_METADATA_NAMESPACE];
134
+ if (!isObject(namespaced)) return {};
135
+ return "interrupts" in namespaced
136
+ ? { interrupts: namespaced.interrupts }
137
+ : {};
138
+ }
139
+
140
+ function withoutPersistedInterrupts(
141
+ wire: Record<string, unknown>,
142
+ ): Record<string, unknown> {
143
+ if (!isObject(wire.custom)) return wire;
144
+ 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
+ const { [AG_UI_METADATA_NAMESPACE]: _namespace, ...remainingCustom } =
149
+ wire.custom;
150
+ if (Object.keys(remainingNamespace).length > 0) {
151
+ remainingCustom[AG_UI_METADATA_NAMESPACE] = remainingNamespace;
152
+ }
153
+ const { custom: _custom, ...remainingWire } = wire;
154
+ return Object.keys(remainingCustom).length > 0
155
+ ? { ...remainingWire, custom: remainingCustom }
156
+ : remainingWire;
157
+ }
158
+
159
+ function toSnapshotCustom(
160
+ metadata: unknown,
161
+ state: Record<string, unknown> = {},
162
+ ): Record<string, unknown> {
163
+ const wire = withoutPersistedInterrupts(toAgUiWireMetadata(metadata) ?? {});
164
+ const needsWireSlot =
165
+ isObject(metadata) &&
166
+ (Object.keys(metadata).length === 0 || AG_UI_METADATA_NAMESPACE in wire);
167
+ const namespaced = {
168
+ ...readLegacyAgUiNamespace(metadata),
169
+ ...state,
170
+ ...(needsWireSlot ? { messageMetadata: wire } : {}),
171
+ };
172
+ return {
173
+ ...wire,
174
+ ...(Object.keys(namespaced).length > 0
175
+ ? { [AG_UI_METADATA_NAMESPACE]: namespaced }
176
+ : {}),
177
+ };
178
+ }
179
+
180
+ function toAgUiMessageMetadata(
181
+ metadata: unknown,
182
+ ): Record<string, unknown> | undefined {
183
+ const custom = readCustomMetadata(metadata);
184
+ const namespaced = readNamespacedMetadata(metadata);
185
+ if (isObject(namespaced.messageMetadata)) {
186
+ return { ...namespaced.messageMetadata };
187
+ }
188
+ const { [AG_UI_METADATA_NAMESPACE]: _state, ...wire } = custom;
189
+ return Object.keys(wire).length > 0 ? wire : undefined;
190
+ }
191
+
192
+ function withAgUiMessageMetadata(metadata: unknown): {
193
+ metadata?: Record<string, unknown>;
194
+ } {
195
+ const messageMetadata = toAgUiMessageMetadata(metadata);
196
+ return messageMetadata !== undefined ? { metadata: messageMetadata } : {};
197
+ }
198
+
199
+ function withAgUiPartMessageMetadata(
200
+ providerMetadata: PartProviderMetadata | undefined,
201
+ messageMetadata: Record<string, unknown> | undefined,
202
+ ): PartProviderMetadata {
203
+ if (!messageMetadata) return providerMetadata ?? {};
204
+
205
+ const existing = providerMetadata ?? {};
206
+ const namespaced = isObject(existing[AG_UI_METADATA_NAMESPACE])
207
+ ? existing[AG_UI_METADATA_NAMESPACE]
208
+ : {};
209
+ return {
210
+ ...existing,
211
+ [AG_UI_METADATA_NAMESPACE]: {
212
+ ...namespaced,
213
+ messageMetadata: messageMetadata as ReadonlyJSONObject,
214
+ },
215
+ };
216
+ }
217
+
218
+ function readAgUiPartMessageMetadata(
219
+ providerMetadata: unknown,
220
+ ): Record<string, unknown> | undefined {
221
+ const namespaced = readAgUiNamespace(providerMetadata);
222
+ return namespaced && isObject(namespaced.messageMetadata)
223
+ ? toAgUiWireMetadata(namespaced.messageMetadata)
224
+ : undefined;
225
+ }
226
+
120
227
  const getToolCallId = (record: Record<string, unknown>) =>
121
228
  getString(record, "toolCallId") ?? getString(record, "tool_call_id");
122
229
 
@@ -132,6 +239,7 @@ const readAgUiReasoningMeta = (providerMetadata: unknown) => {
132
239
  return {
133
240
  encryptedValue: getString(namespaced, "encryptedValue"),
134
241
  reasoningId: getString(namespaced, "reasoningId"),
242
+ messageMetadata: toAgUiWireMetadata(namespaced.messageMetadata),
135
243
  };
136
244
  };
137
245
 
@@ -600,8 +708,14 @@ function readOpaqueReasoning(metadata: unknown): AgUiOpaqueReasoning[] {
600
708
  const id = getString(entry, "id");
601
709
  const encryptedValue = getString(entry, "encryptedValue");
602
710
  if (!id?.trim() || !encryptedValue?.trim()) return [];
711
+ const metadata = toAgUiWireMetadata(entry.metadata);
603
712
  return [
604
- { id, encryptedValue, ...(entry.after === true ? { after: true } : {}) },
713
+ {
714
+ id,
715
+ encryptedValue,
716
+ ...(metadata ? { metadata } : {}),
717
+ ...(entry.after === true ? { after: true } : {}),
718
+ },
605
719
  ];
606
720
  });
607
721
  }
@@ -631,7 +745,8 @@ function toAssistantSnapshotMessage(
631
745
  rawMessage: Record<string, unknown>,
632
746
  ): CoreThreadMessageLike {
633
747
  const text = extractText(rawMessage.content);
634
- const interrupts = readPersistedInterrupts(rawMessage.metadata);
748
+ const custom = toSnapshotCustom(rawMessage.metadata);
749
+ const interrupts = readPersistedInterrupts({ custom });
635
750
  const restoredToolCalls = extractAssistantToolCalls(rawMessage);
636
751
  const approvals = projectAgUiToolApprovals(
637
752
  interrupts,
@@ -652,21 +767,24 @@ function toAssistantSnapshotMessage(
652
767
  ...toolCallParts,
653
768
  ];
654
769
  const messageName = getString(rawMessage, "name");
770
+ const namespaced = isObject(custom[AG_UI_METADATA_NAMESPACE])
771
+ ? { ...custom[AG_UI_METADATA_NAMESPACE] }
772
+ : {};
773
+ if (interrupts) namespaced.interrupts = interrupts;
774
+ else delete namespaced.interrupts;
775
+ const messageCustom = { ...custom };
776
+ if (Object.keys(namespaced).length > 0) {
777
+ messageCustom[AG_UI_METADATA_NAMESPACE] = namespaced;
778
+ } else {
779
+ delete messageCustom[AG_UI_METADATA_NAMESPACE];
780
+ }
655
781
  return {
656
782
  id: getString(rawMessage, "id") ?? generateId(),
657
783
  role: "assistant",
658
784
  content: assistantContent.length > 0 ? assistantContent : "",
659
785
  ...(messageName !== undefined ? { name: messageName } : {}),
660
- ...(interrupts
661
- ? {
662
- metadata: {
663
- custom: {
664
- [AG_UI_METADATA_NAMESPACE]: {
665
- interrupts,
666
- } satisfies AgUiCustomMetadata,
667
- },
668
- },
669
- }
786
+ ...(Object.keys(messageCustom).length > 0
787
+ ? { metadata: { custom: messageCustom } }
670
788
  : {}),
671
789
  };
672
790
  }
@@ -676,6 +794,10 @@ function toUserOrSystemSnapshotMessage(
676
794
  rawMessage: Record<string, unknown>,
677
795
  ): CoreThreadMessageLike {
678
796
  const messageName = getString(rawMessage, "name");
797
+ const custom = toSnapshotCustom(
798
+ rawMessage.metadata,
799
+ role === "developer" ? { role: "developer" } : {},
800
+ );
679
801
  const attachments =
680
802
  role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
681
803
  return {
@@ -686,17 +808,7 @@ function toUserOrSystemSnapshotMessage(
686
808
  content: extractText(rawMessage.content),
687
809
  ...(messageName !== undefined ? { name: messageName } : {}),
688
810
  ...(attachments.length > 0 ? { attachments } : {}),
689
- ...(role === "developer"
690
- ? {
691
- metadata: {
692
- custom: {
693
- [AG_UI_METADATA_NAMESPACE]: {
694
- role: "developer",
695
- } satisfies AgUiCustomMetadata,
696
- },
697
- },
698
- }
699
- : {}),
811
+ ...(Object.keys(custom).length > 0 ? { metadata: { custom } } : {}),
700
812
  };
701
813
  }
702
814
 
@@ -941,6 +1053,7 @@ export function fromAgUiMessages(
941
1053
  const mcpAppUri = readMcpAppResourceUri(mcpResult?._meta);
942
1054
  const mcpApp =
943
1055
  mcpAppUri !== undefined ? { resourceUri: mcpAppUri } : undefined;
1056
+ const messageMetadata = toAgUiWireMetadata(rawMessage.metadata);
944
1057
 
945
1058
  let updated = false;
946
1059
  for (
@@ -974,6 +1087,14 @@ export function fromAgUiMessages(
974
1087
  ? { unstable_toolMessageId: toolMessageId }
975
1088
  : {}),
976
1089
  ...(mcpApp ? { mcp: { app: mcpApp } } : {}),
1090
+ ...(messageMetadata
1091
+ ? {
1092
+ providerMetadata: withAgUiPartMessageMetadata(
1093
+ (part as ToolCallPart).providerMetadata,
1094
+ messageMetadata,
1095
+ ),
1096
+ }
1097
+ : {}),
977
1098
  };
978
1099
  const updatedContent = message.content.map((contentPart, index) =>
979
1100
  index === partIndex ? updatedPart : contentPart,
@@ -1010,6 +1131,14 @@ export function fromAgUiMessages(
1010
1131
  ? { unstable_toolMessageId: toolMessageId }
1011
1132
  : {}),
1012
1133
  ...(mcpApp ? { mcp: { app: mcpApp } } : {}),
1134
+ ...(messageMetadata
1135
+ ? {
1136
+ providerMetadata: withAgUiPartMessageMetadata(
1137
+ undefined,
1138
+ messageMetadata,
1139
+ ),
1140
+ }
1141
+ : {}),
1013
1142
  },
1014
1143
  ],
1015
1144
  });
@@ -1108,6 +1237,7 @@ export function fromAgUiMessages(
1108
1237
  if (role === "reasoning") {
1109
1238
  const text = extractText(rawMessage.content);
1110
1239
  const encryptedValue = getString(rawMessage, "encryptedValue");
1240
+ const messageMetadata = toAgUiWireMetadata(rawMessage.metadata);
1111
1241
  if (text.trim().length === 0) {
1112
1242
  // showThinking hides reasoning; this record is never rendered anyway,
1113
1243
  // and dropping it would deny the provider the payload it needs to
@@ -1120,6 +1250,7 @@ export function fromAgUiMessages(
1120
1250
  opaqueReasoning.push({
1121
1251
  id: opaqueId,
1122
1252
  encryptedValue,
1253
+ ...(messageMetadata ? { metadata: messageMetadata } : {}),
1123
1254
  anchor: converted.length,
1124
1255
  });
1125
1256
  }
@@ -1135,6 +1266,7 @@ export function fromAgUiMessages(
1135
1266
  opaqueReasoning.push({
1136
1267
  id: hiddenId,
1137
1268
  encryptedValue,
1269
+ ...(messageMetadata ? { metadata: messageMetadata } : {}),
1138
1270
  anchor: converted.length,
1139
1271
  });
1140
1272
  }
@@ -1149,6 +1281,9 @@ export function fromAgUiMessages(
1149
1281
  const meta = {
1150
1282
  ...(reasoningId !== undefined ? { reasoningId } : {}),
1151
1283
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
1284
+ ...(messageMetadata
1285
+ ? { messageMetadata: messageMetadata as ReadonlyJSONObject }
1286
+ : {}),
1152
1287
  };
1153
1288
  pendingReasoning.push({
1154
1289
  id: reasoningId,
@@ -1265,9 +1400,8 @@ function convertAssistantMessage(
1265
1400
  if (!isObject(part) || part.type !== "reasoning") continue;
1266
1401
  const text = getString(part, "text") ?? "";
1267
1402
  if (text.trim().length === 0) continue;
1268
- const { encryptedValue, reasoningId } = readAgUiReasoningMeta(
1269
- part.providerMetadata,
1270
- );
1403
+ const { encryptedValue, reasoningId, messageMetadata } =
1404
+ readAgUiReasoningMeta(part.providerMetadata);
1271
1405
  converted.push({
1272
1406
  // The wire id is what a signature was issued against, so it wins over a
1273
1407
  // synthesized one whenever the run carried it.
@@ -1279,12 +1413,12 @@ function convertAssistantMessage(
1279
1413
  role: "reasoning",
1280
1414
  content: text,
1281
1415
  ...(encryptedValue !== undefined ? { encryptedValue } : {}),
1416
+ ...(messageMetadata ? { metadata: messageMetadata } : {}),
1282
1417
  });
1283
1418
  reasoningIndex++;
1284
1419
  }
1285
1420
 
1286
- // Drop assistant messages with no text or tool calls (e.g. an imported
1287
- // reasoning-only entry) so they are not re-sent as a blank assistant turn.
1421
+ // Some providers reject a replayed blank assistant turn, even when it carries metadata.
1288
1422
  if (shellIsDropped) {
1289
1423
  return;
1290
1424
  }
@@ -1319,6 +1453,7 @@ function convertAssistantMessage(
1319
1453
  role: "assistant",
1320
1454
  content,
1321
1455
  ...(message.name ? { name: message.name } : {}),
1456
+ ...withAgUiMessageMetadata(message.metadata),
1322
1457
  ...(toolCalls.length + nestedToolCalls.length > 0
1323
1458
  ? {
1324
1459
  toolCalls: [...toolCalls, ...nestedToolCalls].map(
@@ -1356,6 +1491,7 @@ function emitToolResult(
1356
1491
  : typeof part.result === "string"
1357
1492
  ? part.result
1358
1493
  : JSON.stringify(part.result);
1494
+ const messageMetadata = readAgUiPartMessageMetadata(part.providerMetadata);
1359
1495
 
1360
1496
  converted.push({
1361
1497
  id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
@@ -1363,6 +1499,7 @@ function emitToolResult(
1363
1499
  content: resultContent,
1364
1500
  toolCallId,
1365
1501
  ...(part.isError ? { error: resultContent } : {}),
1502
+ ...(messageMetadata ? { metadata: messageMetadata } : {}),
1366
1503
  });
1367
1504
  }
1368
1505
 
@@ -1379,6 +1516,7 @@ function convertToolMessage(
1379
1516
  content,
1380
1517
  toolCallId,
1381
1518
  ...(typeof message.error === "string" ? { error: message.error } : {}),
1519
+ ...withAgUiMessageMetadata(message.metadata),
1382
1520
  });
1383
1521
  }
1384
1522
 
@@ -1398,6 +1536,7 @@ export function toAgUiMessages(
1398
1536
  role: "reasoning",
1399
1537
  content: "",
1400
1538
  encryptedValue: entry.encryptedValue,
1539
+ ...(entry.metadata ? { metadata: entry.metadata } : {}),
1401
1540
  });
1402
1541
  for (const entry of opaqueReasoning) {
1403
1542
  if (entry.after !== true) converted.push(toOpaqueRecord(entry));
@@ -1426,6 +1565,7 @@ export function toAgUiMessages(
1426
1565
  role: "user",
1427
1566
  content: buildUserContent(message),
1428
1567
  ...(message.name ? { name: message.name } : {}),
1568
+ ...withAgUiMessageMetadata(message.metadata),
1429
1569
  });
1430
1570
  flushTrailingOpaqueReasoning();
1431
1571
  continue;
@@ -1449,6 +1589,7 @@ export function toAgUiMessages(
1449
1589
  role: wireRole,
1450
1590
  content: extractText(message.content),
1451
1591
  ...(message.name ? { name: message.name } : {}),
1592
+ ...withAgUiMessageMetadata(message.metadata),
1452
1593
  });
1453
1594
  flushTrailingOpaqueReasoning();
1454
1595
  continue;
@@ -1459,6 +1600,7 @@ export function toAgUiMessages(
1459
1600
  id: message.id,
1460
1601
  role: "reasoning",
1461
1602
  content: extractText(message.content),
1603
+ ...withAgUiMessageMetadata(message.metadata),
1462
1604
  });
1463
1605
  flushTrailingOpaqueReasoning();
1464
1606
  continue;
@@ -85,16 +85,16 @@ describe("RunAggregator tool approval projection", () => {
85
85
  ).toEqual([GATE]);
86
86
  });
87
87
 
88
- it("drops the gate when the run is then cancelled, keeping the interrupts", () => {
88
+ it("keeps the gate when the finished run is then cancelled", () => {
89
89
  const { aggregator, last } = gatedAggregator();
90
90
 
91
91
  aggregator.handle({ type: "RUN_CANCELLED" } as any);
92
92
 
93
93
  expect(last().status).toMatchObject({
94
- type: "incomplete",
95
- reason: "cancelled",
94
+ type: "requires-action",
95
+ reason: "interrupt",
96
96
  });
97
- expect(approvalOf(last())).toBeUndefined();
97
+ expect(approvalOf(last())).toBeDefined();
98
98
  expect(
99
99
  (last().metadata?.custom as Record<string, any> | undefined)?.agui
100
100
  ?.interrupts,
@@ -319,8 +319,14 @@ export class RunAggregator {
319
319
  }
320
320
  case "RUN_CANCELLED": {
321
321
  // A cancel can land after RUN_FINISHED while the response is still
322
- // closing, and the answer it finished is already persisted as complete.
323
- if (this.status?.type === "complete") break;
322
+ // closing: the finished answer stays complete, and an interrupt the run
323
+ // already delivered stays answerable.
324
+ if (
325
+ this.status?.type === "complete" ||
326
+ (this.status?.type === "requires-action" &&
327
+ this.status.reason === "interrupt")
328
+ )
329
+ break;
324
330
  this.status = { type: "incomplete", reason: "cancelled" };
325
331
  this.closeOpenSubagentRuns(this.status);
326
332
  this.emit();
@@ -6,11 +6,12 @@ import type {
6
6
  ExternalStoreThreadListAdapter,
7
7
  FeedbackAdapter,
8
8
  RealtimeVoiceAdapter,
9
+ RunConfig,
9
10
  SpeechSynthesisAdapter,
10
11
  ThreadHistoryAdapter,
11
12
  ThreadMessage,
12
13
  } from "@assistant-ui/core";
13
- import type { AbstractAgent } from "@ag-ui/client";
14
+ import type { AbstractAgent, Context, Message } from "@ag-ui/client";
14
15
  import type { Logger } from "./logger";
15
16
  import type { ReadonlyJSONValue } from "assistant-stream/utils";
16
17
  import type { McpToolCallResult } from "./mcp-tool-result";
@@ -22,6 +23,7 @@ export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
22
23
  export type AgUiOpaqueReasoning = {
23
24
  id: string;
24
25
  encryptedValue: string;
26
+ metadata?: Record<string, unknown>;
25
27
  after?: boolean;
26
28
  };
27
29
 
@@ -80,6 +82,21 @@ export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
80
82
  agent: AbstractAgent;
81
83
  logger?: Partial<Logger>;
82
84
  showThinking?: boolean;
85
+ /**
86
+ * Return context entries for each run. `messages` is the exact AG-UI
87
+ * transcript sent in the run input, after transcript selection and message
88
+ * conversion. On resume, it follows `resumeTranscript`: "full" sends the
89
+ * whole thread, while "appended" sends messages after the interrupted
90
+ * assistant message, falling back to the whole thread if that message is
91
+ * missing. `runConfig` is the run config passed while building that input.
92
+ * If the getter throws, the error is reported and the run continues without host entries.
93
+ */
94
+ getRunContext?:
95
+ | ((input: {
96
+ messages: readonly Message[];
97
+ runConfig: RunConfig | undefined;
98
+ }) => readonly Context[])
99
+ | undefined;
83
100
  /**
84
101
  * What `messages` carries on a resume run, meaning a `RunAgentInput` that
85
102
  * also carries `resume`. The AG-UI interrupt spec leaves this undefined: its