@assistant-ui/react-ag-ui 0.0.53 → 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.
Files changed (49) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +23 -2
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +183 -39
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts +19 -3
  8. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  9. package/dist/runtime/adapter/conversions.js +197 -36
  10. package/dist/runtime/adapter/conversions.js.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.d.ts +15 -2
  12. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  13. package/dist/runtime/adapter/run-aggregator.js +62 -10
  14. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  15. package/dist/runtime/adapter/subscriber.d.ts +6 -0
  16. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  17. package/dist/runtime/adapter/subscriber.js +10 -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 +15 -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/runtime/types.d.ts +5 -0
  31. package/dist/runtime/types.d.ts.map +1 -1
  32. package/dist/useAgUiRuntime.js +4 -1
  33. package/dist/useAgUiRuntime.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/index.ts +8 -2
  36. package/src/runtime/AgUiThreadRuntimeCore.ts +335 -42
  37. package/src/runtime/adapter/conversions.content.test.ts +278 -0
  38. package/src/runtime/adapter/conversions.ts +298 -50
  39. package/src/runtime/adapter/run-aggregator.approval.test.ts +103 -0
  40. package/src/runtime/adapter/run-aggregator.ts +96 -5
  41. package/src/runtime/adapter/subscriber.ts +17 -0
  42. package/src/runtime/adapter/tool-approval.test.ts +681 -0
  43. package/src/runtime/adapter/tool-approval.ts +366 -0
  44. package/src/runtime/event-parser.ts +23 -1
  45. package/src/runtime/interrupt-internals.ts +35 -0
  46. package/src/runtime/types.ts +6 -0
  47. package/src/useAgUiRuntime.approval.test.tsx +885 -0
  48. package/src/useAgUiRuntime.queue.test.tsx +68 -1
  49. package/src/useAgUiRuntime.ts +5 -1
