@assistant-ui/react-ag-ui 0.0.57 → 0.0.59
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/index.d.ts +2 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -8
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +118 -86
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +34 -11
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +385 -119
- 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 +67 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +15 -5
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +9 -9
- package/src/index.ts +1 -0
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +204 -120
- package/src/runtime/adapter/conversions.ts +87 -17
- package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
- package/src/runtime/adapter/run-aggregator.ts +595 -139
- 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 +96 -12
- package/src/useAgUiRuntime.approval.test.tsx +34 -24
- package/src/useAgUiRuntime.queue.test.tsx +54 -0
- package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
- package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
- package/src/useAgUiRuntime.ts +15 -3
package/src/runtime/types.ts
CHANGED
|
@@ -56,10 +56,36 @@ export type UseAgUiRuntimeAdapters = {
|
|
|
56
56
|
threadList?: UseAgUiThreadListAdapter;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
export type AgUiResumeTranscript = "full" | "appended";
|
|
60
|
+
|
|
59
61
|
export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
|
|
60
62
|
agent: AbstractAgent;
|
|
61
63
|
logger?: Partial<Logger>;
|
|
62
64
|
showThinking?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* What `messages` carries on a resume run, meaning a `RunAgentInput` that
|
|
67
|
+
* also carries `resume`. The AG-UI interrupt spec leaves this undefined: its
|
|
68
|
+
* contract rules constrain the thread id, interrupt coverage, idempotency,
|
|
69
|
+
* and expiry, and none of them mentions `messages`. Hosts therefore disagree,
|
|
70
|
+
* and both readings are conformant.
|
|
71
|
+
*
|
|
72
|
+
* `"full"` sends the whole thread, which is what `@ag-ui/client` itself does.
|
|
73
|
+
* Hosts that resume from a checkpoint ignore the transcript, and hosts that
|
|
74
|
+
* rebuild the interrupted run from it need it.
|
|
75
|
+
*
|
|
76
|
+
* `"appended"` sends only what was appended locally after the interrupted
|
|
77
|
+
* assistant message, which is nothing for an approval or a denial and the new
|
|
78
|
+
* user turn for `steerAway`. Choose it for a host that owns the thread and
|
|
79
|
+
* seeds a resume request from its own stored snapshot, because such a host
|
|
80
|
+
* appends the request body to that snapshot and a re-sent transcript
|
|
81
|
+
* duplicates the interrupted turn in its stored history. A host that rebuilds
|
|
82
|
+
* the run from `messages` has nothing to resume from under `"appended"`, and
|
|
83
|
+
* a host that derives its outgoing `MESSAGES_SNAPSHOT` from the request body
|
|
84
|
+
* emits a truncated one.
|
|
85
|
+
*
|
|
86
|
+
* Defaults to `"full"`.
|
|
87
|
+
*/
|
|
88
|
+
resumeTranscript?: AgUiResumeTranscript | undefined;
|
|
63
89
|
/**
|
|
64
90
|
* When the user sends, edits, or reloads a message while client-side tool
|
|
65
91
|
* calls are still pending, automatically cancel the unresolved tool calls
|
|
@@ -127,6 +153,10 @@ export type AgUiRunFinishedOutcome =
|
|
|
127
153
|
| { type: "success" }
|
|
128
154
|
| { type: "interrupt"; interrupts: AgUiInterrupt[] };
|
|
129
155
|
|
|
156
|
+
export type AgUiSubagentFinishedOutcome =
|
|
157
|
+
| { type: "success" }
|
|
158
|
+
| { type: "suspended"; interruptIds?: string[] };
|
|
159
|
+
|
|
130
160
|
export type AgUiEvent =
|
|
131
161
|
| { type: "RUN_STARTED"; runId: string }
|
|
132
162
|
| {
|
|
@@ -136,40 +166,71 @@ export type AgUiEvent =
|
|
|
136
166
|
}
|
|
137
167
|
| { type: "RUN_CANCELLED"; runId?: string }
|
|
138
168
|
| { type: "RUN_ERROR"; message?: string; code?: string }
|
|
139
|
-
| { type: "TEXT_MESSAGE_START"; messageId?: string }
|
|
140
|
-
| {
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
| { type: "TEXT_MESSAGE_START"; messageId?: string; subagentRunId?: string }
|
|
170
|
+
| {
|
|
171
|
+
type: "TEXT_MESSAGE_CONTENT";
|
|
172
|
+
messageId?: string;
|
|
173
|
+
delta: string;
|
|
174
|
+
subagentRunId?: string;
|
|
175
|
+
}
|
|
176
|
+
| { type: "TEXT_MESSAGE_END"; messageId?: string; subagentRunId?: string }
|
|
177
|
+
| {
|
|
178
|
+
type: "TEXT_MESSAGE_CHUNK";
|
|
179
|
+
messageId?: string;
|
|
180
|
+
delta: string;
|
|
181
|
+
subagentRunId?: string;
|
|
182
|
+
}
|
|
143
183
|
| { type: "THINKING_START"; title?: string }
|
|
144
184
|
| { type: "THINKING_TEXT_MESSAGE_START" }
|
|
145
185
|
| { type: "THINKING_TEXT_MESSAGE_CONTENT"; delta: string }
|
|
146
186
|
| { type: "THINKING_TEXT_MESSAGE_END" }
|
|
147
187
|
| { type: "THINKING_END" }
|
|
148
|
-
| { type: "REASONING_START"; messageId?: string }
|
|
149
|
-
| {
|
|
150
|
-
|
|
151
|
-
|
|
188
|
+
| { type: "REASONING_START"; messageId?: string; subagentRunId?: string }
|
|
189
|
+
| {
|
|
190
|
+
type: "REASONING_MESSAGE_START";
|
|
191
|
+
messageId?: string;
|
|
192
|
+
subagentRunId?: string;
|
|
193
|
+
}
|
|
194
|
+
| {
|
|
195
|
+
type: "REASONING_MESSAGE_CONTENT";
|
|
196
|
+
messageId?: string;
|
|
197
|
+
delta: string;
|
|
198
|
+
subagentRunId?: string;
|
|
199
|
+
}
|
|
200
|
+
| {
|
|
201
|
+
type: "REASONING_MESSAGE_END";
|
|
202
|
+
messageId?: string;
|
|
203
|
+
subagentRunId?: string;
|
|
204
|
+
}
|
|
152
205
|
| {
|
|
153
206
|
type: "REASONING_ENCRYPTED_VALUE";
|
|
154
207
|
subtype: "message" | "tool-call";
|
|
155
208
|
entityId: string;
|
|
156
209
|
encryptedValue: string;
|
|
210
|
+
subagentRunId?: string;
|
|
157
211
|
}
|
|
158
|
-
| { type: "REASONING_END"; messageId?: string }
|
|
212
|
+
| { type: "REASONING_END"; messageId?: string; subagentRunId?: string }
|
|
159
213
|
| {
|
|
160
214
|
type: "TOOL_CALL_START";
|
|
161
215
|
toolCallId: string;
|
|
162
216
|
toolCallName?: string;
|
|
163
217
|
parentMessageId?: string;
|
|
218
|
+
subagentRunId?: string;
|
|
219
|
+
}
|
|
220
|
+
| {
|
|
221
|
+
type: "TOOL_CALL_ARGS";
|
|
222
|
+
toolCallId: string;
|
|
223
|
+
delta: string;
|
|
224
|
+
subagentRunId?: string;
|
|
164
225
|
}
|
|
165
|
-
| { type: "
|
|
166
|
-
| { type: "TOOL_CALL_END"; toolCallId: string }
|
|
226
|
+
| { type: "TOOL_CALL_END"; toolCallId: string; subagentRunId?: string }
|
|
167
227
|
| {
|
|
168
228
|
type: "TOOL_CALL_CHUNK";
|
|
169
229
|
toolCallId?: string;
|
|
170
230
|
toolCallName?: string;
|
|
171
231
|
parentMessageId?: string;
|
|
172
232
|
delta?: string;
|
|
233
|
+
subagentRunId?: string;
|
|
173
234
|
}
|
|
174
235
|
| {
|
|
175
236
|
type: "TOOL_CALL_RESULT";
|
|
@@ -178,6 +239,7 @@ export type AgUiEvent =
|
|
|
178
239
|
content: string;
|
|
179
240
|
role?: "tool";
|
|
180
241
|
mcpResult?: McpToolCallResult;
|
|
242
|
+
subagentRunId?: string;
|
|
181
243
|
}
|
|
182
244
|
| {
|
|
183
245
|
type: "ACTIVITY_SNAPSHOT";
|
|
@@ -185,9 +247,31 @@ export type AgUiEvent =
|
|
|
185
247
|
content: Record<string, unknown>;
|
|
186
248
|
messageId?: string;
|
|
187
249
|
replace?: boolean;
|
|
250
|
+
subagentRunId?: string;
|
|
188
251
|
}
|
|
189
252
|
| { type: "RAW"; event: any; source?: string }
|
|
190
253
|
| { type: "CUSTOM"; name: string; value: any }
|
|
191
254
|
| { type: "STATE_SNAPSHOT"; snapshot: any }
|
|
192
255
|
| { type: "STATE_DELTA"; delta: any[] }
|
|
193
|
-
| { type: "MESSAGES_SNAPSHOT"; messages: any[] }
|
|
256
|
+
| { type: "MESSAGES_SNAPSHOT"; messages: any[] }
|
|
257
|
+
| {
|
|
258
|
+
type: "SUBAGENT_STARTED";
|
|
259
|
+
subagentRunId: string;
|
|
260
|
+
name: string;
|
|
261
|
+
description?: string;
|
|
262
|
+
parentSubagentRunId?: string;
|
|
263
|
+
parentToolCallId?: string;
|
|
264
|
+
parentMessageId?: string;
|
|
265
|
+
}
|
|
266
|
+
| {
|
|
267
|
+
type: "SUBAGENT_FINISHED";
|
|
268
|
+
subagentRunId: string;
|
|
269
|
+
result?: unknown;
|
|
270
|
+
outcome?: AgUiSubagentFinishedOutcome;
|
|
271
|
+
}
|
|
272
|
+
| {
|
|
273
|
+
type: "SUBAGENT_ERROR";
|
|
274
|
+
subagentRunId: string;
|
|
275
|
+
message: string;
|
|
276
|
+
code?: string;
|
|
277
|
+
};
|
|
@@ -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
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
4
|
+
import { startTransition, useEffect } from "react";
|
|
4
5
|
import {
|
|
5
6
|
act,
|
|
6
7
|
cleanup,
|
|
@@ -409,6 +410,59 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
|
|
|
409
410
|
);
|
|
410
411
|
});
|
|
411
412
|
|
|
413
|
+
it("does not clear the committed queue from a suspended render", async () => {
|
|
414
|
+
const { agent, runAgent } = gatedAgent();
|
|
415
|
+
let runtime: AssistantRuntime | undefined;
|
|
416
|
+
const never = new Promise<void>(() => {});
|
|
417
|
+
|
|
418
|
+
const Harness = ({
|
|
419
|
+
enabled,
|
|
420
|
+
suspend,
|
|
421
|
+
}: {
|
|
422
|
+
enabled: boolean;
|
|
423
|
+
suspend: boolean;
|
|
424
|
+
}) => {
|
|
425
|
+
const nextRuntime = useAgUiRuntime({
|
|
426
|
+
agent,
|
|
427
|
+
unstable_enableMessageQueue: enabled,
|
|
428
|
+
});
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
runtime = nextRuntime;
|
|
431
|
+
}, [nextRuntime]);
|
|
432
|
+
if (suspend) throw never;
|
|
433
|
+
return null;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const view = render(<Harness enabled suspend={false} />);
|
|
437
|
+
await waitFor(() => expect(runtime).toBeDefined());
|
|
438
|
+
mount(runtime!);
|
|
439
|
+
|
|
440
|
+
await act(async () => {
|
|
441
|
+
await runtime!.thread.append({
|
|
442
|
+
role: "user",
|
|
443
|
+
content: [{ type: "text", text: "first" }],
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
|
|
447
|
+
|
|
448
|
+
await act(async () => {
|
|
449
|
+
await runtime!.thread.append({
|
|
450
|
+
role: "user",
|
|
451
|
+
content: [{ type: "text", text: "second" }],
|
|
452
|
+
parentId: runtime!.thread.getState().messages.at(-1)?.id ?? null,
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
await waitFor(() =>
|
|
456
|
+
expect(screen.getByTestId("queued").textContent).toBe("second"),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
act(() => {
|
|
460
|
+
startTransition(() => view.rerender(<Harness enabled={false} suspend />));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(screen.getByTestId("queued").textContent).toBe("second");
|
|
464
|
+
});
|
|
465
|
+
|
|
412
466
|
it("leaves the queue capability off when the flag is not set", () => {
|
|
413
467
|
const { agent } = gatedAgent();
|
|
414
468
|
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
6
|
+
import type { HttpAgent } from "@ag-ui/client";
|
|
7
|
+
import type { ThreadMessage } from "@assistant-ui/core";
|
|
8
|
+
import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
|
|
9
|
+
import type { UseAgUiThreadListAdapter } from "./runtime/types";
|
|
10
|
+
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
11
|
+
|
|
12
|
+
type ThreadLoad = Awaited<
|
|
13
|
+
ReturnType<NonNullable<UseAgUiThreadListAdapter["onSwitchToThread"]>>
|
|
14
|
+
>;
|
|
15
|
+
|
|
16
|
+
function deferred<T>() {
|
|
17
|
+
let resolve!: (value: T) => void;
|
|
18
|
+
const promise = new Promise<T>((res) => {
|
|
19
|
+
resolve = res;
|
|
20
|
+
});
|
|
21
|
+
return { promise, resolve };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function message(id: string): ThreadMessage {
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
role: "user",
|
|
28
|
+
content: [{ type: "text", text: id }],
|
|
29
|
+
attachments: [],
|
|
30
|
+
createdAt: new Date(0),
|
|
31
|
+
metadata: { custom: {} },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderRuntime(
|
|
36
|
+
load: (id: string) => Promise<ThreadLoad>,
|
|
37
|
+
create: () => Promise<void> = async () => {},
|
|
38
|
+
) {
|
|
39
|
+
const agent = {
|
|
40
|
+
runAgent: vi.fn(),
|
|
41
|
+
abortRun: vi.fn(),
|
|
42
|
+
} as unknown as HttpAgent;
|
|
43
|
+
return renderHook(() => {
|
|
44
|
+
const [threadId, setThreadId] = useState("initial");
|
|
45
|
+
return useAgUiRuntime({
|
|
46
|
+
agent,
|
|
47
|
+
adapters: {
|
|
48
|
+
threadList: {
|
|
49
|
+
threadId,
|
|
50
|
+
onSwitchToThread: (id) => {
|
|
51
|
+
setThreadId(id);
|
|
52
|
+
return load(id);
|
|
53
|
+
},
|
|
54
|
+
onSwitchToNewThread: () => {
|
|
55
|
+
setThreadId("thread-new");
|
|
56
|
+
return create();
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
cleanup();
|
|
66
|
+
vi.restoreAllMocks();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("useAgUiRuntime thread switching", () => {
|
|
70
|
+
it.each([undefined, { owner: "thread-a" }])(
|
|
71
|
+
"ignores an older load and its resume request with state %j",
|
|
72
|
+
async (state) => {
|
|
73
|
+
const resume = vi
|
|
74
|
+
.spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
|
|
75
|
+
.mockResolvedValue();
|
|
76
|
+
const first = deferred<ThreadLoad>();
|
|
77
|
+
const second = deferred<ThreadLoad>();
|
|
78
|
+
const { result } = renderRuntime((id) =>
|
|
79
|
+
id === "thread-a" ? first.promise : second.promise,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
let switchA!: Promise<void>;
|
|
83
|
+
let switchB!: Promise<void>;
|
|
84
|
+
act(() => {
|
|
85
|
+
switchA = result.current.threads.switchToThread("thread-a");
|
|
86
|
+
switchB = result.current.threads.switchToThread("thread-b");
|
|
87
|
+
});
|
|
88
|
+
await act(async () => {
|
|
89
|
+
second.resolve({
|
|
90
|
+
messages: [message("thread-b")],
|
|
91
|
+
state: { owner: "thread-b" },
|
|
92
|
+
});
|
|
93
|
+
await switchB;
|
|
94
|
+
});
|
|
95
|
+
await act(async () => {
|
|
96
|
+
first.resolve({
|
|
97
|
+
messages: [message("thread-a")],
|
|
98
|
+
...(state !== undefined && { state }),
|
|
99
|
+
unstable_resume: true,
|
|
100
|
+
});
|
|
101
|
+
await switchA;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
|
|
105
|
+
expect(
|
|
106
|
+
result.current.thread.getState().messages.map((m) => m.id),
|
|
107
|
+
).toEqual(["thread-b"]);
|
|
108
|
+
expect(result.current.thread.getState().state).toEqual({
|
|
109
|
+
owner: "thread-b",
|
|
110
|
+
});
|
|
111
|
+
expect(resume).not.toHaveBeenCalled();
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it("ignores a load superseded by creating a new thread", async () => {
|
|
116
|
+
const resume = vi
|
|
117
|
+
.spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
|
|
118
|
+
.mockResolvedValue();
|
|
119
|
+
const load = deferred<ThreadLoad>();
|
|
120
|
+
const { result } = renderRuntime(() => load.promise);
|
|
121
|
+
|
|
122
|
+
let switchA!: Promise<void>;
|
|
123
|
+
act(() => {
|
|
124
|
+
switchA = result.current.threads.switchToThread("thread-a");
|
|
125
|
+
});
|
|
126
|
+
await act(async () => {
|
|
127
|
+
await result.current.threads.switchToNewThread();
|
|
128
|
+
});
|
|
129
|
+
const newThreadState = result.current.thread.getState().state;
|
|
130
|
+
await act(async () => {
|
|
131
|
+
load.resolve({
|
|
132
|
+
messages: [message("thread-a")],
|
|
133
|
+
state: { owner: "thread-a" },
|
|
134
|
+
unstable_resume: true,
|
|
135
|
+
});
|
|
136
|
+
await switchA;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-new");
|
|
140
|
+
expect(result.current.thread.getState().messages).toEqual([]);
|
|
141
|
+
expect(result.current.thread.getState().state).toEqual(newThreadState);
|
|
142
|
+
expect(resume).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("does not clear a newer thread when an older creation finishes", async () => {
|
|
146
|
+
const creation = deferred<void>();
|
|
147
|
+
const { result } = renderRuntime(
|
|
148
|
+
async () => ({
|
|
149
|
+
messages: [message("thread-b")],
|
|
150
|
+
state: { owner: "thread-b" },
|
|
151
|
+
}),
|
|
152
|
+
() => creation.promise,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
let switchNew!: Promise<void>;
|
|
156
|
+
act(() => {
|
|
157
|
+
switchNew = result.current.threads.switchToNewThread();
|
|
158
|
+
});
|
|
159
|
+
await act(async () => {
|
|
160
|
+
await result.current.threads.switchToThread("thread-b");
|
|
161
|
+
});
|
|
162
|
+
await act(async () => {
|
|
163
|
+
creation.resolve();
|
|
164
|
+
await switchNew;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
|
|
168
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
169
|
+
"thread-b",
|
|
170
|
+
]);
|
|
171
|
+
expect(result.current.thread.getState().state).toEqual({
|
|
172
|
+
owner: "thread-b",
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("applies the current load and resumes it", async () => {
|
|
177
|
+
const resume = vi
|
|
178
|
+
.spyOn(AgUiThreadRuntimeCore.prototype, "resumeInFlightRun")
|
|
179
|
+
.mockResolvedValue();
|
|
180
|
+
const messages = [message("thread-a")];
|
|
181
|
+
const { result } = renderRuntime(async () => ({
|
|
182
|
+
messages,
|
|
183
|
+
state: { owner: "thread-a" },
|
|
184
|
+
unstable_resume: true,
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await result.current.threads.switchToThread("thread-a");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
192
|
+
"thread-a",
|
|
193
|
+
]);
|
|
194
|
+
expect(result.current.thread.getState().state).toEqual({
|
|
195
|
+
owner: "thread-a",
|
|
196
|
+
});
|
|
197
|
+
expect(resume).toHaveBeenCalledExactlyOnceWith(messages);
|
|
198
|
+
});
|
|
199
|
+
});
|