@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.
Files changed (47) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +25 -15
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +257 -210
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +13 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +77 -39
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +23 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +155 -41
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +3 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +9 -0
  18. package/dist/runtime/adapter/subscriber.js.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts +56 -0
  20. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -0
  21. package/dist/runtime/adapter/tool-approval.js +244 -0
  22. package/dist/runtime/adapter/tool-approval.js.map +1 -0
  23. package/dist/runtime/event-parser.d.ts.map +1 -1
  24. package/dist/runtime/event-parser.js +2 -1
  25. package/dist/runtime/event-parser.js.map +1 -1
  26. package/dist/runtime/interrupt-internals.d.ts +8 -0
  27. package/dist/runtime/interrupt-internals.d.ts.map +1 -0
  28. package/dist/runtime/interrupt-internals.js +25 -0
  29. package/dist/runtime/interrupt-internals.js.map +1 -0
  30. package/dist/useAgUiRuntime.js +14 -9
  31. package/dist/useAgUiRuntime.js.map +1 -1
  32. package/package.json +9 -9
  33. package/src/index.ts +8 -2
  34. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  35. package/src/runtime/AgUiThreadRuntimeCore.ts +436 -272
  36. package/src/runtime/adapter/conversions.content.test.ts +61 -3
  37. package/src/runtime/adapter/conversions.ts +123 -57
  38. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  39. package/src/runtime/adapter/run-aggregator.ts +234 -47
  40. package/src/runtime/adapter/subscriber.ts +14 -0
  41. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  42. package/src/runtime/adapter/tool-approval.ts +366 -0
  43. package/src/runtime/event-parser.ts +10 -1
  44. package/src/runtime/interrupt-internals.ts +35 -0
  45. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  46. package/src/useAgUiRuntime.history.test.tsx +76 -0
  47. package/src/useAgUiRuntime.ts +15 -9
@@ -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
- return interrupt;
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
+ };