@assistant-ui/react-ag-ui 0.1.0 → 0.1.2

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 (65) hide show
  1. package/README.md +2 -0
  2. package/dist/agUiExtras.js +1 -2
  3. package/dist/hooks.js +1 -2
  4. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  5. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  6. package/dist/runtime/AgUiThreadController.js +565 -89
  7. package/dist/runtime/AgUiThreadController.js.map +1 -1
  8. package/dist/runtime/adapter/conversions.d.ts +9 -1
  9. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  10. package/dist/runtime/adapter/conversions.js +194 -12
  11. package/dist/runtime/adapter/conversions.js.map +1 -1
  12. package/dist/runtime/adapter/replay-content.d.ts +9 -0
  13. package/dist/runtime/adapter/replay-content.d.ts.map +1 -0
  14. package/dist/runtime/adapter/replay-content.js +185 -0
  15. package/dist/runtime/adapter/replay-content.js.map +1 -0
  16. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  17. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  18. package/dist/runtime/adapter/run-aggregator.js +35 -14
  19. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  20. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  21. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  22. package/dist/runtime/adapter/subscriber.js +2 -10
  23. package/dist/runtime/adapter/subscriber.js.map +1 -1
  24. package/dist/runtime/adapter/tool-approval.d.ts +2 -0
  25. package/dist/runtime/adapter/tool-approval.d.ts.map +1 -1
  26. package/dist/runtime/adapter/tool-approval.js +23 -6
  27. package/dist/runtime/adapter/tool-approval.js.map +1 -1
  28. package/dist/runtime/agUiThreadState.d.ts +1 -1
  29. package/dist/runtime/agUiThreadState.d.ts.map +1 -1
  30. package/dist/runtime/agUiThreadState.js +3 -4
  31. package/dist/runtime/agUiThreadState.js.map +1 -1
  32. package/dist/runtime/event-parser.d.ts.map +1 -1
  33. package/dist/runtime/event-parser.js +2 -2
  34. package/dist/runtime/event-parser.js.map +1 -1
  35. package/dist/runtime/interrupt-internals.js +1 -2
  36. package/dist/runtime/logger.js +1 -2
  37. package/dist/runtime/mcp-tool-result.js +1 -2
  38. package/dist/runtime/types.d.ts +2 -0
  39. package/dist/runtime/types.d.ts.map +1 -1
  40. package/dist/runtime/types.js +3 -3
  41. package/dist/runtime/types.js.map +1 -1
  42. package/dist/useAgUiRuntime.js +1 -2
  43. package/package.json +6 -6
  44. package/src/runtime/AgUiThreadController.deferred-results.test.ts +548 -0
  45. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  46. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  47. package/src/runtime/AgUiThreadController.replay.test.ts +3939 -0
  48. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +167 -0
  49. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  50. package/src/runtime/AgUiThreadController.test.ts +119 -0
  51. package/src/runtime/AgUiThreadController.ts +1184 -136
  52. package/src/runtime/adapter/conversions.content.test.ts +242 -1
  53. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  54. package/src/runtime/adapter/conversions.ts +349 -28
  55. package/src/runtime/adapter/replay-content.test.ts +235 -0
  56. package/src/runtime/adapter/replay-content.ts +306 -0
  57. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  58. package/src/runtime/adapter/run-aggregator.ts +52 -9
  59. package/src/runtime/adapter/subscriber.test.ts +1 -1
  60. package/src/runtime/adapter/subscriber.ts +1 -6
  61. package/src/runtime/adapter/tool-approval.ts +41 -3
  62. package/src/runtime/agUiThreadState.test.ts +21 -2
  63. package/src/runtime/agUiThreadState.ts +12 -3
  64. package/src/runtime/event-parser.ts +3 -0
  65. package/src/runtime/types.ts +5 -0
