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

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 (46) hide show
  1. package/README.md +16 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -8
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +118 -86
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +34 -11
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +385 -119
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +4 -1
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  20. package/dist/runtime/adapter/tool-approval.js +11 -12
  21. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  22. package/dist/runtime/event-parser.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.js +176 -45
  24. package/dist/runtime/event-parser.js.map +1 -1
  25. package/dist/runtime/types.d.ts +67 -1
  26. package/dist/runtime/types.d.ts.map +1 -1
  27. package/dist/useAgUiRuntime.d.ts.map +1 -1
  28. package/dist/useAgUiRuntime.js +15 -5
  29. package/dist/useAgUiRuntime.js.map +1 -1
  30. package/package.json +9 -9
  31. package/src/index.ts +1 -0
  32. package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
  33. package/src/runtime/AgUiThreadRuntimeCore.ts +204 -120
  34. package/src/runtime/adapter/conversions.ts +87 -17
  35. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  36. package/src/runtime/adapter/run-aggregator.ts +595 -139
  37. package/src/runtime/adapter/subscriber.ts +10 -1
  38. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  39. package/src/runtime/adapter/tool-approval.ts +17 -12
  40. package/src/runtime/event-parser.ts +216 -36
  41. package/src/runtime/types.ts +96 -12
  42. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  43. package/src/useAgUiRuntime.queue.test.tsx +54 -0
  44. package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
  45. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  46. package/src/useAgUiRuntime.ts +15 -3
@@ -5,6 +5,7 @@ import { generateId } from "@assistant-ui/core";
5
5
  import type {
6
6
  ThreadMessageLike as CoreThreadMessageLike,
7
7
  PartProviderMetadata,
8
+ ThreadMessage,
8
9
  ToolCallMessagePartMcpMetadata,
9
10
  ToolModelContentPart,
10
11
  } from "@assistant-ui/core";
@@ -106,8 +107,17 @@ type ToolCallPart = {
106
107
  modelContent?: readonly ToolModelContentPart[];
107
108
  unstable_toolMessageId?: string;
108
109
  mcp?: ToolCallMessagePartMcpMetadata;
110
+ messages?: readonly ThreadMessage[];
111
+ approval?: CoreToolCallPartApproval;
109
112
  };
110
113
 
114
+ type CoreToolCallPartApproval = NonNullable<
115
+ Extract<
116
+ Exclude<CoreThreadMessageLike["content"], string>[number],
117
+ { type: "tool-call" }
118
+ >["approval"]
119
+ >;
120
+
111
121
  const isObject = (value: unknown): value is Record<string, unknown> =>
112
122
  typeof value === "object" && value !== null;
113
123
 
@@ -1051,36 +1061,96 @@ function convertAssistantMessage(
1051
1061
  return;
1052
1062
  }
1053
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
+
1054
1080
  converted.push({
1055
1081
  id: message.id,
1056
1082
  role: "assistant",
1057
1083
  content,
1058
1084
  ...(message.name ? { name: message.name } : {}),
1059
- ...(toolCalls.length > 0
1060
- ? { toolCalls: toolCalls.map((entry) => entry.call) }
1085
+ ...(toolCalls.length + nestedToolCalls.length > 0
1086
+ ? {
1087
+ toolCalls: [...toolCalls, ...nestedToolCalls].map(
1088
+ (entry) => entry.call,
1089
+ ),
1090
+ }
1061
1091
  : {}),
1062
1092
  });
1063
1093
 
1064
1094
  for (const { id: toolCallId, part } of toolCalls) {
1065
- if (part.result === undefined) continue;
1066
-
1067
- const resultContent =
1068
- part.modelContent !== undefined
1069
- ? extractText(part.modelContent)
1070
- : typeof part.result === "string"
1071
- ? part.result
1072
- : 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
+ }
1073
1108
 
1074
- converted.push({
1075
- id: part.unstable_toolMessageId ?? `${toolCallId}:tool`,
1076
- role: "tool",
1077
- content: resultContent,
1078
- toolCallId,
1079
- ...(part.isError ? { error: resultContent } : {}),
1080
- });
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
+ }
1081
1128
  }
1082
1129
  }
1083
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
+
1084
1154
  function convertToolMessage(
1085
1155
  message: NormalizedThreadMessageLike,
1086
1156
  converted: AgUiMessage[],
@@ -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
  });