@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.
- package/README.md +16 -0
- package/dist/index.d.ts +2 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -8
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +118 -86
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +34 -11
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +385 -119
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +9 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +4 -1
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +3 -1
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.js +11 -12
- package/dist/runtime/adapter/tool-approval.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +176 -45
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +67 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +15 -5
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +204 -120
- package/src/runtime/adapter/conversions.ts +87 -17
- package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
- package/src/runtime/adapter/run-aggregator.ts +595 -139
- package/src/runtime/adapter/subscriber.ts +10 -1
- package/src/runtime/adapter/tool-approval.test.ts +75 -0
- package/src/runtime/adapter/tool-approval.ts +17 -12
- package/src/runtime/event-parser.ts +216 -36
- package/src/runtime/types.ts +96 -12
- package/src/useAgUiRuntime.approval.test.tsx +34 -24
- package/src/useAgUiRuntime.queue.test.tsx +54 -0
- package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
- package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
- 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
|
-
? {
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
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
|
});
|