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

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 (42) hide show
  1. package/README.md +16 -0
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -17
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.js +133 -234
  5. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  7. package/dist/runtime/adapter/conversions.js +54 -25
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts +31 -4
  10. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.js +419 -123
  12. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  13. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  14. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  15. package/dist/runtime/adapter/subscriber.js +4 -1
  16. package/dist/runtime/adapter/subscriber.js.map +1 -1
  17. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.js +11 -12
  20. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  21. package/dist/runtime/event-parser.d.ts.map +1 -1
  22. package/dist/runtime/event-parser.js +176 -45
  23. package/dist/runtime/event-parser.js.map +1 -1
  24. package/dist/runtime/types.d.ts +42 -1
  25. package/dist/runtime/types.d.ts.map +1 -1
  26. package/dist/useAgUiRuntime.js +12 -9
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +10 -10
  29. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  30. package/src/runtime/AgUiThreadRuntimeCore.ts +190 -319
  31. package/src/runtime/adapter/conversions.ts +128 -36
  32. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  33. package/src/runtime/adapter/run-aggregator.ts +670 -148
  34. package/src/runtime/adapter/subscriber.ts +10 -1
  35. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  36. package/src/runtime/adapter/tool-approval.ts +17 -12
  37. package/src/runtime/event-parser.ts +216 -36
  38. package/src/runtime/types.ts +70 -12
  39. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  40. package/src/useAgUiRuntime.history.test.tsx +76 -0
  41. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  42. package/src/useAgUiRuntime.ts +12 -9
@@ -1,16 +1,18 @@
1
1
  "use client";
2
2
 
3
3
  import type { InputContent, RunAgentParameters } from "@ag-ui/client";
4
+ import { generateId } from "@assistant-ui/core";
4
5
  import type {
5
6
  ThreadMessageLike as CoreThreadMessageLike,
6
7
  PartProviderMetadata,
8
+ ThreadMessage,
7
9
  ToolCallMessagePartMcpMetadata,
8
10
  ToolModelContentPart,
9
11
  } from "@assistant-ui/core";
10
12
  import {
11
13
  getAutoStatus,
12
- httpUrlPattern,
13
14
  parseDataUrl,
15
+ resolveFilePartSource,
14
16
  } from "@assistant-ui/core/internal";
15
17
  import { type Tool, toToolsJSONSchema } from "assistant-stream";
16
18
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
@@ -105,8 +107,17 @@ type ToolCallPart = {
105
107
  modelContent?: readonly ToolModelContentPart[];
106
108
  unstable_toolMessageId?: string;
107
109
  mcp?: ToolCallMessagePartMcpMetadata;
110
+ messages?: readonly ThreadMessage[];
111
+ approval?: CoreToolCallPartApproval;
108
112
  };
109
113
 
114
+ type CoreToolCallPartApproval = NonNullable<
115
+ Extract<
116
+ Exclude<CoreThreadMessageLike["content"], string>[number],
117
+ { type: "tool-call" }
118
+ >["approval"]
119
+ >;
120
+
110
121
  const isObject = (value: unknown): value is Record<string, unknown> =>
111
122
  typeof value === "object" && value !== null;
112
123
 
@@ -177,13 +188,6 @@ function parseJSONText(value: string): unknown {
177
188
  }
178
189
  }
179
190
 
