@assistant-ui/react-ag-ui 0.0.56 → 0.0.58

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 (42) hide show
  1. package/README.md +16 -0
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -17
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadRuntimeCore.js +133 -234
  5. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  7. package/dist/runtime/adapter/conversions.js +54 -25
  8. package/dist/runtime/adapter/conversions.js.map +1 -1
  9. package/dist/runtime/adapter/run-aggregator.d.ts +31 -4
  10. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  11. package/dist/runtime/adapter/run-aggregator.js +419 -123
  12. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  13. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  14. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  15. package/dist/runtime/adapter/subscriber.js +4 -1
  16. package/dist/runtime/adapter/subscriber.js.map +1 -1
  17. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  19. package/dist/runtime/adapter/tool-approval.js +11 -12
  20. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  21. package/dist/runtime/event-parser.d.ts.map +1 -1
  22. package/dist/runtime/event-parser.js +176 -45
  23. package/dist/runtime/event-parser.js.map +1 -1
  24. package/dist/runtime/types.d.ts +42 -1
  25. package/dist/runtime/types.d.ts.map +1 -1
  26. package/dist/useAgUiRuntime.js +12 -9
  27. package/dist/useAgUiRuntime.js.map +1 -1
  28. package/package.json +10 -10
  29. package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
  30. package/src/runtime/AgUiThreadRuntimeCore.ts +190 -319
  31. package/src/runtime/adapter/conversions.ts +128 -36
  32. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  33. package/src/runtime/adapter/run-aggregator.ts +670 -148
  34. package/src/runtime/adapter/subscriber.ts +10 -1
  35. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  36. package/src/runtime/adapter/tool-approval.ts +17 -12
  37. package/src/runtime/event-parser.ts +216 -36
  38. package/src/runtime/types.ts +70 -12
  39. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  40. package/src/useAgUiRuntime.history.test.tsx +76 -0
  41. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  42. package/src/useAgUiRuntime.ts +12 -9
@@ -127,6 +127,10 @@ export type AgUiRunFinishedOutcome =
127
127
  | { type: "success" }
128
128
  | { type: "interrupt"; interrupts: AgUiInterrupt[] };
129
129
 
130
+ export type AgUiSubagentFinishedOutcome =
131
+ | { type: "success" }
132
+ | { type: "suspended"; interruptIds?: string[] };
133
+
130
134
  export type AgUiEvent =
131
135
  | { type: "RUN_STARTED"; runId: string }
132
136
  | {
@@ -136,40 +140,71 @@ export type AgUiEvent =
136
140
  }
137
141
  | { type: "RUN_CANCELLED"; runId?: string }
138
142
  | { type: "RUN_ERROR"; message?: string; code?: string }
139
- | { type: "TEXT_MESSAGE_START"; messageId?: string }
140
- | { type: "TEXT_MESSAGE_CONTENT"; messageId?: string; delta: string }
141
- | { type: "TEXT_MESSAGE_END"; messageId?: string }
142
- | { type: "TEXT_MESSAGE_CHUNK"; delta: string }
143
+ | { type: "TEXT_MESSAGE_START"; messageId?: string; subagentRunId?: string }
144
+ | {
145
+ type: "TEXT_MESSAGE_CONTENT";
146
+ messageId?: string;
147
+ delta: string;
148
+ subagentRunId?: string;
149
+ }
150
+ | { type: "TEXT_MESSAGE_END"; messageId?: string; subagentRunId?: string }
151
+ | {
152
+ type: "TEXT_MESSAGE_CHUNK";
153
+ messageId?: string;
154
+ delta: string;
155
+ subagentRunId?: string;
156
+ }
143
157
  | { type: "THINKING_START"; title?: string }
144
158
  | { type: "THINKING_TEXT_MESSAGE_START" }
145
159
  | { type: "THINKING_TEXT_MESSAGE_CONTENT"; delta: string }
146
160
  | { type: "THINKING_TEXT_MESSAGE_END" }
147
161
  | { type: "THINKING_END" }