@@ -0,0 +1,854 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AbstractAgent } from "@ag-ui/client";
3
+ import type { AppendMessage, ThreadMessage } from "@assistant-ui/core";
4
+ import { AgUiThreadController } from "./AgUiThreadController";
5
+ import type { UseAgUiRuntimeOptions } from "./types";
6
+ import { makeLogger } from "./logger";
7
+
8
+ type Subscriber = Record<
9
+ string,
10
+ ((payload: { event: unknown }) => void) | undefined
11
+ >;
12
+ type Event = Record<string, unknown>;
13
+
14
+ const handlerName = (type: string) =>
15
+ `on${type
16
+ .toLowerCase()
17
+ .split("_")
18
+ .map((word) => word[0]?.toUpperCase() + word.slice(1))
19
+ .join("")}Event`;
20
+
21
+ async function run(
22
+ events: readonly Event[],
23
+ joinStrategy?: UseAgUiRuntimeOptions["joinStrategy"],
24
+ ) {
25
+ let pending = events;
26
+ const agent = {
27
+ runAgent: vi.fn(
28
+ async (input: { runId: string }, subscriber: Subscriber) => {
29
+ const body = pending;
30
+ pending = [];
31
+ for (const event of [
32
+ { type: "RUN_STARTED", runId: input.runId },
33
+ ...body,
34
+ { type: "RUN_FINISHED", runId: input.runId },
35
+ ]) {
36
+ subscriber[handlerName(event.type as string)]?.({ event });
37
+ await new Promise((resolve) => setTimeout(resolve, 0));
38
+ }
39
+ },
40
+ ),
41
+ abortRun: vi.fn(),
42
+ } as unknown as AbstractAgent;
43
+ const core = new AgUiThreadController({
44
+ agent,
45
+ logger: makeLogger(),
46
+ showThinking: true,
47
+ ...(joinStrategy ? { joinStrategy } : {}),
48
+ });
49
+ await core.append({
50
+ parentId: null,
51
+ role: "user",
52
+ content: [{ type: "text", text: "u" }],
53
+ startRun: true,
54
+ } as unknown as AppendMessage);
55
+ return core;
56
+ }
57
+
58
+ const rootIds = (core: AgUiThreadController) =>
59
+ core
60
+ .getMessages()
61
+ .filter((message) => message.role === "assistant")
62
+ .map((message) => message.id);
63
+
64
+ const nestedUnder = (core: AgUiThreadController, toolCallId: string) =>
65
+ core
66
+ .getMessages()
67
+ .flatMap((message) => (message.role === "assistant" ? message.content : []))
68
+ .flatMap((part) =>
69
+ part.type === "tool-call" && part.toolCallId === toolCallId
70
+ ? (part.messages ?? [])
71
+ : [],
72
+ );
73
+
74
+ const contentOf = (messages: readonly ThreadMessage[]) =>
75
+ messages.flatMap((message) =>
76
+ message.role === "assistant" ? message.content : [],
77
+ );
78
+
79
+ const call = (id: string, name = "research") => ({
80
+ id,
81
+ type: "function",
82
+ function: { name, arguments: "{}" },
83
+ });
84
+
85
+ const spawn = (toolCallId: string): Event[] => [
86
+ {
87
+ type: "TOOL_CALL_START",
88
+ toolCallId,
89
+ toolCallName: "research",
90
+ parentMessageId: "a",
91
+ },
92
+ { type: "TOOL_CALL_END", toolCallId },
93
+ ];
94
+
95
+ const subagentText = (runId: string, messageId: string, text: string) => [
96
+ { type: "TEXT_MESSAGE_START", messageId, subagentRunId: runId },
97
+ {
98
+ type: "TEXT_MESSAGE_CONTENT",
99
+ messageId,
100
+ delta: text,
101
+ subagentRunId: runId,
102
+ },
103
+ { type: "TEXT_MESSAGE_END", messageId, subagentRunId: runId },
104
+ ];
105
+
106
+ const snapshot = (runId: string, spawnId: string) => ({
107
+ type: "MESSAGES_SNAPSHOT",
108
+ messages: [
109
+ { id: "u", role: "user", content: "u" },
110
+ { id: "a", role: "assistant", content: "", toolCalls: [call(spawnId)] },
111
+ {
112
+ id: "sub-text",
113
+ role: "assistant",
114
+ content: "Paris is sunny.",
115
+ subagentRunId: runId,
116
+ },
117
+ { id: "t", role: "tool", toolCallId: spawnId, content: '"done"' },
118
+ ],
119
+ });
120
+
121
+ describe("AgUiThreadController subagent messages in snapshots", () => {
122
+ it("keeps a run at root when its first start has no parent", async () => {
123
+ const core = await run([
124
+ ...spawn("spawn-1"),
125
+ { type: "SUBAGENT_STARTED", subagentRunId: "run-1", name: "researcher" },
126
+ ...subagentText("run-1", "s1", "First."),
127
+ {
128
+ type: "SUBAGENT_STARTED",
129
+ subagentRunId: "run-1",
130
+ parentToolCallId: "spawn-1",
131
+ name: "researcher",
132
+ },
133
+ snapshot("run-1", "spawn-1"),
134
+ ...subagentText("run-1", "s2", "Second."),
135
+ ]);
136
+ expect(nestedUnder(core, "spawn-1")).toHaveLength(0);
137
+ expect(rootIds(core)).toContain("sub-text");
138
+ });
139
+
140
+ it("reopens a completed nested message when a snapshot adds an interrupt", async () => {
141
+ const core = await run([
142
+ ...spawn("spawn-1"),
143
+ {
144
+ type: "SUBAGENT_STARTED",
145
+ subagentRunId: "run-1",
146
+ parentToolCallId: "spawn-1",
147
+ name: "researcher",
148
+ },
149
+ ...subagentText("run-1", "s1", "Done."),
150
+ { type: "SUBAGENT_FINISHED", subagentRunId: "run-1" },
151
+ {
152
+ type: "TOOL_CALL_RESULT",
153
+ toolCallId: "spawn-1",
154
+ messageId: "t",
155
+ content: '"done"',
156
+ },
157
+ {
158
+ type: "MESSAGES_SNAPSHOT",
159
+ messages: [
160
+ {
161
+ id: "a",
162
+ role: "assistant",
163
+ content: "",
164
+ toolCalls: [call("spawn-1")],
165
+ },
166
+ {
167
+ id: "s1",
168
+ role: "assistant",
169
+ content: "Done.",
170
+ subagentRunId: "run-1",
171
+ metadata: {
172
+ custom: {
173
+ agui: { interrupts: [{ id: "int", reason: "approval" }] },
174
+ },
175
+ },
176
+ },
177
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"done"' },
178
+ ],
179
+ },
180
+ ]);
181
+ expect(nestedUnder(core, "spawn-1")[0]?.status).toMatchObject({
182
+ type: "requires-action",
183
+ reason: "interrupt",
184
+ });
185
+ });
186
+ it("settles a snapshot nested wait when its frontend result arrives", async () => {
187
+ const core = await run([
188
+ ...spawn("spawn-1"),
189
+ {
190
+ type: "SUBAGENT_STARTED",
191
+ subagentRunId: "run-1",
192
+ parentToolCallId: "spawn-1",
193
+ name: "researcher",
194
+ },
195
+ {
196
+ type: "MESSAGES_SNAPSHOT",
197
+ messages: [
198
+ {
199
+ id: "a",
200
+ role: "assistant",
201
+ content: "",
202
+ toolCalls: [call("spawn-1")],
203
+ },
204
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"ok"' },
205
+ {
206
+ id: "s",
207
+ role: "assistant",
208
+ content: "",
209
+ toolCalls: [call("inner")],
210
+ subagentRunId: "run-1",
211
+ },
212
+ ],
213
+ },
214
+ ]);
215
+ expect(nestedUnder(core, "spawn-1")[0]?.status).toMatchObject({
216
+ type: "requires-action",
217
+ reason: "tool-calls",
218
+ });
219
+ core.addToolResult({
220
+ messageId: "run-1",
221
+ toolCallId: "inner",
222
+ toolName: "research",
223
+ result: "done",
224
+ isError: false,
225
+ });
226
+ expect(nestedUnder(core, "spawn-1")[0]?.status).toMatchObject({
227
+ type: "complete",
228
+ });
229
+ });
230
+
231
+ it("keeps a snapshot-only nested call pending after run completion", async () => {
232
+ const core = await run([
233
+ ...spawn("spawn-1"),
234
+ {
235
+ type: "SUBAGENT_STARTED",
236
+ subagentRunId: "run-1",
237
+ parentToolCallId: "spawn-1",
238
+ name: "researcher",
239
+ },
240
+ {
241
+ type: "MESSAGES_SNAPSHOT",
242
+ messages: [
243
+ {
244
+ id: "a",
245
+ role: "assistant",
246
+ content: "",
247
+ toolCalls: [call("spawn-1")],
248
+ },
249
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"ok"' },
250
+ {
251
+ id: "s",
252
+ role: "assistant",
253
+ content: "",
254
+ toolCalls: [call("inner")],
255
+ subagentRunId: "run-1",
256
+ },
257
+ ],
258
+ },
259
+ ]);
260
+ expect(core.getPendingToolCalls()?.toolCallIds).toContain("inner");
261
+ });
262
+ it("admits a snapshot nested call past the spawning message's pending list", async () => {
263
+ const core = await run([
264
+ ...spawn("spawn-1"),
265
+ {
266
+ type: "SUBAGENT_STARTED",
267
+ subagentRunId: "run-1",
268
+ parentToolCallId: "spawn-1",
269
+ name: "researcher",
270
+ },
271
+ {
272
+ type: "MESSAGES_SNAPSHOT",
273
+ messages: [
274
+ {
275
+ id: "a",
276
+ role: "assistant",
277
+ content: "",
278
+ toolCalls: [call("spawn-1")],
279
+ metadata: { custom: { agui: { pendingToolCallIds: ["spawn-1"] } } },
280
+ },
281
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"ok"' },
282
+ {
283
+ id: "s",
284
+ role: "assistant",
285
+ content: "",
286
+ toolCalls: [call("inner")],
287
+ subagentRunId: "run-1",
288
+ metadata: { custom: { agui: { pendingToolCallIds: ["inner"] } } },
289
+ },
290
+ ],
291
+ },
292
+ ]);
293
+ expect(core.getPendingToolCalls()?.toolCallIds).toContain("inner");
294
+ });
295
+ it.each([
296
+ ["equals the spawning call's id", "spawn-1"],
297
+ ["is announced by SUBAGENT_STARTED", "run-1"],
298
+ ])(
299
+ "nests an attributed snapshot message once when its run id %s",
300
+ async (_case, runId) => {
301
+ const core = await run([
302
+ ...spawn("spawn-1"),
303
+ {
304
+ type: "SUBAGENT_STARTED",
305
+ subagentRunId: runId,
306
+ parentToolCallId: "spawn-1",
307
+ name: "researcher",
308
+ },
309
+ ...subagentText(runId, "sub-text", "Paris is sunny."),
310
+ { type: "SUBAGENT_FINISHED", subagentRunId: runId },
311
+ {
312
+ type: "TOOL_CALL_RESULT",
313
+ toolCallId: "spawn-1",
314
+ messageId: "t",
315
+ content: '"done"',
316
+ },
317
+ snapshot(runId, "spawn-1"),
318
+ snapshot(runId, "spawn-1"),
319
+ ]);
320
+
321
+ expect(rootIds(core)).toEqual(["a"]);
322
+ const nested = nestedUnder(core, "spawn-1");
323
+ expect(nested).toHaveLength(1);
324
+ expect(contentOf(nested)).toEqual([
325
+ { type: "text", text: "Paris is sunny." },
326
+ ]);
327
+ expect(nested[0]?.metadata.custom).toMatchObject({
328
+ agui: { name: "researcher" },
329
+ });
330
+ },
331
+ );
332
+
333
+ it.each([undefined, "concat-content"] as const)(
334
+ "keeps the live subagent's error status and reasoning through a snapshot (joinStrategy %s)",
335
+ async (joinStrategy) => {
336
+ const core = await run(
337
+ [
338
+ ...spawn("spawn-1"),
339
+ {
340
+ type: "SUBAGENT_STARTED",
341
+ subagentRunId: "run-1",
342
+ parentToolCallId: "spawn-1",
343
+ name: "researcher",
344
+ },
345
+ { type: "REASONING_START", messageId: "r", subagentRunId: "run-1" },
346
+ {
347
+ type: "REASONING_MESSAGE_START",
348
+ messageId: "r",
349
+ role: "reasoning",
350
+ subagentRunId: "run-1",
351
+ },
352
+ {
353
+ type: "REASONING_MESSAGE_CONTENT",
354
+ messageId: "r",
355
+ delta: "Checking.",
356
+ subagentRunId: "run-1",
357
+ },
358
+ {
359
+ type: "REASONING_MESSAGE_END",
360
+ messageId: "r",
361
+ subagentRunId: "run-1",
362
+ },
363
+ { type: "REASONING_END", messageId: "r", subagentRunId: "run-1" },
364
+ ...subagentText("run-1", "sub-text", "Paris is sunny."),
365
+ { type: "SUBAGENT_ERROR", subagentRunId: "run-1", message: "boom" },
366
+ {
367
+ type: "TOOL_CALL_RESULT",
368
+ toolCallId: "spawn-1",
369
+ messageId: "t",
370
+ content: '"done"',
371
+ },
372
+ snapshot("run-1", "spawn-1"),
373
+ ],
374
+ joinStrategy,
375
+ );
376
+
377
+ const [nested] = nestedUnder(core, "spawn-1");
378
+ expect(nested?.status).toMatchObject({ type: "incomplete" });
379
+ expect(contentOf(nested ? [nested] : [])).toEqual(
380
+ expect.arrayContaining([
381
+ expect.objectContaining({ type: "reasoning", text: "Checking." }),
382
+ { type: "text", text: "Paris is sunny." },
383
+ ]),
384
+ );
385
+ },
386
+ );
387
+
388
+ it("keeps every snapshot's content for a run that grows across snapshots", async () => {
389
+ const turn = (text: Event[]) => ({
390
+ type: "MESSAGES_SNAPSHOT",
391
+ messages: [
392
+ { id: "u", role: "user", content: "u" },
393
+ {
394
+ id: "a",
395
+ role: "assistant",
396
+ content: "",
397
+ toolCalls: [call("spawn-1")],
398
+ },
399
+ ...text,
400
+ ],
401
+ });
402
+ const core = await run([
403
+ ...spawn("spawn-1"),
404
+ {
405
+ type: "SUBAGENT_STARTED",
406
+ subagentRunId: "run-1",
407
+ parentToolCallId: "spawn-1",
408
+ name: "researcher",
409
+ },
410
+ turn([
411
+ {
412
+ id: "s1",
413
+ role: "assistant",
414
+ content: "First.",
415
+ subagentRunId: "run-1",
416
+ },
417
+ ]),
418
+ turn([
419
+ {
420
+ id: "s1",
421
+ role: "assistant",
422
+ content: "First.",
423
+ subagentRunId: "run-1",
424
+ },
425
+ {
426
+ id: "s2",
427
+ role: "assistant",
428
+ content: "Second.",
429
+ subagentRunId: "run-1",
430
+ },
431
+ ]),
432
+ ]);
433
+
434
+ expect(rootIds(core)).toEqual(["a"]);
435
+ expect(contentOf(nestedUnder(core, "spawn-1"))).toEqual([
436
+ { type: "text", text: "First." },
437
+ { type: "text", text: "Second." },
438
+ ]);
439
+ });
440
+
441
+ it("resolves a live nested call whose result only arrives in a snapshot", async () => {
442
+ const core = await run([
443
+ ...spawn("spawn-1"),
444
+ {
445
+ type: "SUBAGENT_STARTED",
446
+ subagentRunId: "run-1",
447
+ parentToolCallId: "spawn-1",
448
+ name: "researcher",
449
+ },
450
+ {
451
+ type: "TOOL_CALL_START",
452
+ toolCallId: "inner",
453
+ toolCallName: "lookup",
454
+ subagentRunId: "run-1",
455
+ },
456
+ { type: "TOOL_CALL_END", toolCallId: "inner", subagentRunId: "run-1" },
457
+ { type: "SUBAGENT_FINISHED", subagentRunId: "run-1" },
458
+ {
459
+ type: "TOOL_CALL_RESULT",
460
+ toolCallId: "spawn-1",
461
+ messageId: "t",
462
+ content: '"done"',
463
+ },
464
+ {
465
+ type: "MESSAGES_SNAPSHOT",
466
+ messages: [
467
+ { id: "u", role: "user", content: "u" },
468
+ {
469
+ id: "a",
470
+ role: "assistant",
471
+ content: "",
472
+ toolCalls: [call("spawn-1")],
473
+ },
474
+ {
475
+ id: "s1",
476
+ role: "assistant",
477
+ content: "",
478
+ toolCalls: [call("inner", "lookup")],
479
+ subagentRunId: "run-1",
480
+ },
481
+ {
482
+ id: "s1-result",
483
+ role: "tool",
484
+ toolCallId: "inner",
485
+ content: '"sunny"',
486
+ subagentRunId: "run-1",
487
+ },
488
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"done"' },
489
+ ],
490
+ },
491
+ ]);
492
+
493
+ const inner = contentOf(nestedUnder(core, "spawn-1")).find(
494
+ (part) => part.type === "tool-call" && part.toolCallId === "inner",
495
+ );
496
+ expect(inner).toMatchObject({ result: "sunny" });
497
+ expect(core.getPendingToolCalls()).toBeNull();
498
+ });
499
+
500
+ it("keeps live subagent text that a lagging snapshot has not caught up with", async () => {
501
+ const core = await run([
502
+ ...spawn("spawn-1"),
503
+ {
504
+ type: "SUBAGENT_STARTED",
505
+ subagentRunId: "run-1",
506
+ parentToolCallId: "spawn-1",
507
+ name: "researcher",
508
+ },
509
+ ...subagentText("run-1", "s1", "First."),
510
+ ...subagentText("run-1", "s2", "Second."),
511
+ { type: "SUBAGENT_FINISHED", subagentRunId: "run-1" },
512
+ {
513
+ type: "TOOL_CALL_RESULT",
514
+ toolCallId: "spawn-1",
515
+ messageId: "t",
516
+ content: '"done"',
517
+ },
518
+ {
519
+ type: "MESSAGES_SNAPSHOT",
520
+ messages: [
521
+ { id: "u", role: "user", content: "u" },
522
+ {
523
+ id: "a",
524
+ role: "assistant",
525
+ content: "",
526
+ toolCalls: [call("spawn-1")],
527
+ },
528
+ {
529
+ id: "s1",
530
+ role: "assistant",
531
+ content: "First.",
532
+ subagentRunId: "run-1",
533
+ },
534
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"done"' },
535
+ ],
536
+ },
537
+ ]);
538
+
539
+ expect(rootIds(core)).toEqual(["a"]);
540
+ expect(contentOf(nestedUnder(core, "spawn-1"))).toEqual([
541
+ { type: "text", text: "First." },
542
+ { type: "text", text: "Second." },
543
+ ]);
544
+ });
545
+
546
+ it("keeps a failed live subagent's status and metadata when the snapshot copy is empty", async () => {
547
+ const core = await run([
548
+ ...spawn("spawn-1"),
549
+ {
550
+ type: "SUBAGENT_STARTED",
551
+ subagentRunId: "run-1",
552
+ parentToolCallId: "spawn-1",
553
+ name: "researcher",
554
+ },
555
+ ...subagentText("run-1", "s1", "Working."),
556
+ {
557
+ type: "SUBAGENT_ERROR",
558
+ subagentRunId: "run-1",
559
+ message: "boom",
560
+ code: "TOOL_FAILED",
561
+ },
562
+ {
563
+ type: "TOOL_CALL_RESULT",
564
+ toolCallId: "spawn-1",
565
+ messageId: "t",
566
+ content: '"failed"',
567
+ },
568
+ {
569
+ type: "MESSAGES_SNAPSHOT",
570
+ messages: [
571
+ { id: "u", role: "user", content: "u" },
572
+ {
573
+ id: "a",
574
+ role: "assistant",
575
+ content: "",
576
+ toolCalls: [call("spawn-1")],
577
+ },
578
+ { id: "s0", role: "assistant", content: "", subagentRunId: "run-1" },
579
+ { id: "t", role: "tool", toolCallId: "spawn-1", content: '"failed"' },
580
+ ],
581
+ },
582
+ ]);
583
+
584
+ const [nested] = nestedUnder(core, "spawn-1");
585
+ expect(nested?.status).toMatchObject({ type: "incomplete" });
586
+ expect(nested?.metadata.custom).toMatchObject({
587
+ agui: { name: "researcher", errorCode: "TOOL_FAILED" },
588
+ });
589
+ expect(contentOf(nested ? [nested] : [])).toEqual([
590
+ { type: "text", text: "Working." },
591
+ ]);
592
+ });
593
+
594
+ describe("merging a nested transcript with a snapshot copy", () => {
595
+ const started: Event = {
596
+ type: "SUBAGENT_STARTED",
597
+ subagentRunId: "run-1",
598
+ parentToolCallId: "spawn-1",
599
+ name: "researcher",
600
+ };
601
+ const innerCall = (toolCallId: string): Event[] => [
602
+ {
603
+ type: "TOOL_CALL_START",
604
+ toolCallId,
605
+ toolCallName: "lookup",
606
+ subagentRunId: "run-1",
607
+ },
608
+ { type: "TOOL_CALL_END", toolCallId, subagentRunId: "run-1" },
609
+ ];
610
+ const innerResult = (toolCallId: string): Event => ({
611
+ type: "TOOL_CALL_RESULT",
612
+ toolCallId,
613
+ messageId: `${toolCallId}-result`,
614
+ content: '"sunny"',
615
+ subagentRunId: "run-1",
616
+ });
617
+ const finish: Event[] = [
618
+ { type: "SUBAGENT_FINISHED", subagentRunId: "run-1" },
619
+ {
620
+ type: "TOOL_CALL_RESULT",
621
+ toolCallId: "spawn-1",
622
+ messageId: "t",
623
+ content: '"done"',
624
+ },
625
+ ];
626
+ const snapshotOf = (...records: Event[]): Event => ({
627
+ type: "MESSAGES_SNAPSHOT",
628
+ messages: [
629
+ { id: "u", role: "user", content: "u" },
630
+ {
631
+ id: "a",
632
+ role: "assistant",
633
+ content: "",
634
+ toolCalls: [call("spawn-1")],
635
+ },
636
+ ...records,
637
+ ],
638
+ });
639
+ const record = (id: string, content: string, toolCalls?: string[]) => ({
640
+ id,
641
+ role: "assistant",
642
+ content,
643
+ subagentRunId: "run-1",
644
+ ...(toolCalls
645
+ ? { toolCalls: toolCalls.map((t) => call(t, "lookup")) }
646
+ : {}),
647
+ });
648
+ const resultRecord = (toolCallId: string) => ({
649
+ id: `${toolCallId}-result`,
650
+ role: "tool",
651
+ toolCallId,
652
+ content: '"sunny"',
653
+ subagentRunId: "run-1",
654
+ });
655
+ const innerOf = (core: AgUiThreadController) =>
656
+ contentOf(nestedUnder(core, "spawn-1")).find(
657
+ (part) => part.type === "tool-call" && part.toolCallId === "inner",
658
+ );
659
+
660
+ it("applies a snapshot's result while live text is ahead of it", async () => {
661
+ const core = await run([
662
+ ...spawn("spawn-1"),
663
+ started,
664
+ ...subagentText("run-1", "s1", "Looking."),
665
+ ...innerCall("inner"),
666
+ ...subagentText("run-1", "s2", "Done."),
667
+ ...finish,
668
+ snapshotOf(record("s1", "Looking.", ["inner"]), resultRecord("inner")),
669
+ ]);
670
+
671
+ expect(innerOf(core)).toMatchObject({ result: "sunny" });
672
+ expect(core.getPendingToolCalls()).toBeNull();
673
+ });
674
+
675
+ it("keeps streaming results and reasoning after a snapshot whose text is ahead", async () => {
676
+ const core = await run([
677
+ ...spawn("spawn-1"),
678
+ started,
679
+ ...subagentText("run-1", "s1", "Hello world."),
680
+ snapshotOf(record("s1", "Hello world.\n")),
681
+ ...innerCall("inner"),
682
+ innerResult("inner"),
683
+ { type: "REASONING_START", messageId: "r", subagentRunId: "run-1" },
684
+ {
685
+ type: "REASONING_MESSAGE_START",
686
+ messageId: "r",
687
+ role: "reasoning",
688
+ subagentRunId: "run-1",
689
+ },
690
+ {
691
+ type: "REASONING_MESSAGE_CONTENT",
692
+ messageId: "r",
693
+ delta: "Hmm.",
694
+ subagentRunId: "run-1",
695
+ },
696
+ {
697
+ type: "REASONING_MESSAGE_END",
698
+ messageId: "r",
699
+ subagentRunId: "run-1",
700
+ },
701
+ { type: "REASONING_END", messageId: "r", subagentRunId: "run-1" },
702
+ ...finish,
703
+ ]);
704
+
705
+ expect(innerOf(core)).toMatchObject({ result: "sunny" });
706
+ expect(contentOf(nestedUnder(core, "spawn-1"))).toEqual(
707
+ expect.arrayContaining([
708
+ { type: "text", text: "Hello world.\n" },
709
+ expect.objectContaining({ type: "reasoning", text: "Hmm." }),
710
+ ]),
711
+ );
712
+ });
713
+
714
+ it("keeps a call that only a snapshot carried once live text continues", async () => {
715
+ const core = await run([
716
+ ...spawn("spawn-1"),
717
+ started,
718
+ snapshotOf(record("s1", "", ["inner"]), resultRecord("inner")),
719
+ ...subagentText("run-1", "s2", "Done."),
720
+ ...finish,
721
+ ]);
722
+
723
+ expect(innerOf(core)).toMatchObject({ result: "sunny" });
724
+ });
725
+
726
+ it("settles a finished subagent that a lagging snapshot shows waiting", async () => {
727
+ const core = await run([
728
+ ...spawn("spawn-1"),
729
+ started,
730
+ ...innerCall("inner"),
731
+ innerResult("inner"),
732
+ ...finish,
733
+ snapshotOf(record("s1", "", ["inner"])),
734
+ ]);
735
+
736
+ const [nested] = nestedUnder(core, "spawn-1");
737
+ expect(nested?.status).toMatchObject({ type: "complete" });
738
+ expect(innerOf(core)).toMatchObject({ result: "sunny" });
739
+ });
740
+
741
+ const sequence = (core: AgUiThreadController) =>
742
+ contentOf(nestedUnder(core, "spawn-1")).map((part) =>
743
+ part.type === "tool-call"
744
+ ? `${part.toolCallId}=${JSON.stringify(part.result)}`
745
+ : part.type === "text"
746
+ ? part.text
747
+ : part.type,
748
+ );
749
+
750
+ it.each([
751
+ {
752
+ name: "a call between two texts",
753
+ live: [
754
+ ...subagentText("run-1", "s1", "A."),
755
+ ...innerCall("t1"),
756
+ innerResult("t1"),
757
+ ...subagentText("run-1", "s2", "B."),
758
+ ],
759
+ snapshot: [record("s1", "A.", ["t1"])],
760
+ expected: ["A.", 't1="sunny"', "B."],
761
+ },
762
+ {
763
+ name: "text after a call with no text before it",
764
+ live: [
765
+ ...innerCall("inner"),
766
+ innerResult("inner"),
767
+ ...subagentText("run-1", "s2", "Done."),
768
+ ],
769
+ snapshot: [record("s1", "", ["inner"])],
770
+ expected: ['inner="sunny"', "Done."],
771
+ },
772
+ {
773
+ name: "two calls with text around them",
774
+ live: [
775
+ ...subagentText("run-1", "s1", "A."),
776
+ ...innerCall("t1"),
777
+ innerResult("t1"),
778
+ ...subagentText("run-1", "s2", "B."),
779
+ ...innerCall("t2"),
780
+ innerResult("t2"),
781
+ ...subagentText("run-1", "s3", "C."),
782
+ ],
783
+ snapshot: [
784
+ record("s1", "A.", ["t1"]),
785
+ resultRecord("t1"),
786
+ record("s2", "B."),
787
+ ],
788
+ expected: ["A.", 't1="sunny"', "B.", 't2="sunny"', "C."],
789
+ },
790
+ {
791
+ name: "text between two calls it lacks",
792
+ live: [
793
+ ...subagentText("run-1", "s1", "A."),
794
+ ...innerCall("t1"),
795
+ innerResult("t1"),
796
+ ...subagentText("run-1", "s2", "B."),
797
+ ...innerCall("t2"),
798
+ innerResult("t2"),
799
+ ],
800
+ snapshot: [record("s1", "A.", ["t1"])],
801
+ expected: ["A.", 't1="sunny"', "B.", 't2="sunny"'],
802
+ },
803
+ ])(
804
+ "keeps the live order when a lagging snapshot has $name",
805
+ async ({ live, snapshot: records, expected }) => {
806
+ const core = await run([
807
+ ...spawn("spawn-1"),
808
+ started,
809
+ ...live,
810
+ ...finish,
811
+ snapshotOf(...records),
812
+ ]);
813
+
814
+ expect(sequence(core)).toEqual(expected);
815
+ },
816
+ );
817
+
818
+ it("keeps snapshot text before live reasoning it preceded while streaming continues", async () => {
819
+ const core = await run([
820
+ ...spawn("spawn-1"),
821
+ started,
822
+ ...subagentText("run-1", "s1", "A."),
823
+ { type: "REASONING_START", messageId: "r", subagentRunId: "run-1" },
824
+ {
825
+ type: "REASONING_MESSAGE_START",
826
+ messageId: "r",
827
+ role: "reasoning",
828
+ subagentRunId: "run-1",
829
+ },
830
+ {
831
+ type: "REASONING_MESSAGE_CONTENT",
832
+ messageId: "r",
833
+ delta: "Hmm.",
834
+ subagentRunId: "run-1",
835
+ },
836
+ {
837
+ type: "REASONING_MESSAGE_END",
838
+ messageId: "r",
839
+ subagentRunId: "run-1",
840
+ },
841
+ { type: "REASONING_END", messageId: "r", subagentRunId: "run-1" },
842
+ snapshotOf(record("s1", "A."), record("s2", "B.")),
843
+ ...innerCall("inner"),
844
+ ]);
845
+
846
+ expect(sequence(core)).toEqual([
847
+ "A.",
848
+ "reasoning",
849
+ "B.",
850
+ "inner=undefined",
851
+ ]);
852
+ });
853
+ });
854
+ });