180
- function generateId(): string {
181
- return (
182
- (globalThis.crypto as { randomUUID?: () => string })?.randomUUID?.() ??
183
- Math.random().toString(36).slice(2)
184
- );
185
- }
186
-
187
191
  function normalizeToolCall(part: ToolCallPart): {
188
192
  id: string;
189
193
  call: AgUiToolCall;
@@ -242,17 +246,20 @@ function buildInputSource(
242
246
  declaredMimeType: string | undefined,
243
247
  sourceType?: string,
244
248
  ): InputContentSource {
245
- if (sourceType === "url" || httpUrlPattern.test(value)) {
249
+ const source = resolveFilePartSource({
250
+ data: value,
251
+ mimeType: declaredMimeType ?? "application/octet-stream",
252
+ sourceType,
253
+ });
254
+ if (source.kind === "url") {
246
255
  return declaredMimeType !== undefined
247
- ? { type: "url", value, mimeType: declaredMimeType }
248
- : { type: "url", value };
256
+ ? { type: "url", value: source.url, mimeType: declaredMimeType }
257
+ : { type: "url", value: source.url };
249
258
  }
250
- const parsed = parseDataUrl(value);
251
259
  return {
252
260
  type: "data",
253
- value: parsed?.data ?? value,
254
- mimeType:
255
- parsed?.mimeType ?? declaredMimeType ?? "application/octet-stream",
261
+ value: source.data,
262
+ mimeType: source.mimeType,
256
263
  };
257
264
  }
258
265
 
@@ -662,7 +669,7 @@ function toAssistantSnapshotMessage(
662
669
  }
663
670
 
664
671
  function toUserOrSystemSnapshotMessage(
665
- role: "user" | "system",
672
+ role: "user" | "system" | "developer",
666
673
  rawMessage: Record<string, unknown>,
667
674
  ): CoreThreadMessageLike {
668
675
  const messageName = getString(rawMessage, "name");
@@ -670,10 +677,23 @@ function toUserOrSystemSnapshotMessage(
670
677
  role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
671
678
  return {
672
679
  id: getString(rawMessage, "id") ?? generateId(),
673
- role,
680
+ // The internal message model has no developer role; it rides as a system
681
+ // message with its wire role kept in metadata so the export restores it.
682
+ role: role === "developer" ? "system" : role,
674
683
  content: extractText(rawMessage.content),
675
684
  ...(messageName !== undefined ? { name: messageName } : {}),
676
685
  ...(attachments.length > 0 ? { attachments } : {}),
686
+ ...(role === "developer"
687
+ ? {
688
+ metadata: {
689
+ custom: {
690
+ [AG_UI_METADATA_NAMESPACE]: {
691
+ role: "developer",
692
+ } satisfies AgUiCustomMetadata,
693
+ },
694
+ },
695
+ }
696
+ : {}),
677
697
  };
678
698
  }
679
699
 
@@ -939,7 +959,7 @@ export function fromAgUiMessages(
939
959
  continue;
940
960
  }
941
961
 
942
- if (role === "user" || role === "system") {
962
+ if (role === "user" || role === "system" || role === "developer") {
943
963
  converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
944
964
  }
945
965
  }
@@ -1041,36 +1061,96 @@ function convertAssistantMessage(
1041
1061
  return;
1042
1062
  }
1043
1063
 
1064
+ // A subagent's tool calls live on nested assistant messages. Before
1065
+ // subagent attribution they flattened to root and went out with this
1066
+ // assistant record as their antecedent, so the resume payload restores
1067
+ // exactly that shape: the calls join this record's toolCalls and their
1068
+ // results follow as tool records — never a tool record without its call.
1069
+ // The nested assistant content itself is backend-owned state and is not
1070
+ // re-sent.
1071
+ const nestedToolCalls: {
1072
+ id: string;
1073
+ call: AgUiToolCall;
1074
+ part: ToolCallPart;
1075
+ }[] = [];
1076
+ for (const { part } of toolCalls) {
1077
+ collectNestedToolCalls(part, nestedToolCalls);
1078
+ }
1079
+
1044
1080
  converted.push({
1045
1081
  id: message.id,
1046
1082
  role: "assistant",
1047
1083
  content,
1048
1084
  ...(message.name ? { name: message.name } : {}),
1049
- ...(toolCalls.length > 0
1050
- ? { toolCalls: toolCalls.map((entry) => entry.call) }
1085
+ ...(toolCalls.length + nestedToolCalls.length > 0
1086
+ ? {
1087
+ toolCalls: [...toolCalls, ...nestedToolCalls].map(
1088
+ (entry) => entry.call,
1089
+ ),
1090
+ }
1051
1091
  : {}),
1052
1092
  });
1053
1093
 
1054
1094
  for (const { id: toolCallId, part } of toolCalls) {
1055
- if (part.result === undefined) continue;
1056
-
1057
- const resultContent =
1058
- part.modelContent !== undefined
1059
- ? extractText(part.modelContent)
1060
- : typeof part.result === "string"
1061
- ? part.result
1062
- : JSON.stringify(part.result);
1095
+ emitToolResult(toolCallId, part, converted);
1096
+ }
1097
+ for (const { id: toolCallId, part } of nestedToolCalls) {
1098
+ // A result recorded while the call's approval gate is still open must not
1099
+ // reach the backend as if the gate had been decided.
1100
+ const gateOpen =
1101
+ part.approval != null &&
1102
+ part.approval.approved === undefined &&
1103
+ part.approval.resolution === undefined;
1104
+ if (gateOpen) continue;
1105
+ emitToolResult(toolCallId, part, converted);
1106
+ }
1107
+ }
1063
1108
 
1064
- converted.push({
1065
- id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1066
- role: "tool",
1067
- content: resultContent,
1068
- toolCallId,
1069
- ...(part.isError ? { error: resultContent } : {}),
1070
- });
1109
+ function collectNestedToolCalls(
1110
+ part: ToolCallPart,
1111
+ out: { id: string; call: AgUiToolCall; part: ToolCallPart }[],
1112
+ ): void {
1113
+ for (const nested of part.messages ?? []) {
1114
+ if (!isObject(nested) || nested.role !== "assistant") continue;
1115
+ const nestedContent = Array.isArray(nested.content) ? nested.content : [];
1116
+ for (const nestedPart of nestedContent) {
1117
+ if (!isObject(nestedPart) || nestedPart.type !== "tool-call") continue;
1118
+ const nestedToolCall = nestedPart as ToolCallPart;
1119
+ if (
1120
+ typeof nestedToolCall.toolCallId !== "string" ||
1121
+ nestedToolCall.toolCallId.startsWith("a2ui:")
1122
+ ) {
1123
+ continue;
1124
+ }
1125
+ out.push({ ...normalizeToolCall(nestedToolCall), part: nestedToolCall });
1126
+ collectNestedToolCalls(nestedToolCall, out);
1127
+ }
1071
1128
  }
1072
1129
  }
1073
1130
 
1131
+ function emitToolResult(
1132
+ toolCallId: string,
1133
+ part: ToolCallPart,
1134
+ converted: AgUiMessage[],
1135
+ ): void {
1136
+ if (part.result === undefined) return;
1137
+
1138
+ const resultContent =
1139
+ part.modelContent !== undefined
1140
+ ? extractText(part.modelContent)
1141
+ : typeof part.result === "string"
1142
+ ? part.result
1143
+ : JSON.stringify(part.result);
1144
+
1145
+ converted.push({
1146
+ id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1147
+ role: "tool",
1148
+ content: resultContent,
1149
+ toolCallId,
1150
+ ...(part.isError ? { error: resultContent } : {}),
1151
+ });
1152
+ }
1153
+
1074
1154
  function convertToolMessage(
1075
1155
  message: NormalizedThreadMessageLike,
1076
1156
  converted: AgUiMessage[],
@@ -1137,9 +1217,21 @@ export function toAgUiMessages(
1137
1217
  }
1138
1218
 
1139
1219
  if (message.role === "system" || message.role === "developer") {
1220
+ const custom = isObject(message.metadata)
1221
+ ? message.metadata.custom
1222
+ : undefined;
1223
+ const namespaced = isObject(custom)
1224
+ ? custom[AG_UI_METADATA_NAMESPACE]
1225
+ : undefined;
1226
+ const wireRole =
1227
+ message.role === "system" &&
1228
+ isObject(namespaced) &&
1229
+ namespaced.role === "developer"
1230
+ ? ("developer" as const)
1231
+ : message.role;
1140
1232
  converted.push({
1141
1233
  id: message.id,
1142
- role: message.role,
1234
+ role: wireRole,
1143
1235
  content: extractText(message.content),
1144
1236
  ...(message.name ? { name: message.name } : {}),
1145
1237
  });
@@ -100,4 +100,132 @@ describe("RunAggregator tool approval projection", () => {
100
100
  ?.interrupts,
101
101
  ).toEqual([GATE]);
102
102
  });
103
+
104
+ /**
105
+ * `boundToolCallIds` names every call the run produced, root or nested, so a
106
+ * gate on a call inside a subagent message is bound and projects onto the
107
+ * nested part rather than collapsing the batch. The approval seams walk
108
+ * `ToolCallMessagePart.messages`, which is what makes a subagent-scoped gate
109
+ * answerable the same way a root one is.
110
+ */
111
+ it("projects a batch that names a subagent-scoped tool call onto both the root and nested parts", () => {
112
+ const emitted: ChatModelRunResult[] = [];
113
+ const aggregator = new RunAggregator({
114
+ showThinking: false,
115
+ logger: noopLogger,
116
+ emit: (result) => {
117
+ emitted.push(result);
118
+ },
119
+ });
120
+
121
+ const ROOT_GATE: AgUiInterrupt = {
122
+ id: "int-root",
123
+ reason: "tool_call",
124
+ toolCallId: "tc-root",
125
+ message: "Delete /tmp/root?",
126
+ };
127
+ const SUBAGENT_GATE: AgUiInterrupt = {
128
+ id: "int-sub",
129
+ reason: "tool_call",
130
+ toolCallId: "tc-sub",
131
+ message: "Delete /tmp/sub?",
132
+ };
133
+
134
+ aggregator.handle({ type: "RUN_STARTED", runId: "run-1" });
135
+ aggregator.handle({
136
+ type: "TOOL_CALL_START",
137
+ toolCallId: "tc-root",
138
+ toolCallName: "delete_file",
139
+ });
140
+ aggregator.handle({
141
+ type: "TOOL_CALL_ARGS",
142
+ toolCallId: "tc-root",
143
+ delta: '{"path":"/tmp/root"}',
144
+ });
145
+ aggregator.handle({ type: "TOOL_CALL_END", toolCallId: "tc-root" });
146
+ aggregator.handle({
147
+ type: "SUBAGENT_STARTED",
148
+ subagentRunId: "sub-1",
149
+ name: "cleanup",
150
+ parentToolCallId: "tc-root",
151
+ } as any);
152
+ aggregator.handle({
153
+ type: "TOOL_CALL_START",
154
+ toolCallId: "tc-sub",
155
+ toolCallName: "delete_file",
156
+ subagentRunId: "sub-1",
157
+ } as any);
158
+ aggregator.handle({
159
+ type: "TOOL_CALL_ARGS",
160
+ toolCallId: "tc-sub",
161
+ delta: '{"path":"/tmp/sub"}',
162
+ subagentRunId: "sub-1",
163
+ } as any);
164
+ aggregator.handle({
165
+ type: "TOOL_CALL_END",
166
+ toolCallId: "tc-sub",
167
+ subagentRunId: "sub-1",
168
+ } as any);
169
+ aggregator.handle({
170
+ type: "RUN_FINISHED",
171
+ runId: "run-1",
172
+ outcome: {
173
+ type: "interrupt",
174
+ interrupts: [ROOT_GATE, SUBAGENT_GATE],
175
+ },
176
+ } as any);
177
+
178
+ const result = emitted[emitted.length - 1]!;
179
+ expect(result.status).toMatchObject({
180
+ type: "requires-action",
181
+ reason: "interrupt",
182
+ });
183
+ const content = result.content ?? [];
184
+ const rootPart = content.find(
185
+ (p: any) => p.type === "tool-call" && p.toolCallId === "tc-root",
186
+ ) as any;
187
+ expect(rootPart?.approval).toEqual({
188
+ id: "int-root",
189
+ prompt: "Delete /tmp/root?",
190
+ });
191
+ const nestedPart = rootPart?.messages?.[0]?.content.find(
192
+ (p: any) => p.type === "tool-call" && p.toolCallId === "tc-sub",
193
+ );
194
+ expect(nestedPart?.approval).toEqual({
195
+ id: "int-sub",
196
+ prompt: "Delete /tmp/sub?",
197
+ });
198
+ // The interrupts survive on the message metadata so the app's bespoke
199
+ // interrupt hooks (useAgUiSubmitInterruptResponses / steerAway) can still
200
+ // resolve the batch.
201
+ expect(
202
+ (result.metadata?.custom as Record<string, any> | undefined)?.agui
203
+ ?.interrupts,
204
+ ).toEqual([ROOT_GATE, SUBAGENT_GATE]);
205
+ });
206
+
207
+ it("still projects a root gate whose batch is entirely root-scoped, even while an unrelated subagent tool call is in flight", () => {
208
+ const { aggregator, last } = gatedAggregator();
209
+
210
+ aggregator.handle({
211
+ type: "SUBAGENT_STARTED",
212
+ subagentRunId: "sub-1",
213
+ name: "cleanup",
214
+ } as any);
215
+ aggregator.handle({
216
+ type: "TOOL_CALL_START",
217
+ toolCallId: "tc-unrelated",
218
+ toolCallName: "noop",
219
+ subagentRunId: "sub-1",
220
+ } as any);
221
+
222
+ expect(last().status).toMatchObject({
223
+ type: "requires-action",
224
+ reason: "interrupt",
225
+ });
226
+ expect(approvalOf(last())).toEqual({
227
+ id: "int-1",
228
+ prompt: "Delete /tmp/a?",
229
+ });
230
+ });
103
231
  });