@assistant-ui/react-ag-ui 0.0.54 → 0.0.56
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 +23 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +169 -35
- 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 +58 -26
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +5 -1
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +26 -1
- 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 +3 -0
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +8 -2
- package/src/runtime/AgUiThreadRuntimeCore.ts +314 -41
- package/src/runtime/adapter/conversions.content.test.ts +61 -3
- package/src/runtime/adapter/conversions.ts +83 -39
- package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
- package/src/runtime/adapter/run-aggregator.ts +37 -1
- 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.ts +4 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { buildResumeArray } from "@ag-ui/client";
|
|
2
|
+
import type {
|
|
3
|
+
RespondToToolApprovalOptions,
|
|
4
|
+
ThreadAssistantMessagePart,
|
|
5
|
+
ToolCallMessagePart,
|
|
6
|
+
} from "@assistant-ui/core";
|
|
7
|
+
import type { AgUiInterrupt, AgUiResumeEntry } from "../types";
|
|
8
|
+
import {
|
|
9
|
+
readRawResponseSchema,
|
|
10
|
+
withoutRawResponseSchema,
|
|
11
|
+
} from "../interrupt-internals";
|
|
12
|
+
|
|
13
|
+
type ToolApproval = NonNullable<ToolCallMessagePart["approval"]>;
|
|
14
|
+
|
|
15
|
+
const EMPTY_APPROVALS: ReadonlyMap<string, ToolApproval> = new Map();
|
|
16
|
+
|
|
17
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
18
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
|
|
20
|
+
const isString = (value: unknown) => typeof value === "string";
|
|
21
|
+
|
|
22
|
+
const JSON_SCHEMA_TYPES = new Set([
|
|
23
|
+
"null",
|
|
24
|
+
"boolean",
|
|
25
|
+
"object",
|
|
26
|
+
"array",
|
|
27
|
+
"number",
|
|
28
|
+
"integer",
|
|
29
|
+
"string",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const isUniqueStrings = (value: unknown) =>
|
|
33
|
+
Array.isArray(value) &&
|
|
34
|
+
value.every(isString) &&
|
|
35
|
+
new Set(value).size === value.length;
|
|
36
|
+
|
|
37
|
+
const isSchemaType = (type: unknown) =>
|
|
38
|
+
(isString(type) && JSON_SCHEMA_TYPES.has(type as string)) ||
|
|
39
|
+
(Array.isArray(type) &&
|
|
40
|
+
type.length > 0 &&
|
|
41
|
+
isUniqueStrings(type) &&
|
|
42
|
+
type.every((entry) => JSON_SCHEMA_TYPES.has(entry as string)));
|
|
43
|
+
|
|
44
|
+
const acceptsType = (type: unknown, primitive: string) =>
|
|
45
|
+
type === primitive ||
|
|
46
|
+
(Array.isArray(type) && isSchemaType(type) && type.includes(primitive));
|
|
47
|
+
|
|
48
|
+
const acceptsObject = (type: unknown) => acceptsType(type, "object");
|
|
49
|
+
|
|
50
|
+
const SUB_SCHEMA_ANNOTATIONS = new Set([
|
|
51
|
+
"$schema",
|
|
52
|
+
"$id",
|
|
53
|
+
"title",
|
|
54
|
+
"description",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A field schema accepts a value of `primitive` only where nothing in it can
|
|
59
|
+
* narrow past the type: an annotation carries no constraint, and an absent
|
|
60
|
+
* `type` constrains nothing. Any other keyword — `const`, `enum`, `anyOf`,
|
|
61
|
+
* `format` — can reject the value this seam sends, so it is not interpreted.
|
|
62
|
+
*/
|
|
63
|
+
const acceptsOnly = (schema: unknown, primitive: string) =>
|
|
64
|
+
isPlainObject(schema) &&
|
|
65
|
+
Object.entries(schema).every(([keyword, value]) =>
|
|
66
|
+
keyword === "type"
|
|
67
|
+
? acceptsType(value, primitive)
|
|
68
|
+
: SUB_SCHEMA_ANNOTATIONS.has(keyword) && isString(value),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Keywords of a sub-schema whose value shape a validator reads, each paired
|
|
73
|
+
* with that shape: the same shapes the top-level schema is held to, plus
|
|
74
|
+
* `properties` and `items`, which carry schemas, so a malformed one nested
|
|
75
|
+
* under them is as uncompilable as a malformed one at the top. A keyword this
|
|
76
|
+
* seam cannot judge is left alone: unlike the top-level schema, a field the
|
|
77
|
+
* seam never sends needs only to be compilable, not permissive.
|
|
78
|
+
*/
|
|
79
|
+
const SUB_SCHEMA_SHAPES = new Map<string, (value: unknown) => boolean>([
|
|
80
|
+
["type", isSchemaType],
|
|
81
|
+
["required", isUniqueStrings],
|
|
82
|
+
...[...SUB_SCHEMA_ANNOTATIONS].map(
|
|
83
|
+
(annotation) =>
|
|
84
|
+
[annotation, isString] as [string, (value: unknown) => boolean],
|
|
85
|
+
),
|
|
86
|
+
[
|
|
87
|
+
"properties",
|
|
88
|
+
(value) =>
|
|
89
|
+
isPlainObject(value) &&
|
|
90
|
+
Object.values(value).every((schema) => isWellFormedSubSchema(schema)),
|
|
91
|
+
],
|
|
92
|
+
["items", (value) => isWellFormedSubSchema(value)],
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A schema the server cannot compile fails the run whatever payload follows, so
|
|
97
|
+
* a field this seam never sends is still checked for the shape a validator
|
|
98
|
+
* reads it as. A boolean is a schema in its own right.
|
|
99
|
+
*/
|
|
100
|
+
const isWellFormedSubSchema = (schema: unknown): boolean =>
|
|
101
|
+
typeof schema === "boolean" ||
|
|
102
|
+
(isPlainObject(schema) &&
|
|
103
|
+
Object.entries(schema).every(([keyword, value]) => {
|
|
104
|
+
const holds = SUB_SCHEMA_SHAPES.get(keyword);
|
|
105
|
+
return holds === undefined || holds(value);
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* AG-UI's own tool-approval schema declares the fields it expects under
|
|
110
|
+
* `properties`, so a declaration is read where it cannot reject what the seam
|
|
111
|
+
* sends: `approved` must accept a boolean and `reason` a string. A field the
|
|
112
|
+
* seam never sends is unconstrained by `required` (which admits only
|
|
113
|
+
* `approved`), so only its well-formedness as a sub-schema is checked — the
|
|
114
|
+
* `editedArgs` of the spec's approve-with-edits shape is such a field.
|
|
115
|
+
*/
|
|
116
|
+
const acceptsSeamProperties = (properties: unknown) =>
|
|
117
|
+
isPlainObject(properties) &&
|
|
118
|
+
Object.entries(properties).every(([field, schema]) => {
|
|
119
|
+
if (field === "approved") return acceptsOnly(schema, "boolean");
|
|
120
|
+
if (field === "reason") return acceptsOnly(schema, "string");
|
|
121
|
+
return isWellFormedSubSchema(schema);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const acceptsSeamPayload = (required: unknown) =>
|
|
125
|
+
Array.isArray(required) &&
|
|
126
|
+
required.every((field) => field === "approved") &&
|
|
127
|
+
new Set(required).size === required.length;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Keywords that cannot reject a payload this seam emits, each paired with the
|
|
131
|
+
* check that its value is the well-formed shape the keyword is read as. A
|
|
132
|
+
* keyword whose value a validator would reject as an invalid schema is not
|
|
133
|
+
* decided here, because a server that compiles the schema fails the run. An
|
|
134
|
+
* unlisted keyword — `additionalProperties`, `allOf`, `$ref` — makes the gate
|
|
135
|
+
* bespoke rather than being interpreted. The map is keyed by
|
|
136
|
+
* `Map` rather than by object property, because a schema key of `constructor`
|
|
137
|
+
* or `toString` would otherwise read an `Object.prototype` member as its
|
|
138
|
+
* validator and `__proto__` would read the prototype itself.
|
|
139
|
+
*/
|
|
140
|
+
const SEAM_SAFE_KEYWORDS = new Map<string, (value: unknown) => boolean>([
|
|
141
|
+
["$schema", isString],
|
|
142
|
+
["$id", isString],
|
|
143
|
+
["title", isString],
|
|
144
|
+
["description", isString],
|
|
145
|
+
["type", acceptsObject],
|
|
146
|
+
["required", acceptsSeamPayload],
|
|
147
|
+
["properties", acceptsSeamProperties],
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The seam sends `{ approved }` and may add `reason`, so it may answer a
|
|
152
|
+
* `responseSchema` only where acceptance of those exact payloads is established
|
|
153
|
+
* by construction. AG-UI answers a resume payload its schema rejects with
|
|
154
|
+
* `RunError`, and the host can always build the exact payload through the
|
|
155
|
+
* bespoke interrupt hooks, so an undecidable schema is left to them. Only an
|
|
156
|
+
* absent schema and a plain object of well-formed allowed keywords establish
|
|
157
|
+
* that: a boolean schema (`false` rejects every payload), a malformed one, and
|
|
158
|
+
* a keyword whose value is not the shape it interprets are all left bespoke.
|
|
159
|
+
*/
|
|
160
|
+
const isSeamAnswerable = (schema: unknown) => {
|
|
161
|
+
if (schema === undefined) return true;
|
|
162
|
+
if (!isPlainObject(schema)) return false;
|
|
163
|
+
return Object.entries(schema).every(([keyword, value]) => {
|
|
164
|
+
const accepts = SEAM_SAFE_KEYWORDS.get(keyword);
|
|
165
|
+
return accepts !== undefined && accepts(value);
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const isGate = (
|
|
170
|
+
interrupt: AgUiInterrupt,
|
|
171
|
+
): interrupt is AgUiInterrupt & { toolCallId: string } =>
|
|
172
|
+
interrupt.reason === "tool_call" &&
|
|
173
|
+
!!interrupt.id &&
|
|
174
|
+
!!interrupt.toolCallId &&
|
|
175
|
+
// A restored interrupt is untyped, so `responseSchema` may hold any persisted
|
|
176
|
+
// shape; a non-object schema received live arrives on the internal carrier.
|
|
177
|
+
readRawResponseSchema(interrupt) === undefined &&
|
|
178
|
+
isSeamAnswerable(interrupt.responseSchema);
|
|
179
|
+
|
|
180
|
+
const isPending = (approval: ToolCallMessagePart["approval"]) =>
|
|
181
|
+
!!approval && approval.approved === undefined && !approval.resolution;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* AG-UI binds a decision to a tool call with `reason: "tool_call"`;
|
|
185
|
+
* `confirmation` is a free-standing yes/no and `input_required` wants structured
|
|
186
|
+
* input, so both stay on the bespoke interrupt hooks, as does a `tool_call`
|
|
187
|
+
* whose `responseSchema` asks for more than this seam can send. A batch mixing a
|
|
188
|
+
* gate with any other interrupt projects nothing: AG-UI resumes with one
|
|
189
|
+
* response per open interrupt, so a half-owned batch cannot be completed from
|
|
190
|
+
* either side alone.
|
|
191
|
+
*
|
|
192
|
+
* The same rule covers binding. A gate naming a tool call the message does not
|
|
193
|
+
* render can never be decided through this seam, so the batch it belongs to
|
|
194
|
+
* could never be completed either: the decisions taken on its visible siblings
|
|
195
|
+
* would sit on the message while the run waited for a response that no click
|
|
196
|
+
* can produce. `boundToolCallIds` leaves such a batch bespoke instead.
|
|
197
|
+
*/
|
|
198
|
+
export const projectAgUiToolApprovals = (
|
|
199
|
+
interrupts: readonly AgUiInterrupt[] | undefined,
|
|
200
|
+
boundToolCallIds?: ReadonlySet<string>,
|
|
201
|
+
): ReadonlyMap<string, ToolApproval> => {
|
|
202
|
+
if (!interrupts?.length || !interrupts.every(isGate)) return EMPTY_APPROVALS;
|
|
203
|
+
if (
|
|
204
|
+
boundToolCallIds &&
|
|
205
|
+
!interrupts.every((interrupt) => boundToolCallIds.has(interrupt.toolCallId))
|
|
206
|
+
) {
|
|
207
|
+
return EMPTY_APPROVALS;
|
|
208
|
+
}
|
|
209
|
+
// Two gates naming one tool call would collapse into a single approval, so
|
|
210
|
+
// the batch could never be completed: the surviving gate takes one decision
|
|
211
|
+
// while the resume array still waits on the id that no part carries.
|
|
212
|
+
const toolCallIds = interrupts.map((interrupt) => interrupt.toolCallId);
|
|
213
|
+
if (new Set(toolCallIds).size !== toolCallIds.length) return EMPTY_APPROVALS;
|
|
214
|
+
return new Map(
|
|
215
|
+
interrupts.map((interrupt) => [interrupt.toolCallId, { id: interrupt.id }]),
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Records a decision on the gated tool call. The decision is held on the part
|
|
221
|
+
* until every gate in the batch is answered, because AG-UI resumes a run with
|
|
222
|
+
* one response per open interrupt rather than one response per gate. A chosen
|
|
223
|
+
* `optionId` is not recorded: the projection exposes an approval without
|
|
224
|
+
* options, and core rejects an `optionId` absent from them, so no option can
|
|
225
|
+
* reach this seam.
|
|
226
|
+
*/
|
|
227
|
+
export const withToolApprovalDecision = (
|
|
228
|
+
content: readonly ThreadAssistantMessagePart[],
|
|
229
|
+
{ approvalId, approved, reason }: RespondToToolApprovalOptions,
|
|
230
|
+
): readonly ThreadAssistantMessagePart[] => {
|
|
231
|
+
let changed = false;
|
|
232
|
+
const next = content.map((part) => {
|
|
233
|
+
if (part.type !== "tool-call") return part;
|
|
234
|
+
const approval = part.approval;
|
|
235
|
+
if (approval?.id !== approvalId || !isPending(approval)) return part;
|
|
236
|
+
changed = true;
|
|
237
|
+
return {
|
|
238
|
+
...part,
|
|
239
|
+
approval: {
|
|
240
|
+
...approval,
|
|
241
|
+
approved,
|
|
242
|
+
...(reason !== undefined && { reason }),
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
return changed ? next : content;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Maps the recorded decisions onto AG-UI resume entries, or `null` while the
|
|
251
|
+
* batch is incomplete. A denial is a resolved response carrying
|
|
252
|
+
* `approved: false`, not a cancellation: `cancelled` means the user abandoned
|
|
253
|
+
* the interrupt without meaningful input and carries no payload, which would
|
|
254
|
+
* drop the denial reason on the wire.
|
|
255
|
+
*/
|
|
256
|
+
export const buildToolApprovalResume = (
|
|
257
|
+
content: readonly ThreadAssistantMessagePart[],
|
|
258
|
+
interrupts: readonly AgUiInterrupt[],
|
|
259
|
+
): AgUiResumeEntry[] | null => {
|
|
260
|
+
const open = new Set(interrupts.map((interrupt) => interrupt.id));
|
|
261
|
+
// Decisions accumulate in a `Map` rather than in an object literal, because an
|
|
262
|
+
// interrupt id of `__proto__` would otherwise write to the prototype chain
|
|
263
|
+
// instead of recording a response, and `constructor` or `toString` would
|
|
264
|
+
// report themselves as answered while undecided.
|
|
265
|
+
const responses = new Map<string, { status: "resolved"; payload: unknown }>();
|
|
266
|
+
for (const part of content) {
|
|
267
|
+
if (part.type !== "tool-call") continue;
|
|
268
|
+
const approval = part.approval;
|
|
269
|
+
if (!approval || approval.approved === undefined) continue;
|
|
270
|
+
if (!open.has(approval.id)) continue;
|
|
271
|
+
responses.set(approval.id, {
|
|
272
|
+
status: "resolved",
|
|
273
|
+
payload: {
|
|
274
|
+
approved: approval.approved,
|
|
275
|
+
...(approval.reason !== undefined && { reason: approval.reason }),
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (interrupts.some((interrupt) => !responses.has(interrupt.id))) return null;
|
|
280
|
+
// The schema is dropped rather than cast: AG-UI's own interrupt type cannot
|
|
281
|
+
// express a boolean schema, and `buildResumeArray` reads only the ids.
|
|
282
|
+
return buildResumeArray(
|
|
283
|
+
interrupts.map((interrupt) => {
|
|
284
|
+
const { responseSchema: _schema, ...rest } =
|
|
285
|
+
withoutRawResponseSchema(interrupt);
|
|
286
|
+
return rest;
|
|
287
|
+
}),
|
|
288
|
+
Object.fromEntries(responses),
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const settle = (
|
|
293
|
+
approval: ToolApproval,
|
|
294
|
+
entry: AgUiResumeEntry,
|
|
295
|
+
): ToolApproval | undefined => {
|
|
296
|
+
// A decision this gate recorded locally but never sent is dropped rather than
|
|
297
|
+
// displayed beside the settlement it contradicts: a submitted entry carries
|
|
298
|
+
// the whole outcome, so no field of an earlier one may survive it.
|
|
299
|
+
const {
|
|
300
|
+
approved: _approved,
|
|
301
|
+
reason: _reason,
|
|
302
|
+
resolution: _resolution,
|
|
303
|
+
...rest
|
|
304
|
+
} = approval;
|
|
305
|
+
if (entry.status === "cancelled") {
|
|
306
|
+
return { ...rest, resolution: "cancelled" };
|
|
307
|
+
}
|
|
308
|
+
const payload = entry.payload;
|
|
309
|
+
if (
|
|
310
|
+
typeof payload !== "object" ||
|
|
311
|
+
payload === null ||
|
|
312
|
+
typeof (payload as { approved?: unknown }).approved !== "boolean"
|
|
313
|
+
) {
|
|
314
|
+
// The entry settles the gate with no decision. Nothing is fabricated, and a
|
|
315
|
+
// decision recorded locally is dropped: the payload that went out carried
|
|
316
|
+
// none, so displaying one would show what was never sent. The gate is still
|
|
317
|
+
// marked terminal, because the interrupt it belonged to is closed and a
|
|
318
|
+
// click on it afterwards would find nothing pending.
|
|
319
|
+
return { ...rest, resolution: "cancelled" };
|
|
320
|
+
}
|
|
321
|
+
const { approved, reason } = payload as {
|
|
322
|
+
approved: boolean;
|
|
323
|
+
reason?: unknown;
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
...rest,
|
|
327
|
+
approved,
|
|
328
|
+
...(typeof reason === "string" && { reason }),
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Settles every gate the submitted resume array names, so the displayed outcome
|
|
334
|
+
* cannot contradict the wire. A decision recorded locally is not exempt: a gate
|
|
335
|
+
* approved while the batch waited on its sibling is still cancelled when
|
|
336
|
+
* `steerAway` submits a cancellation for it. A gate is shown as cancelled only
|
|
337
|
+
* where its own entry is cancelled; a resolved entry supplies the decision it
|
|
338
|
+
* carries, and one that carries none settles the gate with no decision rather
|
|
339
|
+
* than fabricating one or keeping an unsent local decision beside it.
|
|
340
|
+
*
|
|
341
|
+
* A settlement always overrides an earlier one, in either direction. A resolved
|
|
342
|
+
* settlement leaves no state a locally recorded decision does not also leave —
|
|
343
|
+
* core's `resolution` covers only `cancelled` and `expired` — so honoring the
|
|
344
|
+
* first settlement instead would mean treating an unsent local decision as
|
|
345
|
+
* terminal, which is exactly what the cancellation case must override.
|
|
346
|
+
*/
|
|
347
|
+
export const withSettledToolApprovals = (
|
|
348
|
+
content: readonly ThreadAssistantMessagePart[],
|
|
349
|
+
resume: readonly AgUiResumeEntry[],
|
|
350
|
+
): readonly ThreadAssistantMessagePart[] => {
|
|
351
|
+
if (resume.length === 0) return content;
|
|
352
|
+
const byId = new Map(resume.map((entry) => [entry.interruptId, entry]));
|
|
353
|
+
let changed = false;
|
|
354
|
+
const next = content.map((part) => {
|
|
355
|
+
if (part.type !== "tool-call") return part;
|
|
356
|
+
const approval = part.approval;
|
|
357
|
+
if (!approval) return part;
|
|
358
|
+
const entry = byId.get(approval.id);
|
|
359
|
+
if (!entry) return part;
|
|
360
|
+
const settled = settle(approval, entry);
|
|
361
|
+
if (!settled) return part;
|
|
362
|
+
changed = true;
|
|
363
|
+
return { ...part, approval: settled };
|
|
364
|
+
});
|
|
365
|
+
return changed ? next : content;
|
|
366
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgUiEvent, AgUiInterrupt, AgUiRunFinishedOutcome } from "./types";
|
|
2
2
|
import type { Logger } from "./logger";
|
|
3
3
|
import { parseMcpToolCallResult } from "./mcp-tool-result";
|
|
4
|
+
import { withRawResponseSchema } from "./interrupt-internals";
|
|
4
5
|
|
|
5
6
|
export type ParseAgUiEventOptions = {
|
|
6
7
|
logger?: Logger;
|
|
@@ -37,7 +38,15 @@ const parseInterrupt = (raw: unknown): AgUiInterrupt | null => {
|
|
|
37
38
|
if (isPlainObject(raw.responseSchema))
|
|
38
39
|
interrupt.responseSchema = raw.responseSchema;
|
|
39
40
|
if (isPlainObject(raw.metadata)) interrupt.metadata = raw.metadata;
|
|
40
|
-
|
|
41
|
+
// A present schema is kept whatever its shape: `false` is a JSON Schema that
|
|
42
|
+
// rejects every payload, so normalizing it to absent would claim a gate no
|
|
43
|
+
// decision can answer. Only an object schema fits `responseSchema`; every
|
|
44
|
+
// other shape travels on the internal carrier. `null` is not a JSON Schema at
|
|
45
|
+
// all, and it is what a server serializing an unset optional field sends, so
|
|
46
|
+
// it reads as absent rather than as a schema nothing can satisfy.
|
|
47
|
+
return raw.responseSchema == null || isPlainObject(raw.responseSchema)
|
|
48
|
+
? interrupt
|
|
49
|
+
: withRawResponseSchema(interrupt, raw.responseSchema);
|
|
41
50
|
};
|
|
42
51
|
|
|
43
52
|
const parseRunFinishedOutcome = (
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AgUiInterrupt } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a received `responseSchema` travels when it is not an object. JSON
|
|
5
|
+
* Schema allows a boolean, and `false` rejects every payload, so a schema
|
|
6
|
+
* `responseSchema` cannot hold still has to be distinguishable from an absent
|
|
7
|
+
* one. The carrier is a string key rather than a symbol so it survives the
|
|
8
|
+
* persistence round trip, and it stays off `AgUiInterrupt` because only the
|
|
9
|
+
* gate check reads it.
|
|
10
|
+
*/
|
|
11
|
+
const RAW_RESPONSE_SCHEMA = "__aui_agui_responseSchemaRaw";
|
|
12
|
+
|
|
13
|
+
type RawResponseSchemaCarrier = { [RAW_RESPONSE_SCHEMA]?: unknown };
|
|
14
|
+
|
|
15
|
+
export const withRawResponseSchema = (
|
|
16
|
+
interrupt: AgUiInterrupt,
|
|
17
|
+
schema: unknown,
|
|
18
|
+
): AgUiInterrupt => {
|
|
19
|
+
const carried: AgUiInterrupt & RawResponseSchemaCarrier = {
|
|
20
|
+
...interrupt,
|
|
21
|
+
[RAW_RESPONSE_SCHEMA]: schema,
|
|
22
|
+
};
|
|
23
|
+
return carried;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const readRawResponseSchema = (interrupt: AgUiInterrupt): unknown =>
|
|
27
|
+
(interrupt as RawResponseSchemaCarrier)[RAW_RESPONSE_SCHEMA];
|
|
28
|
+
|
|
29
|
+
export const withoutRawResponseSchema = <T extends AgUiInterrupt>(
|
|
30
|
+
interrupt: T,
|
|
31
|
+
): T => {
|
|
32
|
+
const { [RAW_RESPONSE_SCHEMA]: _raw, ...rest } = interrupt as T &
|
|
33
|
+
RawResponseSchemaCarrier;
|
|
34
|
+
return rest as T;
|
|
35
|
+
};
|