@assistant-ui/react-ag-ui 0.0.54 → 0.0.57
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/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +25 -15
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +257 -210
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +13 -3
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +77 -39
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +23 -2
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +155 -41
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +3 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +9 -0
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +56 -0
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
- package/dist/runtime/adapter/tool-approval.js +244 -0
- package/dist/runtime/adapter/tool-approval.js.map +1 -0
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +2 -1
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/interrupt-internals.d.ts +8 -0
- package/dist/runtime/interrupt-internals.d.ts.map +1 -0
- package/dist/runtime/interrupt-internals.js +25 -0
- package/dist/runtime/interrupt-internals.js.map +1 -0
- package/dist/useAgUiRuntime.js +14 -9
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +9 -9
- package/src/index.ts +8 -2
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +436 -272
- package/src/runtime/adapter/conversions.content.test.ts +61 -3
- package/src/runtime/adapter/conversions.ts +123 -57
- package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
- package/src/runtime/adapter/run-aggregator.ts +234 -47
- package/src/runtime/adapter/subscriber.ts +14 -0
- package/src/runtime/adapter/tool-approval.test.ts +681 -0
- package/src/runtime/adapter/tool-approval.ts +366 -0
- package/src/runtime/event-parser.ts +10 -1
- package/src/runtime/interrupt-internals.ts +35 -0
- package/src/useAgUiRuntime.approval.test.tsx +885 -0
- package/src/useAgUiRuntime.history.test.tsx +76 -0
- package/src/useAgUiRuntime.ts +15 -9
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { ThreadAssistantMessagePart } from "@assistant-ui/core";
|
|
3
|
+
import { fromAgUiMessages } from "./conversions";
|
|
4
|
+
import {
|
|
5
|
+
buildToolApprovalResume,
|
|
6
|
+
projectAgUiToolApprovals,
|
|
7
|
+
withSettledToolApprovals,
|
|
8
|
+
withToolApprovalDecision,
|
|
9
|
+
} from "./tool-approval";
|
|
10
|
+
import type { AgUiInterrupt, AgUiResumeEntry } from "../types";
|
|
11
|
+
import { withRawResponseSchema } from "../interrupt-internals";
|
|
12
|
+
|
|
13
|
+
const gate = (id: string, toolCallId: string): AgUiInterrupt => ({
|
|
14
|
+
id,
|
|
15
|
+
reason: "tool_call",
|
|
16
|
+
toolCallId,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const toolCall = (
|
|
20
|
+
toolCallId: string,
|
|
21
|
+
approval?: Record<string, unknown>,
|
|
22
|
+
): ThreadAssistantMessagePart =>
|
|
23
|
+
({
|
|
24
|
+
type: "tool-call",
|
|
25
|
+
toolCallId,
|
|
26
|
+
toolName: "delete_file",
|
|
27
|
+
args: {},
|
|
28
|
+
argsText: "{}",
|
|
29
|
+
...(approval ? { approval } : {}),
|
|
30
|
+
}) as ThreadAssistantMessagePart;
|
|
31
|
+
|
|
32
|
+
describe("projectAgUiToolApprovals", () => {
|
|
33
|
+
it("gates the named tool call for a tool_call interrupt", () => {
|
|
34
|
+
expect([...projectAgUiToolApprovals([gate("int-1", "tc-1")])]).toEqual([
|
|
35
|
+
["tc-1", { id: "int-1" }],
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("gates a batch whose every tool call is rendered", () => {
|
|
40
|
+
expect([
|
|
41
|
+
...projectAgUiToolApprovals(
|
|
42
|
+
[gate("int-1", "tc-1"), gate("int-2", "tc-2")],
|
|
43
|
+
new Set(["tc-1", "tc-2"]),
|
|
44
|
+
),
|
|
45
|
+
]).toEqual([
|
|
46
|
+
["tc-1", { id: "int-1" }],
|
|
47
|
+
["tc-2", { id: "int-2" }],
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves the whole batch bespoke when two gates name one tool call", () => {
|
|
52
|
+
// One tool call renders one approval, so the second gate could never be
|
|
53
|
+
// decided and `buildToolApprovalResume` would wait on it forever.
|
|
54
|
+
const interrupts = [gate("int-1", "tc-1"), gate("int-2", "tc-1")];
|
|
55
|
+
expect(projectAgUiToolApprovals(interrupts, new Set(["tc-1"])).size).toBe(
|
|
56
|
+
0,
|
|
57
|
+
);
|
|
58
|
+
expect(projectAgUiToolApprovals(interrupts).size).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("leaves the whole batch bespoke when a gate names an unrendered tool call", () => {
|
|
62
|
+
// The unrendered gate could never be clicked, so the batch could never be
|
|
63
|
+
// completed: its rendered sibling would take a decision that no resume
|
|
64
|
+
// could carry.
|
|
65
|
+
expect([
|
|
66
|
+
...projectAgUiToolApprovals(
|
|
67
|
+
[gate("int-1", "tc-1"), gate("int-2", "tc-missing")],
|
|
68
|
+
new Set(["tc-1"]),
|
|
69
|
+
),
|
|
70
|
+
]).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves free-standing confirmation and input_required to the bespoke hooks", () => {
|
|
74
|
+
const interrupts: AgUiInterrupt[] = [
|
|
75
|
+
{ id: "int-1", reason: "confirmation" },
|
|
76
|
+
{ id: "int-2", reason: "input_required", toolCallId: "tc-2" },
|
|
77
|
+
{ id: "int-3", reason: "tool_call" },
|
|
78
|
+
];
|
|
79
|
+
for (const interrupt of interrupts) {
|
|
80
|
+
expect(projectAgUiToolApprovals([interrupt]).size).toBe(0);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("projects nothing for a batch that mixes a gate with another interrupt", () => {
|
|
85
|
+
expect(
|
|
86
|
+
projectAgUiToolApprovals([
|
|
87
|
+
gate("int-1", "tc-1"),
|
|
88
|
+
{ id: "int-2", reason: "input_required" },
|
|
89
|
+
]).size,
|
|
90
|
+
).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves a gate to the bespoke hooks unless the schema is known to accept a decision", () => {
|
|
94
|
+
const unanswerable: Record<string, unknown>[] = [
|
|
95
|
+
{ type: "object", required: ["approved", "auditCode"] },
|
|
96
|
+
{ required: ["auditCode"] },
|
|
97
|
+
{ required: "approved" },
|
|
98
|
+
{ type: "string" },
|
|
99
|
+
{ type: ["string", "number"] },
|
|
100
|
+
// Keywords the seam does not interpret, each of which can reject a
|
|
101
|
+
// payload the seam emits.
|
|
102
|
+
{ type: "object", allOf: [{ required: ["auditCode"] }] },
|
|
103
|
+
{
|
|
104
|
+
type: "object",
|
|
105
|
+
$ref: "#/$defs/audited",
|
|
106
|
+
$defs: { audited: { required: ["auditCode"] } },
|
|
107
|
+
},
|
|
108
|
+
{ type: "object", additionalProperties: { type: "number" } },
|
|
109
|
+
{
|
|
110
|
+
type: "object",
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
properties: { approved: { type: "boolean" } },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
type: "object",
|
|
116
|
+
additionalProperties: false,
|
|
117
|
+
properties: { decision: { type: "boolean" } },
|
|
118
|
+
},
|
|
119
|
+
{ type: "object", properties: { approved: { type: "string" } } },
|
|
120
|
+
{ type: "object", properties: { reason: { type: "number" } } },
|
|
121
|
+
// A declared field the seam sends may not be narrowed past its type.
|
|
122
|
+
{ type: "object", properties: { approved: { const: true } } },
|
|
123
|
+
{ type: "object", properties: { approved: { enum: [true, false] } } },
|
|
124
|
+
{ type: "object", properties: { reason: { minLength: 1 } } },
|
|
125
|
+
// Shapes a validator rejects as invalid: `properties` and a field schema
|
|
126
|
+
// must each be the object a schema is read from.
|
|
127
|
+
{ type: "object", properties: 7 },
|
|
128
|
+
{ type: "object", properties: [] },
|
|
129
|
+
{ type: "object", properties: { approved: 7 } },
|
|
130
|
+
{ type: "object", properties: { editedArgs: 7 } },
|
|
131
|
+
{ type: "object", properties: { approved: { title: 42 } } },
|
|
132
|
+
// A field the seam never sends still has to compile: a malformed schema
|
|
133
|
+
// under it fails the run whatever payload follows.
|
|
134
|
+
{ type: "object", properties: { editedArgs: { type: 7 } } },
|
|
135
|
+
{ type: "object", properties: { editedArgs: { type: "bogus" } } },
|
|
136
|
+
{ type: "object", properties: { editedArgs: { required: [7] } } },
|
|
137
|
+
{ type: "object", properties: { editedArgs: { items: 7 } } },
|
|
138
|
+
// The shapes the top-level schema is held to hold one level down too.
|
|
139
|
+
{ type: "object", properties: { editedArgs: { type: [] } } },
|
|
140
|
+
{ type: "object", properties: { editedArgs: { type: ["object", 7] } } },
|
|
141
|
+
{
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: { editedArgs: { type: ["object", "object"] } },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: "object",
|
|
147
|
+
properties: { editedArgs: { required: ["path", "path"] } },
|
|
148
|
+
},
|
|
149
|
+
{ type: "object", properties: { editedArgs: { title: 42 } } },
|
|
150
|
+
{ type: "object", properties: { editedArgs: { description: [] } } },
|
|
151
|
+
{ type: "object", properties: { editedArgs: { $schema: 7 } } },
|
|
152
|
+
{ type: "object", properties: { editedArgs: { $id: {} } } },
|
|
153
|
+
{
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: { editedArgs: { type: "object", properties: 7 } },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
editedArgs: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: { path: { type: "nonsense" } },
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
for (const responseSchema of unanswerable) {
|
|
168
|
+
expect(
|
|
169
|
+
projectAgUiToolApprovals([{ ...gate("int-1", "tc-1"), responseSchema }])
|
|
170
|
+
.size,
|
|
171
|
+
).toBe(0);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("leaves a gate whose schema is not a plain object to the bespoke hooks, without throwing", () => {
|
|
176
|
+
// `false` is a JSON Schema that rejects every payload, `true` and the rest
|
|
177
|
+
// are not schemas this seam interprets; none may be boxed into an object or
|
|
178
|
+
// crash the projection.
|
|
179
|
+
const notObjects: unknown[] = [false, true, null, 0, 7, "object", []];
|
|
180
|
+
for (const schema of notObjects) {
|
|
181
|
+
// Live, a non-object schema arrives on the internal carrier.
|
|
182
|
+
expect(
|
|
183
|
+
projectAgUiToolApprovals([
|
|
184
|
+
withRawResponseSchema(gate("int-1", "tc-1"), schema),
|
|
185
|
+
]).size,
|
|
186
|
+
).toBe(0);
|
|
187
|
+
// A restored interrupt is untyped, so it can also sit on `responseSchema`.
|
|
188
|
+
expect(
|
|
189
|
+
projectAgUiToolApprovals([
|
|
190
|
+
{ ...gate("int-1", "tc-1"), responseSchema: schema } as AgUiInterrupt,
|
|
191
|
+
]).size,
|
|
192
|
+
).toBe(0);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("leaves a gate whose allowed keyword carries a malformed value to the bespoke hooks", () => {
|
|
197
|
+
const malformed: Record<string, unknown>[] = [
|
|
198
|
+
{ type: ["object", 7] },
|
|
199
|
+
{ type: ["object", null] },
|
|
200
|
+
{ type: 7 },
|
|
201
|
+
{ required: ["approved", null, 7] },
|
|
202
|
+
{ required: 7 },
|
|
203
|
+
// Shapes a JSON Schema validator rejects as an invalid schema: an
|
|
204
|
+
// annotation that is not a string, an illegal type name, a duplicated
|
|
205
|
+
// type entry, and a duplicated `required` entry.
|
|
206
|
+
{ title: 42 },
|
|
207
|
+
{ description: [] },
|
|
208
|
+
{ $schema: 7 },
|
|
209
|
+
{ $id: {} },
|
|
210
|
+
{ type: ["object", "bogus"] },
|
|
211
|
+
{ type: ["object", "object"] },
|
|
212
|
+
{ required: ["approved", "approved"] },
|
|
213
|
+
];
|
|
214
|
+
for (const responseSchema of malformed) {
|
|
215
|
+
expect(
|
|
216
|
+
projectAgUiToolApprovals([{ ...gate("int-1", "tc-1"), responseSchema }])
|
|
217
|
+
.size,
|
|
218
|
+
).toBe(0);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("gates a schema built only from keywords that accept every payload the seam emits", () => {
|
|
223
|
+
const answerable: Record<string, unknown>[] = [
|
|
224
|
+
{ type: "object" },
|
|
225
|
+
{ type: ["object"] },
|
|
226
|
+
{ type: ["object", "null"] },
|
|
227
|
+
{ type: "object", required: ["approved"] },
|
|
228
|
+
{ type: "object", required: [] },
|
|
229
|
+
{ required: ["approved"] },
|
|
230
|
+
{ title: "Decision", description: "approve or deny", type: "object" },
|
|
231
|
+
// AG-UI's own minimal tool-approval schema, and its approve-with-edits
|
|
232
|
+
// shape, whose `editedArgs` this seam simply never sends.
|
|
233
|
+
{
|
|
234
|
+
type: "object",
|
|
235
|
+
properties: { approved: { type: "boolean" } },
|
|
236
|
+
required: ["approved"],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
approved: { type: "boolean" },
|
|
242
|
+
editedArgs: {
|
|
243
|
+
type: "object",
|
|
244
|
+
description: "Full replacement of the tool args. Not merged.",
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
required: ["approved"],
|
|
248
|
+
},
|
|
249
|
+
{ type: "object", properties: {} },
|
|
250
|
+
{ type: "object", properties: { approved: {} } },
|
|
251
|
+
{
|
|
252
|
+
type: "object",
|
|
253
|
+
properties: { approved: { type: ["boolean", "null"] } },
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
approved: { type: "boolean", description: "the decision" },
|
|
259
|
+
reason: { type: "string" },
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
{ type: "object", properties: { editedArgs: false } },
|
|
263
|
+
// A well-formed schema for a field the seam never sends stays answerable,
|
|
264
|
+
// however it constrains that field and however deeply it nests.
|
|
265
|
+
{ type: "object", properties: { editedArgs: { type: "string" } } },
|
|
266
|
+
{
|
|
267
|
+
type: "object",
|
|
268
|
+
properties: {
|
|
269
|
+
editedArgs: {
|
|
270
|
+
type: "object",
|
|
271
|
+
required: ["path"],
|
|
272
|
+
properties: { path: { type: ["string", "null"], minLength: 1 } },
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
editedArgs: { type: "array", items: { type: "object" } },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
for (const responseSchema of answerable) {
|
|
284
|
+
expect([
|
|
285
|
+
...projectAgUiToolApprovals([
|
|
286
|
+
{ ...gate("int-1", "tc-1"), responseSchema },
|
|
287
|
+
]),
|
|
288
|
+
]).toEqual([["tc-1", { id: "int-1" }]]);
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("leaves a gate whose schema is keyed by a prototype member to the bespoke hooks", () => {
|
|
293
|
+
// Parsed from the wire so each key is an own property, as a server can send
|
|
294
|
+
// it. Read off an object literal these would resolve to `Object.prototype`
|
|
295
|
+
// members and either crash the projection or gate a schema nothing checked.
|
|
296
|
+
for (const source of [
|
|
297
|
+
'{"__proto__":{}}',
|
|
298
|
+
'{"constructor":{}}',
|
|
299
|
+
'{"toString":{}}',
|
|
300
|
+
'{"hasOwnProperty":{}}',
|
|
301
|
+
'{"valueOf":"object"}',
|
|
302
|
+
'{"type":"object","toString":{}}',
|
|
303
|
+
]) {
|
|
304
|
+
const responseSchema = JSON.parse(source) as Record<string, unknown>;
|
|
305
|
+
expect(
|
|
306
|
+
projectAgUiToolApprovals([{ ...gate("int-1", "tc-1"), responseSchema }])
|
|
307
|
+
.size,
|
|
308
|
+
).toBe(0);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("buildToolApprovalResume", () => {
|
|
314
|
+
const interrupts = [gate("int-1", "tc-1"), gate("int-2", "tc-2")];
|
|
315
|
+
|
|
316
|
+
it("returns null until every gate in the batch is decided", () => {
|
|
317
|
+
const content = [
|
|
318
|
+
toolCall("tc-1", { id: "int-1", approved: true }),
|
|
319
|
+
toolCall("tc-2", { id: "int-2" }),
|
|
320
|
+
];
|
|
321
|
+
expect(buildToolApprovalResume(content, interrupts)).toBeNull();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("maps approve and deny onto resolved payloads, keeping the denial reason", () => {
|
|
325
|
+
const content = [
|
|
326
|
+
toolCall("tc-1", { id: "int-1", approved: true }),
|
|
327
|
+
toolCall("tc-2", { id: "int-2", approved: false, reason: "too risky" }),
|
|
328
|
+
];
|
|
329
|
+
expect(buildToolApprovalResume(content, interrupts)).toEqual([
|
|
330
|
+
{ interruptId: "int-1", status: "resolved", payload: { approved: true } },
|
|
331
|
+
{
|
|
332
|
+
interruptId: "int-2",
|
|
333
|
+
status: "resolved",
|
|
334
|
+
payload: { approved: false, reason: "too risky" },
|
|
335
|
+
},
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("sends only the decision, never an option the wire cannot validate", () => {
|
|
340
|
+
const content = [
|
|
341
|
+
toolCall("tc-1", {
|
|
342
|
+
id: "int-1",
|
|
343
|
+
approved: true,
|
|
344
|
+
optionId: "allow-always",
|
|
345
|
+
}),
|
|
346
|
+
toolCall("tc-2", { id: "int-2", approved: false }),
|
|
347
|
+
];
|
|
348
|
+
expect(buildToolApprovalResume(content, interrupts)).toEqual([
|
|
349
|
+
{ interruptId: "int-1", status: "resolved", payload: { approved: true } },
|
|
350
|
+
{
|
|
351
|
+
interruptId: "int-2",
|
|
352
|
+
status: "resolved",
|
|
353
|
+
payload: { approved: false },
|
|
354
|
+
},
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("answers and withholds a batch keyed by prototype-named interrupt ids", () => {
|
|
359
|
+
// An interrupt id is a server-chosen string. Accumulated on an object
|
|
360
|
+
// literal, `__proto__` would rewrite the prototype instead of recording a
|
|
361
|
+
// response, and `constructor` or `toString` would read as answered while
|
|
362
|
+
// still undecided.
|
|
363
|
+
for (const id of ["__proto__", "constructor", "toString", "valueOf"]) {
|
|
364
|
+
const batch = [gate(id, "tc-1"), gate("int-2", "tc-2")];
|
|
365
|
+
expect(
|
|
366
|
+
buildToolApprovalResume(
|
|
367
|
+
[toolCall("tc-1", { id, approved: true }), toolCall("tc-2", {})],
|
|
368
|
+
batch,
|
|
369
|
+
),
|
|
370
|
+
).toBeNull();
|
|
371
|
+
expect(
|
|
372
|
+
buildToolApprovalResume(
|
|
373
|
+
[
|
|
374
|
+
toolCall("tc-1", { id, approved: true }),
|
|
375
|
+
toolCall("tc-2", { id: "int-2", approved: false }),
|
|
376
|
+
],
|
|
377
|
+
batch,
|
|
378
|
+
),
|
|
379
|
+
).toEqual([
|
|
380
|
+
{ interruptId: id, status: "resolved", payload: { approved: true } },
|
|
381
|
+
{
|
|
382
|
+
interruptId: "int-2",
|
|
383
|
+
status: "resolved",
|
|
384
|
+
payload: { approved: false },
|
|
385
|
+
},
|
|
386
|
+
]);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("withholds the resume while a prototype-named gate is undecided", () => {
|
|
391
|
+
for (const id of ["__proto__", "constructor", "toString", "valueOf"]) {
|
|
392
|
+
expect(
|
|
393
|
+
buildToolApprovalResume([toolCall("tc-1", { id })], [gate(id, "tc-1")]),
|
|
394
|
+
).toBeNull();
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
describe("withToolApprovalDecision", () => {
|
|
400
|
+
it("records the decision on the gate it addresses and leaves the rest alone", () => {
|
|
401
|
+
const content = [
|
|
402
|
+
toolCall("tc-1", { id: "int-1" }),
|
|
403
|
+
toolCall("tc-2", { id: "int-2" }),
|
|
404
|
+
];
|
|
405
|
+
const next = withToolApprovalDecision(content, {
|
|
406
|
+
approvalId: "int-1",
|
|
407
|
+
approved: false,
|
|
408
|
+
reason: "no",
|
|
409
|
+
});
|
|
410
|
+
expect((next[0] as any).approval).toEqual({
|
|
411
|
+
id: "int-1",
|
|
412
|
+
approved: false,
|
|
413
|
+
reason: "no",
|
|
414
|
+
});
|
|
415
|
+
expect(next[1]).toBe(content[1]);
|
|
416
|
+
expect(
|
|
417
|
+
withToolApprovalDecision(next, { approvalId: "int-1", approved: true }),
|
|
418
|
+
).toBe(next);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("records the decision alone, since the projected approval carries no options", () => {
|
|
422
|
+
const next = withToolApprovalDecision([toolCall("tc-1", { id: "int-1" })], {
|
|
423
|
+
approvalId: "int-1",
|
|
424
|
+
approved: true,
|
|
425
|
+
optionId: "allow-always",
|
|
426
|
+
});
|
|
427
|
+
expect((next[0] as any).approval).toEqual({
|
|
428
|
+
id: "int-1",
|
|
429
|
+
approved: true,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
describe("withSettledToolApprovals", () => {
|
|
435
|
+
it("cancels only the gate whose own entry is cancelled", () => {
|
|
436
|
+
const content = [
|
|
437
|
+
toolCall("tc-1", { id: "int-1" }),
|
|
438
|
+
toolCall("tc-2", { id: "int-2" }),
|
|
439
|
+
];
|
|
440
|
+
const next = withSettledToolApprovals(content, [
|
|
441
|
+
{ interruptId: "int-1", status: "resolved", payload: { approved: true } },
|
|
442
|
+
{ interruptId: "int-2", status: "cancelled" },
|
|
443
|
+
]);
|
|
444
|
+
expect((next[0] as any).approval).toEqual({ id: "int-1", approved: true });
|
|
445
|
+
expect((next[1] as any).approval).toEqual({
|
|
446
|
+
id: "int-2",
|
|
447
|
+
resolution: "cancelled",
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("overrides a locally recorded decision the submitted entry contradicts", () => {
|
|
452
|
+
const content = [
|
|
453
|
+
toolCall("tc-1", { id: "int-1", approved: true }),
|
|
454
|
+
toolCall("tc-2", { id: "int-2" }),
|
|
455
|
+
];
|
|
456
|
+
const next = withSettledToolApprovals(content, [
|
|
457
|
+
{ interruptId: "int-1", status: "cancelled" },
|
|
458
|
+
{ interruptId: "int-2", status: "cancelled" },
|
|
459
|
+
]);
|
|
460
|
+
expect((next[0] as any).approval).toEqual({
|
|
461
|
+
id: "int-1",
|
|
462
|
+
resolution: "cancelled",
|
|
463
|
+
});
|
|
464
|
+
expect((next[1] as any).approval).toEqual({
|
|
465
|
+
id: "int-2",
|
|
466
|
+
resolution: "cancelled",
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("settles repeatedly in either order, always showing the latest entry", () => {
|
|
471
|
+
const cancelled = [
|
|
472
|
+
{ interruptId: "int-1", status: "cancelled" },
|
|
473
|
+
] as const satisfies readonly AgUiResumeEntry[];
|
|
474
|
+
const resolved = [
|
|
475
|
+
{
|
|
476
|
+
interruptId: "int-1",
|
|
477
|
+
status: "resolved",
|
|
478
|
+
payload: { approved: true },
|
|
479
|
+
},
|
|
480
|
+
] as const satisfies readonly AgUiResumeEntry[];
|
|
481
|
+
const pending = [toolCall("tc-1", { id: "int-1" })];
|
|
482
|
+
|
|
483
|
+
const cancelledThenResolved = withSettledToolApprovals(
|
|
484
|
+
withSettledToolApprovals(pending, cancelled),
|
|
485
|
+
resolved,
|
|
486
|
+
);
|
|
487
|
+
const resolvedThenCancelled = withSettledToolApprovals(
|
|
488
|
+
withSettledToolApprovals(pending, resolved),
|
|
489
|
+
cancelled,
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
expect((cancelledThenResolved[0] as any).approval).toEqual({
|
|
493
|
+
id: "int-1",
|
|
494
|
+
approved: true,
|
|
495
|
+
});
|
|
496
|
+
expect((resolvedThenCancelled[0] as any).approval).toEqual({
|
|
497
|
+
id: "int-1",
|
|
498
|
+
resolution: "cancelled",
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("adopts the decision a resolved entry carries", () => {
|
|
503
|
+
const next = withSettledToolApprovals(
|
|
504
|
+
[toolCall("tc-1", { id: "int-1" })],
|
|
505
|
+
[
|
|
506
|
+
{
|
|
507
|
+
interruptId: "int-1",
|
|
508
|
+
status: "resolved",
|
|
509
|
+
payload: { approved: false, reason: "denied elsewhere" },
|
|
510
|
+
},
|
|
511
|
+
],
|
|
512
|
+
);
|
|
513
|
+
expect((next[0] as any).approval).toEqual({
|
|
514
|
+
id: "int-1",
|
|
515
|
+
approved: false,
|
|
516
|
+
reason: "denied elsewhere",
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("drops local decision metadata the resolved payload does not carry", () => {
|
|
521
|
+
const next = withSettledToolApprovals(
|
|
522
|
+
[
|
|
523
|
+
toolCall("tc-1", {
|
|
524
|
+
id: "int-1",
|
|
525
|
+
approved: false,
|
|
526
|
+
reason: "local denial",
|
|
527
|
+
}),
|
|
528
|
+
],
|
|
529
|
+
[
|
|
530
|
+
{
|
|
531
|
+
interruptId: "int-1",
|
|
532
|
+
status: "resolved",
|
|
533
|
+
payload: { approved: true },
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
);
|
|
537
|
+
expect((next[0] as any).approval).toEqual({ id: "int-1", approved: true });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("ignores an option a resolved entry carries, which the approval has no options to name", () => {
|
|
541
|
+
const next = withSettledToolApprovals(
|
|
542
|
+
[toolCall("tc-1", { id: "int-1" })],
|
|
543
|
+
[
|
|
544
|
+
{
|
|
545
|
+
interruptId: "int-1",
|
|
546
|
+
status: "resolved",
|
|
547
|
+
payload: { approved: true, optionId: "allow-always" },
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
);
|
|
551
|
+
expect((next[0] as any).approval).toEqual({
|
|
552
|
+
id: "int-1",
|
|
553
|
+
approved: true,
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("closes an undecided gate whose resolved entry carries no decision", () => {
|
|
558
|
+
const next = withSettledToolApprovals(
|
|
559
|
+
[toolCall("tc-1", { id: "int-1" })],
|
|
560
|
+
[{ interruptId: "int-1", status: "resolved", payload: { answer: 42 } }],
|
|
561
|
+
);
|
|
562
|
+
// No decision is fabricated, but the interrupt is closed, so leaving the
|
|
563
|
+
// gate actionable would render a button with nothing left to answer.
|
|
564
|
+
expect((next[0] as any).approval).toEqual({
|
|
565
|
+
id: "int-1",
|
|
566
|
+
resolution: "cancelled",
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("clears a local decision the resolved entry that was sent did not carry", () => {
|
|
571
|
+
const next = withSettledToolApprovals(
|
|
572
|
+
[toolCall("tc-1", { id: "int-1", approved: true, reason: "sure" })],
|
|
573
|
+
[{ interruptId: "int-1", status: "resolved", payload: { answer: 42 } }],
|
|
574
|
+
);
|
|
575
|
+
expect((next[0] as any).approval).toEqual({
|
|
576
|
+
id: "int-1",
|
|
577
|
+
resolution: "cancelled",
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe("restored snapshots", () => {
|
|
583
|
+
it("exposes the approval seam on a tool call restored from history", () => {
|
|
584
|
+
const [message] = fromAgUiMessages([
|
|
585
|
+
{
|
|
586
|
+
id: "msg-1",
|
|
587
|
+
role: "assistant",
|
|
588
|
+
content: "",
|
|
589
|
+
toolCalls: [
|
|
590
|
+
{
|
|
591
|
+
id: "tc-1",
|
|
592
|
+
type: "function",
|
|
593
|
+
function: { name: "delete_file", arguments: '{"path":"/tmp/a"}' },
|
|
594
|
+
},
|
|
595
|
+
],
|
|
596
|
+
metadata: {
|
|
597
|
+
custom: { agui: { interrupts: [gate("int-1", "tc-1")] } },
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
]);
|
|
601
|
+
|
|
602
|
+
const part = (message!.content as any[]).find(
|
|
603
|
+
(p) => p.type === "tool-call",
|
|
604
|
+
);
|
|
605
|
+
expect(part.approval).toEqual({ id: "int-1" });
|
|
606
|
+
expect(message!.status).toMatchObject({
|
|
607
|
+
type: "requires-action",
|
|
608
|
+
reason: "interrupt",
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("leaves a restored gate whose schema is not a plain object to the bespoke hooks", () => {
|
|
613
|
+
// Persisted metadata is untyped, so the projection sees whatever was
|
|
614
|
+
// stored; `null` must not reach `Object.keys`, and `false` must not be
|
|
615
|
+
// boxed into an object that passes the allowlist.
|
|
616
|
+
for (const responseSchema of [false, null, 7, "object"]) {
|
|
617
|
+
const [message] = fromAgUiMessages([
|
|
618
|
+
{
|
|
619
|
+
id: "msg-1",
|
|
620
|
+
role: "assistant",
|
|
621
|
+
content: "",
|
|
622
|
+
toolCalls: [
|
|
623
|
+
{
|
|
624
|
+
id: "tc-1",
|
|
625
|
+
type: "function",
|
|
626
|
+
function: { name: "delete_file", arguments: "{}" },
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
metadata: {
|
|
630
|
+
custom: {
|
|
631
|
+
agui: {
|
|
632
|
+
interrupts: [{ ...gate("int-1", "tc-1"), responseSchema }],
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
},
|
|
637
|
+
]);
|
|
638
|
+
|
|
639
|
+
const part = (message!.content as any[]).find(
|
|
640
|
+
(p) => p.type === "tool-call",
|
|
641
|
+
);
|
|
642
|
+
expect(part.approval).toBeUndefined();
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("leaves a restored gate whose object schema is malformed to the bespoke hooks", () => {
|
|
647
|
+
for (const responseSchema of [
|
|
648
|
+
{ title: 42 },
|
|
649
|
+
{ description: [] },
|
|
650
|
+
{ type: ["object", "bogus"] },
|
|
651
|
+
{ required: ["approved", "approved"] },
|
|
652
|
+
]) {
|
|
653
|
+
const [message] = fromAgUiMessages([
|
|
654
|
+
{
|
|
655
|
+
id: "msg-1",
|
|
656
|
+
role: "assistant",
|
|
657
|
+
content: "",
|
|
658
|
+
toolCalls: [
|
|
659
|
+
{
|
|
660
|
+
id: "tc-1",
|
|
661
|
+
type: "function",
|
|
662
|
+
function: { name: "delete_file", arguments: "{}" },
|
|
663
|
+
},
|
|
664
|
+
],
|
|
665
|
+
metadata: {
|
|
666
|
+
custom: {
|
|
667
|
+
agui: {
|
|
668
|
+
interrupts: [{ ...gate("int-1", "tc-1"), responseSchema }],
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
},
|
|
673
|
+
]);
|
|
674
|
+
|
|
675
|
+
const part = (message!.content as any[]).find(
|
|
676
|
+
(p) => p.type === "tool-call",
|
|
677
|
+
);
|
|
678
|
+
expect(part.approval).toBeUndefined();
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
});
|