148
- | { type: "REASONING_START"; messageId?: string }
149
- | { type: "REASONING_MESSAGE_START"; messageId?: string }
150
- | { type: "REASONING_MESSAGE_CONTENT"; messageId?: string; delta: string }
151
- | { type: "REASONING_MESSAGE_END"; messageId?: string }
162
+ | { type: "REASONING_START"; messageId?: string; subagentRunId?: string }
163
+ | {
164
+ type: "REASONING_MESSAGE_START";
165
+ messageId?: string;
166
+ subagentRunId?: string;
167
+ }
168
+ | {
169
+ type: "REASONING_MESSAGE_CONTENT";
170
+ messageId?: string;
171
+ delta: string;
172
+ subagentRunId?: string;
173
+ }
174
+ | {
175
+ type: "REASONING_MESSAGE_END";
176
+ messageId?: string;
177
+ subagentRunId?: string;
178
+ }
152
179
  | {
153
180
  type: "REASONING_ENCRYPTED_VALUE";
154
181
  subtype: "message" | "tool-call";
155
182
  entityId: string;
156
183
  encryptedValue: string;
184
+ subagentRunId?: string;
157
185
  }
158
- | { type: "REASONING_END"; messageId?: string }
186
+ | { type: "REASONING_END"; messageId?: string; subagentRunId?: string }
159
187
  | {
160
188
  type: "TOOL_CALL_START";
161
189
  toolCallId: string;
162
190
  toolCallName?: string;
163
191
  parentMessageId?: string;
192
+ subagentRunId?: string;
193
+ }
194
+ | {
195
+ type: "TOOL_CALL_ARGS";
196
+ toolCallId: string;
197
+ delta: string;
198
+ subagentRunId?: string;
164
199
  }
165
- | { type: "TOOL_CALL_ARGS"; toolCallId: string; delta: string }
166
- | { type: "TOOL_CALL_END"; toolCallId: string }
200
+ | { type: "TOOL_CALL_END"; toolCallId: string; subagentRunId?: string }
167
201
  | {
168
202
  type: "TOOL_CALL_CHUNK";
169
203
  toolCallId?: string;
170
204
  toolCallName?: string;
171
205
  parentMessageId?: string;
172
206
  delta?: string;
207
+ subagentRunId?: string;
173
208
  }
174
209
  | {
175
210
  type: "TOOL_CALL_RESULT";
@@ -178,6 +213,7 @@ export type AgUiEvent =
178
213
  content: string;
179
214
  role?: "tool";
180
215
  mcpResult?: McpToolCallResult;
216
+ subagentRunId?: string;
181
217
  }
182
218
  | {
183
219
  type: "ACTIVITY_SNAPSHOT";
@@ -185,9 +221,31 @@ export type AgUiEvent =
185
221
  content: Record<string, unknown>;
186
222
  messageId?: string;
187
223
  replace?: boolean;
224
+ subagentRunId?: string;
188
225
  }
189
226
  | { type: "RAW"; event: any; source?: string }
190
227
  | { type: "CUSTOM"; name: string; value: any }
191
228
  | { type: "STATE_SNAPSHOT"; snapshot: any }
192
229
  | { type: "STATE_DELTA"; delta: any[] }
193
- | { type: "MESSAGES_SNAPSHOT"; messages: any[] };
230
+ | { type: "MESSAGES_SNAPSHOT"; messages: any[] }
231
+ | {
232
+ type: "SUBAGENT_STARTED";
233
+ subagentRunId: string;
234
+ name: string;
235
+ description?: string;
236
+ parentSubagentRunId?: string;
237
+ parentToolCallId?: string;
238
+ parentMessageId?: string;
239
+ }
240
+ | {
241
+ type: "SUBAGENT_FINISHED";
242
+ subagentRunId: string;
243
+ result?: unknown;
244
+ outcome?: AgUiSubagentFinishedOutcome;
245
+ }
246
+ | {
247
+ type: "SUBAGENT_ERROR";
248
+ subagentRunId: string;
249
+ message: string;
250
+ code?: string;
251
+ };
@@ -30,13 +30,23 @@ import { readRawResponseSchema } from "./runtime/interrupt-internals";
30
30
 