@@ -0,0 +1,885 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import {
5
+ act,
6
+ cleanup,
7
+ render,
8
+ renderHook,
9
+ screen,
10
+ waitFor,
11
+ } from "@testing-library/react";
12
+ import type { AssistantRuntime, ToolCallMessagePart } from "@assistant-ui/core";
13
+ import {
14
+ AssistantRuntimeProvider,
15
+ MessagePrimitiveParts,
16
+ ThreadPrimitiveMessages,
17
+ useAssistantTool,
18
+ type ToolCallMessagePartProps,
19
+ } from "@assistant-ui/core/react";
20
+ import type { HttpAgent } from "@ag-ui/client";
21
+ import { z } from "zod";
22
+ import { useAgUiRuntime } from "./useAgUiRuntime";
23
+ import {
24
+ useAgUiInterrupts,
25
+ useAgUiSteerAway,
26
+ useAgUiSubmitInterruptResponses,
27
+ } from "./hooks";
28
+ import type { AgUiInterrupt } from "./runtime/types";
29
+ import { readRawResponseSchema } from "./runtime/interrupt-internals";
30
+
31
+ type Subscriber = Record<string, ((payload: any) => void) | undefined>;
32
+
33
+ const GATE: AgUiInterrupt = {
34
+ id: "int-1",
35
+ reason: "tool_call",
36
+ toolCallId: "tc-1",
37
+ message: "Delete /tmp/a?",
38
+ };
39
+
40
+ // Gates the first run on an interrupt and lets every later run settle, so a
41
+ // resumed run is observable as a second call rather than a second gate.
42
+ const gatingAgent = (interrupts: readonly AgUiInterrupt[] = [GATE]) => {
43
+ const runAgent = vi.fn(async (input: unknown, subscriber: Subscriber) => {
44
+ if (runAgent.mock.calls.length > 1) {
45
+ subscriber.onRunFinalized?.(undefined);
46
+ return;
47
+ }
48
+ for (const interrupt of interrupts) {
49
+ const toolCallId = interrupt.toolCallId ?? "tc-x";
50
+ subscriber.onToolCallStartEvent?.({
51
+ event: {
52
+ type: "TOOL_CALL_START",
53
+ toolCallId,
54
+ toolCallName: "delete_file",
55
+ },
56
+ });
57
+ subscriber.onToolCallArgsEvent?.({
58
+ event: {
59
+ type: "TOOL_CALL_ARGS",
60
+ toolCallId,
61
+ delta: '{"path":"/tmp/a"}',
62
+ },
63
+ });
64
+ subscriber.onToolCallEndEvent?.({
65
+ event: { type: "TOOL_CALL_END", toolCallId },
66
+ });
67
+ }
68
+ subscriber.onRunFinishedEvent?.({
69
+ event: {
70
+ type: "RUN_FINISHED",
71
+ runId: "run-1",
72
+ outcome: { type: "interrupt", interrupts },
73
+ },
74
+ });
75
+ subscriber.onRunFinalized?.(undefined);
76
+ void input;
77
+ });
78
+ return {
79
+ agent: { runAgent, abortRun: vi.fn() } as unknown as HttpAgent,
80
+ runAgent,
81
+ };
82
+ };
83
+
84
+ const steerAwayRef: { current: ReturnType<typeof useAgUiSteerAway> | null } = {
85
+ current: null,
86
+ };
87
+
88
+ const interruptsRef: { current: readonly AgUiInterrupt[] } = { current: [] };
89
+
90
+ const submitRef: {
91
+ current: ReturnType<typeof useAgUiSubmitInterruptResponses> | null;
92
+ } = { current: null };
93
+
94
+ const SteerAway = () => {
95
+ steerAwayRef.current = useAgUiSteerAway();
96
+ interruptsRef.current = useAgUiInterrupts();
97
+ submitRef.current = useAgUiSubmitInterruptResponses();
98
+ return null;
99
+ };
100
+
101
+ const gatedThread = async (
102
+ interrupts: readonly AgUiInterrupt[] = [GATE],
103
+ options: { children?: React.ReactNode } = {},
104
+ ) => {
105
+ const { agent, runAgent } = gatingAgent(interrupts);
106
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
107
+ render(
108
+ <AssistantRuntimeProvider runtime={result.current}>
109
+ <SteerAway />
110
+ {options.children}
111
+ </AssistantRuntimeProvider>,
112
+ );
113
+
114
+ await act(async () => {
115
+ await result.current.thread.append({
116
+ role: "user",
117
+ content: [{ type: "text", text: "delete it" }],
118
+ });
119
+ });
120
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
121
+
122
+ return { runtime: result, runAgent };
123
+ };
124
+
125
+ const gatedPart = (runtime: AssistantRuntime, toolCallId = "tc-1") =>
126
+ runtime.thread
127
+ .getMessageByIndex(runtime.thread.getState().messages.length - 1)
128
+ .getMessagePartByToolCallId(toolCallId);
129
+
130
+ const allToolCalls = (runtime: AssistantRuntime): ToolCallMessagePart[] =>
131
+ runtime.thread
132
+ .getState()
133
+ .messages.flatMap((message) => message.content as readonly unknown[])
134
+ .filter(
135
+ (part): part is ToolCallMessagePart =>
136
+ (part as ToolCallMessagePart).type === "tool-call",
137
+ );
138
+
139
+ const resumeOf = (runAgent: ReturnType<typeof vi.fn>) =>
140
+ (runAgent.mock.calls.at(-1)?.[0] as { resume?: unknown } | undefined)?.resume;
141
+
142
+ afterEach(() => cleanup());
143
+
144
+ describe("useAgUiRuntime tool approvals", () => {
145
+ it("gates the tool call a tool_call interrupt names", async () => {
146
+ const { runtime } = await gatedThread();
147
+
148
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual({ id: "int-1" });
149
+ expect(gatedPart(runtime.current).getState().status).toMatchObject({
150
+ type: "requires-action",
151
+ });
152
+ });
153
+
154
+ it("holds and resumes a batch whose interrupt ids name prototype members", async () => {
155
+ for (const id of ["__proto__", "constructor", "toString"]) {
156
+ const { runtime, runAgent } = await gatedThread([
157
+ { ...GATE, id },
158
+ { id: "int-2", reason: "tool_call", toolCallId: "tc-2" },
159
+ ]);
160
+
161
+ await act(async () => {
162
+ gatedPart(runtime.current, "tc-1").respondToToolApproval({
163
+ approved: true,
164
+ });
165
+ });
166
+ expect(runAgent).toHaveBeenCalledTimes(1);
167
+
168
+ await act(async () => {
169
+ gatedPart(runtime.current, "tc-2").respondToToolApproval({
170
+ approved: false,
171
+ });
172
+ });
173
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
174
+
175
+ expect(resumeOf(runAgent)).toEqual([
176
+ { interruptId: id, status: "resolved", payload: { approved: true } },
177
+ {
178
+ interruptId: "int-2",
179
+ status: "resolved",
180
+ payload: { approved: false },
181
+ },
182
+ ]);
183
+ cleanup();
184
+ }
185
+ });
186
+
187
+ it("holds the run until every gate is decided, then resumes once in order", async () => {
188
+ const second: AgUiInterrupt = {
189
+ id: "int-2",
190
+ reason: "tool_call",
191
+ toolCallId: "tc-2",
192
+ message: "Delete /tmp/b?",
193
+ };
194
+ const { runtime, runAgent } = await gatedThread([GATE, second]);
195
+
196
+ await act(async () => {
197
+ gatedPart(runtime.current, "tc-1").respondToToolApproval({
198
+ approved: true,
199
+ });
200
+ });
201
+ expect(runAgent).toHaveBeenCalledTimes(1);
202
+ // The first decision survives the rerender the second gate triggers.
203
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual({
204
+ id: "int-1",
205
+ approved: true,
206
+ });
207
+
208
+ await act(async () => {
209
+ gatedPart(runtime.current, "tc-2").respondToToolApproval({
210
+ approved: false,
211
+ reason: "too risky",
212
+ });
213
+ });
214
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
215
+
216
+ expect(resumeOf(runAgent)).toEqual([
217
+ { interruptId: "int-1", status: "resolved", payload: { approved: true } },
218
+ {
219
+ interruptId: "int-2",
220
+ status: "resolved",
221
+ payload: { approved: false, reason: "too risky" },
222
+ },
223
+ ]);
224
+ // Settlement consumes the resume array, so the approved gate stays approved.
225
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual({
226
+ id: "int-1",
227
+ approved: true,
228
+ });
229
+ expect(
230
+ allToolCalls(runtime.current).every((part) => part.result === undefined),
231
+ ).toBe(true);
232
+ });
233
+
234
+ it("cancels a locally approved gate when the batch is discarded for both", async () => {
235
+ const second: AgUiInterrupt = {
236
+ id: "int-2",
237
+ reason: "tool_call",
238
+ toolCallId: "tc-2",
239
+ };
240
+ const { runtime, runAgent } = await gatedThread([GATE, second]);
241
+
242
+ await act(async () => {
243
+ gatedPart(runtime.current, "tc-1").respondToToolApproval({
244
+ approved: true,
245
+ });
246
+ });
247
+ await act(async () => {
248
+ await steerAwayRef.current!("never mind");
249
+ });
250
+
251
+ expect(resumeOf(runAgent)).toEqual([
252
+ { interruptId: "int-1", status: "cancelled" },
253
+ { interruptId: "int-2", status: "cancelled" },
254
+ ]);
255
+ // The wire cancelled both, so the gate approved while its sibling was
256
+ // still open cannot keep displaying an approval that was never sent.
257
+ expect(allToolCalls(runtime.current).map((part) => part.approval)).toEqual([
258
+ { id: "int-1", resolution: "cancelled" },
259
+ { id: "int-2", resolution: "cancelled" },
260
+ ]);
261
+ });
262
+
263
+ it("leaves a mixed batch entirely to the bespoke interrupt hooks", async () => {
264
+ const { runtime } = await gatedThread([
265
+ GATE,
266
+ { id: "int-2", reason: "input_required", message: "which branch?" },
267
+ ]);
268
+
269
+ expect(
270
+ allToolCalls(runtime.current).every(
271
+ (part) => part.approval === undefined,
272
+ ),
273
+ ).toBe(true);
274
+ });
275
+
276
+ it("marks a discarded gate cancelled, matching the entry actually sent", async () => {
277
+ const { runtime, runAgent } = await gatedThread();
278
+
279
+ await act(async () => {
280
+ await steerAwayRef.current!("never mind");
281
+ });
282
+
283
+ expect(resumeOf(runAgent)).toEqual([
284
+ { interruptId: "int-1", status: "cancelled" },
285
+ ]);
286
+ expect(
287
+ allToolCalls(runtime.current).find((p) => p.approval?.id === "int-1")
288
+ ?.approval,
289
+ ).toEqual({ id: "int-1", resolution: "cancelled" });
290
+ });
291
+
292
+ it("shows a bespoke resolved override as the decision it carried", async () => {
293
+ const { runtime } = await gatedThread();
294
+
295
+ await act(async () => {
296
+ await steerAwayRef.current!("do it anyway", [
297
+ {
298
+ interruptId: "int-1",
299
+ status: "resolved",
300
+ payload: { approved: true },
301
+ },
302
+ ]);
303
+ });
304
+
305
+ expect(
306
+ allToolCalls(runtime.current).find((p) => p.approval?.id === "int-1")
307
+ ?.approval,
308
+ ).toEqual({ id: "int-1", approved: true });
309
+ });
310
+
311
+ it("leaves the tool call ungated when the live interrupt carries a rejecting schema", async () => {
312
+ const { runtime } = await gatedThread([
313
+ { ...GATE, responseSchema: false } as unknown as AgUiInterrupt,
314
+ ]);
315
+
316
+ expect(allToolCalls(runtime.current)[0]!.approval).toBeUndefined();
317
+ });
318
+
319
+ it("keeps a rejecting live schema readable without widening the public field", async () => {
320
+ const { runtime } = await gatedThread([
321
+ { ...GATE, responseSchema: false } as unknown as AgUiInterrupt,
322
+ ]);
323
+ const [interrupt] = interruptsRef.current;
324
+
325
+ expect(interrupt?.responseSchema).toBeUndefined();
326
+ expect(interrupt && readRawResponseSchema(interrupt)).toBe(false);
327
+ // The exported reads a consumer wrote against the previous release still
328
+ // typecheck: the field stays an object schema or absent.
329
+ if (interrupt?.responseSchema !== undefined)
330
+ expect(Object.keys(interrupt.responseSchema)).toEqual([]);
331
+ const schema: Record<string, unknown> | undefined =
332
+ interrupt?.responseSchema;
333
+ expect(schema).toBeUndefined();
334
+ expect(allToolCalls(runtime.current)[0]!.approval).toBeUndefined();
335
+ });
336
+
337
+ /**
338
+ * `null` is not a JSON Schema, and it is what a server serializing an unset
339
+ * optional field sends, so it reads as absent rather than disabling the seam.
340
+ */
341
+ it("gates a live interrupt whose schema is null", async () => {
342
+ const { runtime } = await gatedThread([
343
+ { ...GATE, responseSchema: null } as unknown as AgUiInterrupt,
344
+ ]);
345
+ const [interrupt] = interruptsRef.current;
346
+
347
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual({ id: "int-1" });
348
+ expect(interrupt?.responseSchema).toBeUndefined();
349
+ expect(interrupt && readRawResponseSchema(interrupt)).toBeUndefined();
350
+ });
351
+
352
+ it("carries an object schema on the public field unchanged", async () => {
353
+ await gatedThread([
354
+ { ...GATE, responseSchema: { type: "object" } } as AgUiInterrupt,
355
+ ]);
356
+
357
+ expect(interruptsRef.current[0]?.responseSchema).toEqual({
358
+ type: "object",
359
+ });
360
+ expect(readRawResponseSchema(interruptsRef.current[0]!)).toBeUndefined();
361
+ });
362
+
363
+ it("leaves the tool call ungated when a live object schema is malformed", async () => {
364
+ for (const responseSchema of [
365
+ { title: 42 },
366
+ { description: [] },
367
+ { type: ["object", "bogus"] },
368
+ { required: ["approved", "approved"] },
369
+ // Own properties named after `Object.prototype` members, as a server can
370
+ // send them over the wire.
371
+ JSON.parse('{"__proto__":{}}'),
372
+ JSON.parse('{"constructor":{}}'),
373
+ JSON.parse('{"toString":{}}'),
374
+ JSON.parse('{"type":"object","valueOf":{}}'),
375
+ ]) {
376
+ const { runtime } = await gatedThread([
377
+ { ...GATE, responseSchema } as unknown as AgUiInterrupt,
378
+ ]);
379
+
380
+ expect(allToolCalls(runtime.current)[0]!.approval).toBeUndefined();
381
+ cleanup();
382
+ }
383
+ });
384
+
385
+ it("clears a local approval a resolved override replaced with a payload of its own", async () => {
386
+ const second: AgUiInterrupt = {
387
+ id: "int-2",
388
+ reason: "tool_call",
389
+ toolCallId: "tc-2",
390
+ };
391
+ const { runtime, runAgent } = await gatedThread([GATE, second]);
392
+
393
+ await act(async () => {
394
+ gatedPart(runtime.current, "tc-1").respondToToolApproval({
395
+ approved: true,
396
+ });
397
+ });
398
+ await act(async () => {
399
+ await steerAwayRef.current!("do it my way", [
400
+ {
401
+ interruptId: "int-1",
402
+ status: "resolved",
403
+ payload: { answer: 42 },
404
+ },
405
+ ]);
406
+ });
407
+
408
+ expect(resumeOf(runAgent)).toEqual([
409
+ { interruptId: "int-1", status: "resolved", payload: { answer: 42 } },
410
+ { interruptId: "int-2", status: "cancelled" },
411
+ ]);
412
+ // `{answer:42}` went out for the first gate, so the approval recorded while
413
+ // its sibling was still open cannot keep displaying a decision never sent.
414
+ expect(allToolCalls(runtime.current).map((part) => part.approval)).toEqual([
415
+ { id: "int-1", resolution: "cancelled" },
416
+ { id: "int-2", resolution: "cancelled" },
417
+ ]);
418
+ });
419
+
420
+ it("resumes on a decision it validated, even when the clock crosses expiry mid-submission", async () => {
421
+ const { runtime, runAgent } = await gatedThread([
422
+ { ...GATE, expiresAt: new Date(2000).toISOString() },
423
+ ]);
424
+
425
+ // The gate is answerable at the first reading and expired at the second, so
426
+ // a second validation would reject a decision already recorded.
427
+ let readings = 0;
428
+ const now = vi
429
+ .spyOn(Date, "now")
430
+ .mockImplementation(() => (readings++ === 0 ? 1000 : 3000));
431
+ try {
432
+ await act(async () => {
433
+ await gatedPart(runtime.current).respondToToolApproval({
434
+ approved: true,
435
+ });
436
+ });
437
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
438
+ } finally {
439
+ now.mockRestore();
440
+ }
441
+
442
+ expect(resumeOf(runAgent)).toEqual([
443
+ { interruptId: "int-1", status: "resolved", payload: { approved: true } },
444
+ ]);
445
+ });
446
+
447
+ it("keeps a gated call out of the tool-call action state a frontend tool would run from", async () => {
448
+ const execute = vi.fn(async () => ({ deleted: true }));
449
+ const DeleteFileTool = () => {
450
+ useAssistantTool({
451
+ toolName: "delete_file",
452
+ description: "delete a file",
453
+ parameters: z.object({ path: z.string() }),
454
+ execute,
455
+ });
456
+ return null;
457
+ };
458
+ const { runtime, runAgent } = await gatedThread([GATE], {
459
+ children: <DeleteFileTool />,
460
+ });
461
+
462
+ // A frontend tool of the same name is registered and reaches the thread's
463
+ // model context, so only the gate keeps the call from being actionable.
464
+ expect(
465
+ Object.keys(runtime.current.thread.getModelContext().tools ?? {}),
466
+ ).toContain("delete_file");
467
+ const message = runtime.current.thread.getState().messages.at(-1)!;
468
+ expect(message.status).toMatchObject({
469
+ type: "requires-action",
470
+ reason: "interrupt",
471
+ });
472
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual({ id: "int-1" });
473
+ expect(execute).not.toHaveBeenCalled();
474
+
475
+ await act(async () => {
476
+ gatedPart(runtime.current).respondToToolApproval({ approved: true });
477
+ });
478
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
479
+
480
+ // The provider owns the gated call: approving resumes the run rather than
481
+ // resolving the call with a local result.
482
+ expect(execute).not.toHaveBeenCalled();
483
+ expect(
484
+ allToolCalls(runtime.current).every((part) => part.result === undefined),
485
+ ).toBe(true);
486
+ });
487
+ });
488
+
489
+ describe("useAgUiRuntime tool approvals under an unsettled run", () => {
490
+ // Exposes the gate through RUN_FINISHED while runAgent is still pending, so
491
+ // a click lands in the window where the run has not finalized.
492
+ const heldGatingAgent = () => {
493
+ let release!: () => void;
494
+ const held = new Promise<void>((resolve) => {
495
+ release = resolve;
496
+ });
497
+ const runAgent = vi.fn(async (input: unknown, subscriber: Subscriber) => {
498
+ if (runAgent.mock.calls.length > 1) {
499
+ subscriber.onRunFinalized?.(undefined);
500
+ return;
501
+ }
502
+ subscriber.onToolCallStartEvent?.({
503
+ event: {
504
+ type: "TOOL_CALL_START",
505
+ toolCallId: "tc-1",
506
+ toolCallName: "delete_file",
507
+ },
508
+ });
509
+ subscriber.onToolCallEndEvent?.({
510
+ event: { type: "TOOL_CALL_END", toolCallId: "tc-1" },
511
+ });
512
+ subscriber.onRunFinishedEvent?.({
513
+ event: {
514
+ type: "RUN_FINISHED",
515
+ runId: "run-1",
516
+ outcome: { type: "interrupt", interrupts: [GATE] },
517
+ },
518
+ });
519
+ await held;
520
+ subscriber.onRunFinalized?.(undefined);
521
+ void input;
522
+ });
523
+ return {
524
+ agent: { runAgent, abortRun: vi.fn() } as unknown as HttpAgent,
525
+ runAgent,
526
+ release,
527
+ };
528
+ };
529
+
530
+ it("keeps the gate answerable after a click the in-flight run rejects", async () => {
531
+ const { agent, runAgent, release } = heldGatingAgent();
532
+ const onError = vi.fn();
533
+ const { result } = renderHook(() => useAgUiRuntime({ agent, onError }));
534
+ render(
535
+ <AssistantRuntimeProvider runtime={result.current}>
536
+ <></>
537
+ </AssistantRuntimeProvider>,
538
+ );
539
+
540
+ result.current.thread.append({
541
+ role: "user",
542
+ content: [{ type: "text", text: "delete it" }],
543
+ });
544
+ await waitFor(() =>
545
+ expect(allToolCalls(result.current)[0]?.approval).toEqual({
546
+ id: "int-1",
547
+ }),
548
+ );
549
+ expect(result.current.thread.getState().isRunning).toBe(true);
550
+
551
+ await act(async () => {
552
+ gatedPart(result.current).respondToToolApproval({ approved: true });
553
+ });
554
+ await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
555
+ expect((onError.mock.calls[0]![0] as Error).message).toContain(
556
+ "a run is already in progress",
557
+ );
558
+ // The rejected click must not have recorded a decision, or the retry
559
+ // below would report the approval as already decided.
560
+ expect(allToolCalls(result.current)[0]!.approval).toEqual({ id: "int-1" });
561
+
562
+ await act(async () => {
563
+ release();
564
+ });
565
+ await waitFor(() =>
566
+ expect(result.current.thread.getState().isRunning).toBe(false),
567
+ );
568
+
569
+ await act(async () => {
570
+ await gatedPart(result.current).respondToToolApproval({ approved: true });
571
+ });
572
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
573
+ expect(resumeOf(runAgent)).toEqual([
574
+ { interruptId: "int-1", status: "resolved", payload: { approved: true } },
575
+ ]);
576
+ });
577
+ });
578
+
579
+ describe("useAgUiRuntime tool approvals across a messages snapshot", () => {
580
+ // A snapshot whose assistant id differs from the optimistic one evicts the
581
+ // in-flight assistant before the interrupt arrives; the gate must still be
582
+ // stamped exactly once and stay answerable.
583
+ const snapshotGatingAgent = () => {
584
+ const runAgent = vi.fn(async (input: unknown, subscriber: Subscriber) => {
585
+ if (runAgent.mock.calls.length > 1) {
586
+ subscriber.onRunFinalized?.(undefined);
587
+ return;
588
+ }
589
+ subscriber.onToolCallStartEvent?.({
590
+ event: {
591
+ type: "TOOL_CALL_START",
592
+ toolCallId: "tc-1",
593
+ toolCallName: "delete_file",
594
+ },
595
+ });
596
+ subscriber.onToolCallArgsEvent?.({
597
+ event: {
598
+ type: "TOOL_CALL_ARGS",
599
+ toolCallId: "tc-1",
600
+ delta: '{"path":"/tmp/a"}',
601
+ },
602
+ });
603
+ subscriber.onToolCallEndEvent?.({
604
+ event: { type: "TOOL_CALL_END", toolCallId: "tc-1" },
605
+ });
606
+ subscriber.onMessagesSnapshotEvent?.({
607
+ event: {
608
+ type: "MESSAGES_SNAPSHOT",
609
+ messages: [
610
+ { id: "u-1", role: "user", content: "delete it" },
611
+ {
612
+ id: "srv-assistant-1",
613
+ role: "assistant",
614
+ content: "",
615
+ toolCalls: [
616
+ {
617
+ id: "tc-1",
618
+ type: "function",
619
+ function: {
620
+ name: "delete_file",
621
+ arguments: '{"path":"/tmp/a"}',
622
+ },
623
+ },
624
+ ],
625
+ },
626
+ ],
627
+ },
628
+ });
629
+ subscriber.onRunFinishedEvent?.({
630
+ event: {
631
+ type: "RUN_FINISHED",
632
+ runId: "run-1",
633
+ outcome: { type: "interrupt", interrupts: [GATE] },
634
+ },
635
+ });
636
+ subscriber.onRunFinalized?.(undefined);
637
+ void input;
638
+ });
639
+ return {
640
+ agent: { runAgent, abortRun: vi.fn() } as unknown as HttpAgent,
641
+ runAgent,
642
+ };
643
+ };
644
+
645
+ it("stamps the gate once and answers it after a snapshot lands mid-run", async () => {
646
+ const { agent, runAgent } = snapshotGatingAgent();
647
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
648
+ render(
649
+ <AssistantRuntimeProvider runtime={result.current}>
650
+ <></>
651
+ </AssistantRuntimeProvider>,
652
+ );
653
+
654
+ await act(async () => {
655
+ await result.current.thread.append({
656
+ role: "user",
657
+ content: [{ type: "text", text: "delete it" }],
658
+ });
659
+ });
660
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
661
+
662
+ const gated = allToolCalls(result.current).filter(
663
+ (part) => part.approval !== undefined,
664
+ );
665
+ expect(gated.map((part) => part.approval)).toEqual([{ id: "int-1" }]);
666
+
667
+ await act(async () => {
668
+ await gatedPart(result.current).respondToToolApproval({ approved: true });
669
+ });
670
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
671
+ expect(resumeOf(runAgent)).toEqual([
672
+ { interruptId: "int-1", status: "resolved", payload: { approved: true } },
673
+ ]);
674
+ expect(
675
+ allToolCalls(result.current).filter(
676
+ (part) => part.approval?.approved === true,
677
+ ),
678
+ ).toHaveLength(1);
679
+ });
680
+ });
681
+
682
+ describe("useAgUiRuntime default rendering path", () => {
683
+ // Renders the runtime through core's real MessagePrimitive.Parts, the seam
684
+ // that supplies `approval` and `respondToApproval` to whichever tool
685
+ // component is registered. A revert of the aggregator projection, of
686
+ // `onRespondToToolApproval`, or of the wire mapping fails here.
687
+ const ToolRenderer = ({
688
+ approval,
689
+ respondToApproval,
690
+ }: ToolCallMessagePartProps) => {
691
+ if (!approval || approval.approved !== undefined || approval.resolution) {
692
+ return <div data-testid="settled" />;
693
+ }
694
+ return (
695
+ <>
696
+ <button onClick={() => respondToApproval?.({ approved: true })}>
697
+ Allow
698
+ </button>
699
+ <button
700
+ onClick={() => respondToApproval?.({ approved: false, reason: "no" })}
701
+ >
702
+ Deny
703
+ </button>
704
+ </>
705
+ );
706
+ };
707
+
708
+ const Thread = () => (
709
+ <ThreadPrimitiveMessages
710
+ components={{
711
+ Message: () => (
712
+ <MessagePrimitiveParts
713
+ components={{ tools: { Fallback: ToolRenderer } }}
714
+ />
715
+ ),
716
+ }}
717
+ />
718
+ );
719
+
720
+ it("sends approved:true when the rendered gate is allowed", async () => {
721
+ const { runAgent } = await gatedThread([GATE], { children: <Thread /> });
722
+
723
+ await waitFor(() => expect(screen.getByText("Allow")).toBeDefined());
724
+ await act(async () => {
725
+ screen.getByText("Allow").click();
726
+ });
727
+
728
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
729
+ expect(resumeOf(runAgent)).toEqual([
730
+ { interruptId: "int-1", status: "resolved", payload: { approved: true } },
731
+ ]);
732
+ });
733
+
734
+ it("sends a resolved approved:false with the reason when denied", async () => {
735
+ const { runAgent } = await gatedThread([GATE], { children: <Thread /> });
736
+
737
+ await waitFor(() => expect(screen.getByText("Deny")).toBeDefined());
738
+ await act(async () => {
739
+ screen.getByText("Deny").click();
740
+ });
741
+
742
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
743
+ expect(resumeOf(runAgent)).toEqual([
744
+ {
745
+ interruptId: "int-1",
746
+ status: "resolved",
747
+ payload: { approved: false, reason: "no" },
748
+ },
749
+ ]);
750
+ });
751
+ });
752
+
753
+ describe("useAgUiRuntime approvals on a run that fails after the interrupt", () => {
754
+ it("reports a failure raised by a resumed run only once", async () => {
755
+ const runAgent = vi.fn(async (_input: unknown, subscriber: Subscriber) => {
756
+ if (runAgent.mock.calls.length > 1) {
757
+ subscriber.onRunFailed?.({ error: new Error("resume failed") });
758
+ return;
759
+ }
760
+ subscriber.onToolCallStartEvent?.({
761
+ event: {
762
+ type: "TOOL_CALL_START",
763
+ toolCallId: "tc-1",
764
+ toolCallName: "delete_file",
765
+ },
766
+ });
767
+ subscriber.onToolCallEndEvent?.({
768
+ event: { type: "TOOL_CALL_END", toolCallId: "tc-1" },
769
+ });
770
+ subscriber.onRunFinishedEvent?.({
771
+ event: {
772
+ type: "RUN_FINISHED",
773
+ runId: "run-1",
774
+ outcome: { type: "interrupt", interrupts: [GATE] },
775
+ },
776
+ });
777
+ subscriber.onRunFinalized?.(undefined);
778
+ });
779
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
780
+ const onError = vi.fn();
781
+
782
+ const { result } = renderHook(() => useAgUiRuntime({ agent, onError }));
783
+ render(
784
+ <AssistantRuntimeProvider runtime={result.current}>
785
+ <SteerAway />
786
+ </AssistantRuntimeProvider>,
787
+ );
788
+
789
+ await act(async () => {
790
+ await result.current.thread.append({
791
+ role: "user",
792
+ content: [{ type: "text", text: "delete it" }],
793
+ });
794
+ });
795
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
796
+
797
+ await act(async () => {
798
+ gatedPart(result.current).respondToToolApproval({ approved: true });
799
+ });
800
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
801
+ await waitFor(() => expect(onError).toHaveBeenCalled());
802
+
803
+ expect(onError.mock.calls.map((call) => call[0].message)).toEqual([
804
+ "resume failed",
805
+ ]);
806
+ });
807
+ });
808
+
809
+ describe("useAgUiRuntime approvals that cannot be completed through the seam", () => {
810
+ it("leaves a batch bespoke when a gate names a tool call the message never rendered", async () => {
811
+ // The aggregator clears its tool calls on `RUN_STARTED`, so a gate naming a
812
+ // call from an earlier run has no part to bind to.
813
+ const unbound: AgUiInterrupt = {
814
+ id: "int-2",
815
+ reason: "tool_call",
816
+ toolCallId: "tc-missing",
817
+ };
818
+ const runAgent = vi.fn(async (_input: unknown, subscriber: Subscriber) => {
819
+ if (runAgent.mock.calls.length > 1) {
820
+ subscriber.onRunFinalized?.(undefined);
821
+ return;
822
+ }
823
+ subscriber.onToolCallStartEvent?.({
824
+ event: {
825
+ type: "TOOL_CALL_START",
826
+ toolCallId: "tc-1",
827
+ toolCallName: "delete_file",
828
+ },
829
+ });
830
+ subscriber.onToolCallEndEvent?.({
831
+ event: { type: "TOOL_CALL_END", toolCallId: "tc-1" },
832
+ });
833
+ subscriber.onRunFinishedEvent?.({
834
+ event: {
835
+ type: "RUN_FINISHED",
836
+ runId: "run-1",
837
+ outcome: { type: "interrupt", interrupts: [GATE, unbound] },
838
+ },
839
+ });
840
+ subscriber.onRunFinalized?.(undefined);
841
+ });
842
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
843
+
844
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
845
+ render(
846
+ <AssistantRuntimeProvider runtime={result.current}>
847
+ <SteerAway />
848
+ </AssistantRuntimeProvider>,
849
+ );
850
+ await act(async () => {
851
+ await result.current.thread.append({
852
+ role: "user",
853
+ content: [{ type: "text", text: "delete it" }],
854
+ });
855
+ });
856
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
857
+
858
+ // Nothing is gated, so the rendered sibling cannot take a decision the
859
+ // resume could never carry; both interrupts stay on the bespoke hooks.
860
+ expect(allToolCalls(result.current).map((part) => part.approval)).toEqual([
861
+ undefined,
862
+ ]);
863
+ expect(interruptsRef.current.map((interrupt) => interrupt.id)).toEqual([
864
+ "int-1",
865
+ "int-2",
866
+ ]);
867
+ });
868
+
869
+ it("closes the gate when a resolved override settles it without a decision", async () => {
870
+ const { runtime, runAgent } = await gatedThread();
871
+
872
+ await act(async () => {
873
+ await submitRef.current!([
874
+ { interruptId: "int-1", status: "resolved", payload: { answer: 42 } },
875
+ ]);
876
+ });
877
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
878
+
879
+ // The interrupt is closed, so the gate must not stay actionable: a click
880
+ // would find no pending interrupt on the thread.
881
+ expect(allToolCalls(runtime.current).map((part) => part.approval)).toEqual([
882
+ { id: "int-1", resolution: "cancelled" },
883
+ ]);
884
+ });
885
+ });