@assistant-ui/react-ag-ui 0.1.1 → 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 (41) hide show
  1. package/README.md +2 -0
  2. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadController.js +212 -22
  5. package/dist/runtime/AgUiThreadController.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts +9 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +184 -9
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +34 -12
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +1 -8
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/event-parser.d.ts.map +1 -1
  19. package/dist/runtime/event-parser.js +1 -0
  20. package/dist/runtime/event-parser.js.map +1 -1
  21. package/dist/runtime/types.d.ts +2 -0
  22. package/dist/runtime/types.d.ts.map +1 -1
  23. package/dist/runtime/types.js +2 -1
  24. package/dist/runtime/types.js.map +1 -1
  25. package/package.json +5 -5
  26. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  27. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  28. package/src/runtime/AgUiThreadController.replay.test.ts +8 -1
  29. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +167 -0
  30. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  31. package/src/runtime/AgUiThreadController.test.ts +40 -0
  32. package/src/runtime/AgUiThreadController.ts +459 -27
  33. package/src/runtime/adapter/conversions.content.test.ts +129 -0
  34. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  35. package/src/runtime/adapter/conversions.ts +331 -20
  36. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  37. package/src/runtime/adapter/run-aggregator.ts +52 -9
  38. package/src/runtime/adapter/subscriber.test.ts +1 -1
  39. package/src/runtime/adapter/subscriber.ts +1 -6
  40. package/src/runtime/event-parser.ts +3 -0
  41. 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
+ });