31
31
  type Subscriber = Record<string, ((payload: any) => void) | undefined>;
32
32
 
33
+ const GATE_PROMPT = "Delete /tmp/a?";
34
+
33
35
  const GATE: AgUiInterrupt = {
34
36
  id: "int-1",
35
37
  reason: "tool_call",
36
38
  toolCallId: "tc-1",
37
- message: "Delete /tmp/a?",
39
+ message: GATE_PROMPT,
38
40
  };
39
41
 
42
+ // The gate's message reaches the renderer as the approval's prompt, so every
43
+ // projection of GATE carries it.
44
+ const gateApproval = (rest: Record<string, unknown> = {}) => ({
45
+ id: "int-1",
46
+ prompt: GATE_PROMPT,
47
+ ...rest,
48
+ });
49
+
40
50
  // Gates the first run on an interrupt and lets every later run settle, so a
41
51
  // resumed run is observable as a second call rather than a second gate.
42
52
  const gatingAgent = (interrupts: readonly AgUiInterrupt[] = [GATE]) => {
@@ -145,7 +155,7 @@ describe("useAgUiRuntime tool approvals", () => {
145
155
  it("gates the tool call a tool_call interrupt names", async () => {
146
156
  const { runtime } = await gatedThread();
147
157
 
148
- expect(allToolCalls(runtime.current)[0]!.approval).toEqual({ id: "int-1" });
158
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual(gateApproval());
149
159
  expect(gatedPart(runtime.current).getState().status).toMatchObject({
150
160
  type: "requires-action",
151
161
  });
@@ -200,10 +210,9 @@ describe("useAgUiRuntime tool approvals", () => {
200
210
  });
201
211
  expect(runAgent).toHaveBeenCalledTimes(1);
202
212
  // 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
- });
213
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual(
214
+ gateApproval({ approved: true }),
215
+ );
207
216
 
208
217
  await act(async () => {
209
218
  gatedPart(runtime.current, "tc-2").respondToToolApproval({
@@ -222,10 +231,9 @@ describe("useAgUiRuntime tool approvals", () => {
222
231
  },
223
232
  ]);
224
233
  // 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
- });
234
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual(
235
+ gateApproval({ approved: true }),
236
+ );
229
237
  expect(
230
238
  allToolCalls(runtime.current).every((part) => part.result === undefined),
231
239
  ).toBe(true);
@@ -255,7 +263,7 @@ describe("useAgUiRuntime tool approvals", () => {
255
263
  // The wire cancelled both, so the gate approved while its sibling was
256
264
  // still open cannot keep displaying an approval that was never sent.
257
265
  expect(allToolCalls(runtime.current).map((part) => part.approval)).toEqual([
258
- { id: "int-1", resolution: "cancelled" },
266
+ gateApproval({ resolution: "cancelled" }),
259
267
  { id: "int-2", resolution: "cancelled" },
260
268
  ]);
261
269
  });
@@ -286,7 +294,7 @@ describe("useAgUiRuntime tool approvals", () => {
286
294
  expect(
287
295
  allToolCalls(runtime.current).find((p) => p.approval?.id === "int-1")
288
296
  ?.approval,
289
- ).toEqual({ id: "int-1", resolution: "cancelled" });
297
+ ).toEqual(gateApproval({ resolution: "cancelled" }));
290
298
  });
291
299
 
292
300
  it("shows a bespoke resolved override as the decision it carried", async () => {
@@ -305,7 +313,7 @@ describe("useAgUiRuntime tool approvals", () => {
305
313
  expect(
306
314
  allToolCalls(runtime.current).find((p) => p.approval?.id === "int-1")
307
315
  ?.approval,
308
- ).toEqual({ id: "int-1", approved: true });
316
+ ).toEqual(gateApproval({ approved: true }));
309
317
  });
310
318
 
311
319
  it("leaves the tool call ungated when the live interrupt carries a rejecting schema", async () => {
@@ -344,7 +352,7 @@ describe("useAgUiRuntime tool approvals", () => {
344
352
  ]);
345
353
  const [interrupt] = interruptsRef.current;
346
354
 
347
- expect(allToolCalls(runtime.current)[0]!.approval).toEqual({ id: "int-1" });
355
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual(gateApproval());
348
356
  expect(interrupt?.responseSchema).toBeUndefined();
349
357
  expect(interrupt && readRawResponseSchema(interrupt)).toBeUndefined();
350
358
  });
@@ -412,7 +420,7 @@ describe("useAgUiRuntime tool approvals", () => {
412
420
  // `{answer:42}` went out for the first gate, so the approval recorded while
413
421
  // its sibling was still open cannot keep displaying a decision never sent.
414
422
  expect(allToolCalls(runtime.current).map((part) => part.approval)).toEqual([
415
- { id: "int-1", resolution: "cancelled" },
423
+ gateApproval({ resolution: "cancelled" }),
416
424
  { id: "int-2", resolution: "cancelled" },
417
425
  ]);
418
426
  });
@@ -469,7 +477,7 @@ describe("useAgUiRuntime tool approvals", () => {
469
477
  type: "requires-action",
470
478
  reason: "interrupt",
471
479
  });
472
- expect(allToolCalls(runtime.current)[0]!.approval).toEqual({ id: "int-1" });
480
+ expect(allToolCalls(runtime.current)[0]!.approval).toEqual(gateApproval());
473
481
  expect(execute).not.toHaveBeenCalled();
474
482
 
475
483
  await act(async () => {
@@ -542,14 +550,14 @@ describe("useAgUiRuntime tool approvals under an unsettled run", () => {
542
550
  content: [{ type: "text", text: "delete it" }],
543
551
  });
544
552
  await waitFor(() =>
545
- expect(allToolCalls(result.current)[0]?.approval).toEqual({
546
- id: "int-1",
547
- }),
553
+ expect(allToolCalls(result.current)[0]?.approval).toEqual(gateApproval()),
548
554
  );
549
555
  expect(result.current.thread.getState().isRunning).toBe(true);
550
556
 
551
557
  await act(async () => {
552
- gatedPart(result.current).respondToToolApproval({ approved: true });
558
+ await expect(
559
+ gatedPart(result.current).respondToToolApproval({ approved: true }),
560
+ ).rejects.toThrow("a run is already in progress");
553
561
  });
554
562
  await waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
555
563
  expect((onError.mock.calls[0]![0] as Error).message).toContain(
@@ -557,7 +565,7 @@ describe("useAgUiRuntime tool approvals under an unsettled run", () => {
557
565
  );
558
566
  // The rejected click must not have recorded a decision, or the retry
559
567
  // below would report the approval as already decided.
560
- expect(allToolCalls(result.current)[0]!.approval).toEqual({ id: "int-1" });
568
+ expect(allToolCalls(result.current)[0]!.approval).toEqual(gateApproval());
561
569
 
562
570
  await act(async () => {
563
571
  release();
@@ -662,7 +670,7 @@ describe("useAgUiRuntime tool approvals across a messages snapshot", () => {
662
670
  const gated = allToolCalls(result.current).filter(
663
671
  (part) => part.approval !== undefined,
664
672
  );
665
- expect(gated.map((part) => part.approval)).toEqual([{ id: "int-1" }]);
673
+ expect(gated.map((part) => part.approval)).toEqual([gateApproval()]);
666
674
 
667
675
  await act(async () => {
668
676
  await gatedPart(result.current).respondToToolApproval({ approved: true });
@@ -795,7 +803,9 @@ describe("useAgUiRuntime approvals on a run that fails after the interrupt", ()
795
803
  await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
796
804
 
797
805
  await act(async () => {
798
- gatedPart(result.current).respondToToolApproval({ approved: true });
806
+ await expect(
807
+ gatedPart(result.current).respondToToolApproval({ approved: true }),
808
+ ).rejects.toThrow("resume failed");
799
809
  });
800
810
  await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
801
811
  await waitFor(() => expect(onError).toHaveBeenCalled());
@@ -879,7 +889,7 @@ describe("useAgUiRuntime approvals that cannot be completed through the seam", (
879
889
  // The interrupt is closed, so the gate must not stay actionable: a click
880
890
  // would find no pending interrupt on the thread.
881
891
  expect(allToolCalls(runtime.current).map((part) => part.approval)).toEqual([
882
- { id: "int-1", resolution: "cancelled" },
892
+ gateApproval({ resolution: "cancelled" }),
883
893
  ]);
884
894
  });
885
895
  });
@@ -0,0 +1,76 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import { cleanup, renderHook, waitFor } from "@testing-library/react";
5
+ import type { ThreadHistoryAdapter } from "@assistant-ui/core";
6
+ import type { HttpAgent } from "@ag-ui/client";
7
+ import { useAgUiRuntime } from "./useAgUiRuntime";
8
+
9
+ const agent = { runAgent: vi.fn(), abortRun: vi.fn() } as unknown as HttpAgent;
10
+
11
+ function createHistory(): ThreadHistoryAdapter {
12
+ return {
13
+ load: vi.fn().mockResolvedValue({
14
+ headId: "restored",
15
+ messages: [
16
+ {
17
+ parentId: null,
18
+ message: {
19
+ id: "restored",
20
+ role: "user" as const,
21
+ content: [{ type: "text" as const, text: "hello" }],
22
+ createdAt: new Date(0),
23
+ metadata: { custom: {} },
24
+ },
25
+ },
26
+ ],
27
+ }),
28
+ append: vi.fn().mockResolvedValue(undefined),
29
+ };
30
+ }
31
+
32
+ afterEach(() => {
33
+ cleanup();
34
+ });
35
+
36
+ describe("useAgUiRuntime history", () => {
37
+ it("loads a history adapter that arrives on a later render", async () => {
38
+ const history = createHistory();
39
+ const { result, rerender } = renderHook(
40
+ ({ history }: { history?: ThreadHistoryAdapter }) =>
41
+ useAgUiRuntime({ agent, adapters: history ? { history } : {} }),
42
+ { initialProps: {} },
43
+ );
44
+
45
+ await waitFor(() =>
46
+ expect(result.current.thread.getState().isLoading).toBe(false),
47
+ );
48
+ expect(history.load).not.toHaveBeenCalled();
49
+ expect(result.current.thread.getState().messages).toEqual([]);
50
+
51
+ rerender({ history });
52
+
53
+ await waitFor(() =>
54
+ expect(
55
+ result.current.thread.getState().messages.map((m) => m.id),
56
+ ).toEqual(["restored"]),
57
+ );
58
+ expect(history.load).toHaveBeenCalledOnce();
59
+ });
60
+
61
+ it("loads once when the adapter is present from the first render", async () => {
62
+ const history = createHistory();
63
+ const { result, rerender } = renderHook(() =>
64
+ useAgUiRuntime({ agent, adapters: { history } }),
65
+ );
66
+
67
+ await waitFor(() =>
68
+ expect(
69
+ result.current.thread.getState().messages.map((m) => m.id),
70
+ ).toEqual(["restored"]),
71
+ );
72
+ rerender();
73
+ rerender();
74
+ expect(history.load).toHaveBeenCalledOnce();
75
+ });
76
+ });
@@ -0,0 +1,186 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import { act, cleanup, render, renderHook } from "@testing-library/react";
5
+ import type { AssistantRuntime, ToolCallMessagePart } from "@assistant-ui/core";
6
+ import {
7
+ AssistantRuntimeProvider,
8
+ useAssistantTool,
9
+ } from "@assistant-ui/core/react";
10
+ import type { HttpAgent } from "@ag-ui/client";
11
+ import { z } from "zod";
12
+ import { useAgUiRuntime } from "./useAgUiRuntime";
13
+ import type { AgUiInterrupt } from "./runtime/types";
14
+
15
+ type Subscriber = Record<string, ((payload: any) => void) | undefined>;
16
+
17
+ const GATE: AgUiInterrupt = {
18
+ id: "int-1",
19
+ reason: "tool_call",
20
+ toolCallId: "tc-1",
21
+ message: "Delete /tmp/a?",
22
+ };
23
+
24
+ const toolCalls = (runtime: AssistantRuntime): ToolCallMessagePart[] =>
25
+ runtime.thread
26
+ .getState()
27
+ .messages.flatMap((message) => message.content as readonly unknown[])
28
+ .filter(
29
+ (part): part is ToolCallMessagePart =>
30
+ (part as ToolCallMessagePart).type === "tool-call",
31
+ );
32
+
33
+ const flush = () =>
34
+ act(async () => void (await new Promise((r) => setTimeout(r, 0))));
35
+
36
+ /**
37
+ * Holds the run open after the tool call's arguments complete, so the window
38
+ * between complete args and the run's outcome is observable as its own render
39
+ * rather than coalescing with the tail.
40
+ */
41
+ const renderHeldRun = async (tail: (subscriber: Subscriber) => void) => {
42
+ let releaseTail!: () => void;
43
+ const held = new Promise<void>((r) => (releaseTail = r));
44
+ let argsDelivered!: () => void;
45
+ const argsDeliveredP = new Promise<void>((r) => (argsDelivered = r));
46
+
47
+ const runAgent = vi.fn(async (_input: unknown, subscriber: Subscriber) => {
48
+ if (runAgent.mock.calls.length > 1) {
49
+ subscriber.onRunFinalized?.(undefined);
50
+ return;
51
+ }
52
+ subscriber.onToolCallStartEvent?.({
53
+ event: {
54
+ type: "TOOL_CALL_START",
55
+ toolCallId: "tc-1",
56
+ toolCallName: "delete_file",
57
+ },
58
+ });
59
+ subscriber.onToolCallArgsEvent?.({
60
+ event: {
61
+ type: "TOOL_CALL_ARGS",
62
+ toolCallId: "tc-1",
63
+ delta: '{"path":"/tmp/a"}',
64
+ },
65
+ });
66
+ subscriber.onToolCallEndEvent?.({
67
+ event: { type: "TOOL_CALL_END", toolCallId: "tc-1" },
68
+ });
69
+ argsDelivered();
70
+ await held;
71
+ tail(subscriber);
72
+ subscriber.onRunFinalized?.(undefined);
73
+ });
74
+ const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
75
+
76
+ const execute = vi.fn(async () => ({ deleted: true }));
77
+ const DeleteFileTool = () => {
78
+ useAssistantTool({
79
+ toolName: "delete_file",
80
+ description: "delete a file",
81
+ parameters: z.object({ path: z.string() }),
82
+ execute,
83
+ });
84
+ return null;
85
+ };
86
+
87
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
88
+ render(
89
+ <AssistantRuntimeProvider runtime={result.current}>
90
+ <DeleteFileTool />
91
+ </AssistantRuntimeProvider>,
92
+ );
93
+ await flush();
94
+
95
+ await act(async () => {
96
+ void result.current.thread.append({
97
+ role: "user",
98
+ content: [{ type: "text", text: "delete it" }],
99
+ });
100
+ await argsDeliveredP;
101
+ });
102
+ await flush();
103
+
104
+ return {
105
+ runtime: result,
106
+ execute,
107
+ settle: async () => {
108
+ await act(async () => {
109
+ releaseTail();
110
+ await new Promise((r) => setTimeout(r, 0));
111
+ });
112
+ await flush();
113
+ },
114
+ };
115
+ };
116
+
117
+ afterEach(() => cleanup());
118
+
119
+ describe("useAgUiRuntime frontend tool deferral", () => {
120
+ it("does not run a frontend execute on a call the provider is about to gate", async () => {
121
+ const { runtime, execute, settle } = await renderHeldRun((subscriber) => {
122
+ subscriber.onRunFinishedEvent?.({
123
+ event: {
124
+ type: "RUN_FINISHED",
125
+ runId: "run-1",
126
+ outcome: { type: "interrupt", interrupts: [GATE] },
127
+ },
128
+ });
129
+ });
130
+
131
+ const inWindow = toolCalls(runtime.current)[0]!;
132
+ expect(inWindow.argsText).toBe('{"path":"/tmp/a"}');
133
+ expect(inWindow.approval).toBeUndefined();
134
+ expect(inWindow.result).toBeUndefined();
135
+ expect(execute).not.toHaveBeenCalled();
136
+
137
+ await settle();
138
+
139
+ const gated = toolCalls(runtime.current)[0]!;
140
+ expect(gated.approval).toEqual({
141
+ id: "int-1",
142
+ prompt: "Delete /tmp/a?",
143
+ });
144
+ expect(gated.result).toBeUndefined();
145
+ expect(execute).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it("does not run a frontend execute on a call the agent answers itself", async () => {
149
+ const { runtime, execute, settle } = await renderHeldRun((subscriber) => {
150
+ subscriber.onToolCallResultEvent?.({
151
+ event: {
152
+ type: "TOOL_CALL_RESULT",
153
+ messageId: "m-result",
154
+ toolCallId: "tc-1",
155
+ content: "server did the delete",
156
+ },
157
+ });
158
+ subscriber.onRunFinishedEvent?.({
159
+ event: { type: "RUN_FINISHED", runId: "run-1" },
160
+ });
161
+ });
162
+
163
+ expect(toolCalls(runtime.current)[0]!.result).toBeUndefined();
164
+ expect(execute).not.toHaveBeenCalled();
165
+
166
+ await settle();
167
+
168
+ expect(toolCalls(runtime.current)[0]!.result).toBe("server did the delete");
169
+ expect(execute).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it("runs a frontend execute once the run settles with no outcome for the call", async () => {
173
+ const { runtime, execute, settle } = await renderHeldRun((subscriber) => {
174
+ subscriber.onRunFinishedEvent?.({
175
+ event: { type: "RUN_FINISHED", runId: "run-1" },
176
+ });
177
+ });
178
+
179
+ expect(execute).not.toHaveBeenCalled();
180
+
181
+ await settle();
182
+
183
+ expect(execute).toHaveBeenCalledTimes(1);
184
+ expect(toolCalls(runtime.current)[0]!.result).toEqual({ deleted: true });
185
+ });
186
+ });
@@ -81,14 +81,16 @@ export function useAgUiRuntime(
81
81
  }
82
82
 
83
83
  const core = coreRef.current;
84
- core.updateOptions({
85
- agent: options.agent,
86
- logger,
87
- showThinking: options.showThinking ?? true,
88
- autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
89
- ...(options.onError && { onError: options.onError }),
90
- ...(options.onCancel && { onCancel: options.onCancel }),
91
- ...(historyAdapter && { history: historyAdapter }),
84
+ useEffect(() => {
85
+ core.updateOptions({
86
+ agent: options.agent,
87
+ logger,
88
+ showThinking: options.showThinking ?? true,
89
+ autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
90
+ ...(options.onError && { onError: options.onError }),
91
+ ...(options.onCancel && { onCancel: options.onCancel }),
92
+ ...(historyAdapter && { history: historyAdapter }),
93
+ });
92
94
  });
93
95
 
94
96
  const [toolStatuses, setToolStatuses] = useState<
@@ -228,7 +230,7 @@ export function useAgUiRuntime(
228
230
  isLoading: core.isLoading,
229
231
  messageRepository: core.getMessageRepository(),
230
232
  state: core.getState(),
231
- isRunning,
233
+ isRunning: core.isRunning(),
232
234
  extras: agUiExtras.provide({
233
235
  interrupts:
234
236
  core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,
@@ -258,6 +260,7 @@ export function useAgUiRuntime(
258
260
  onRespondToToolApproval: (options) =>
259
261
  core.respondToToolApproval(options).catch((error: unknown) => {
260
262
  core.reportError(error);
263
+ throw error;
261
264
  }),
262
265
  onResume: (config) => core.resume(config),
263
266
  setMessages: (messages: readonly ThreadMessage[]) =>