@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.
- package/README.md +16 -0
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +5 -17
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +133 -234
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +54 -25
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +31 -4
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +419 -123
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +9 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +4 -1
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/adapter/tool-approval.d.ts +3 -1
- package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
- package/dist/runtime/adapter/tool-approval.js +11 -12
- package/dist/runtime/adapter/tool-approval.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +176 -45
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +42 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +12 -9
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +10 -10
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +190 -319
- package/src/runtime/adapter/conversions.ts +128 -36
- package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
- package/src/runtime/adapter/run-aggregator.ts +670 -148
- package/src/runtime/adapter/subscriber.ts +10 -1
- package/src/runtime/adapter/tool-approval.test.ts +75 -0
- package/src/runtime/adapter/tool-approval.ts +17 -12
- package/src/runtime/event-parser.ts +216 -36
- package/src/runtime/types.ts +70 -12
- package/src/useAgUiRuntime.approval.test.tsx +34 -24
- package/src/useAgUiRuntime.history.test.tsx +76 -0
- package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
- package/src/useAgUiRuntime.ts +12 -9
package/src/runtime/types.ts
CHANGED
|
@@ -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
|
-
| {
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
| {
|
|
150
|
-
|
|
151
|
-
|
|
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: "
|
|
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:
|
|
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(
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
{
|
|
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({
|
|
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({
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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([
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
});
|
package/src/useAgUiRuntime.ts
CHANGED
|
@@ -81,14 +81,16 @@ export function useAgUiRuntime(
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
const core = coreRef.current;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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[]) =>
|