@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.
Files changed (46) hide show
  1. package/README.md +16 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/runtime/AgUiThreadRuntimeCore.d.ts +19 -8
  4. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  5. package/dist/runtime/AgUiThreadRuntimeCore.js +118 -86
  6. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +34 -11
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +13 -3
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +385 -119
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +4 -1
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/adapter/tool-approval.d.ts +3 -1
  19. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  20. package/dist/runtime/adapter/tool-approval.js +11 -12
  21. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  22. package/dist/runtime/event-parser.d.ts.map +1 -1
  23. package/dist/runtime/event-parser.js +176 -45
  24. package/dist/runtime/event-parser.js.map +1 -1
  25. package/dist/runtime/types.d.ts +67 -1
  26. package/dist/runtime/types.d.ts.map +1 -1
  27. package/dist/useAgUiRuntime.d.ts.map +1 -1
  28. package/dist/useAgUiRuntime.js +15 -5
  29. package/dist/useAgUiRuntime.js.map +1 -1
  30. package/package.json +9 -9
  31. package/src/index.ts +1 -0
  32. package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
  33. package/src/runtime/AgUiThreadRuntimeCore.ts +204 -120
  34. package/src/runtime/adapter/conversions.ts +87 -17
  35. package/src/runtime/adapter/run-aggregator.approval.test.ts +128 -0
  36. package/src/runtime/adapter/run-aggregator.ts +595 -139
  37. package/src/runtime/adapter/subscriber.ts +10 -1
  38. package/src/runtime/adapter/tool-approval.test.ts +75 -0
  39. package/src/runtime/adapter/tool-approval.ts +17 -12
  40. package/src/runtime/event-parser.ts +216 -36
  41. package/src/runtime/types.ts +96 -12
  42. package/src/useAgUiRuntime.approval.test.tsx +34 -24
  43. package/src/useAgUiRuntime.queue.test.tsx +54 -0
  44. package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
  45. package/src/useAgUiRuntime.toolDeferral.test.tsx +186 -0
  46. package/src/useAgUiRuntime.ts +15 -3
@@ -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
- | { type: "TEXT_MESSAGE_CONTENT"; messageId?: string; delta: string }
141
- | { type: "TEXT_MESSAGE_END"; messageId?: string }
142
- | { type: "TEXT_MESSAGE_CHUNK"; delta: string }
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
- | { type: "REASONING_MESSAGE_START"; messageId?: string }
150
- | { type: "REASONING_MESSAGE_CONTENT"; messageId?: string; delta: string }
151
- | { type: "REASONING_MESSAGE_END"; messageId?: string }
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: "TOOL_CALL_ARGS"; toolCallId: string; delta: string }
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: "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
  });
@@ -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
+ });