@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,550 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fromAgUiMessages, fromAgUiSnapshotMessages } from "./conversions";
3
+
4
+ type Part = {
5
+ type: string;
6
+ toolCallId?: string;
7
+ text?: string;
8
+ result?: unknown;
9
+ messages?: Message[];
10
+ };
11
+ type Message = { id?: string; role: string; content: string | Part[] };
12
+
13
+ const call = (id: string, name = "research") => ({
14
+ id,
15
+ type: "function",
16
+ function: { name, arguments: "{}" },
17
+ });
18
+
19
+ const toolCalls = (message: Message | undefined) =>
20
+ Array.isArray(message?.content)
21
+ ? message.content.filter((part) => part.type === "tool-call")
22
+ : [];
23
+
24
+ const findCall = (messages: readonly Message[], toolCallId: string) =>
25
+ messages
26
+ .flatMap((message) => toolCalls(message))
27
+ .find((part) => part.toolCallId === toolCallId);
28
+
29
+ const texts = (message: Message | undefined) =>
30
+ Array.isArray(message?.content)
31
+ ? message.content.flatMap((part) =>
32
+ part.type === "text" ? [part.text] : [],
33
+ )
34
+ : [message?.content];
35
+
36
+ const subagentTurn = [
37
+ { id: "u1", role: "user", content: "weather?" },
38
+ { id: "a1", role: "assistant", content: "", toolCalls: [call("spawn-1")] },
39
+ {
40
+ id: "s1",
41
+ role: "assistant",
42
+ content: "",
43
+ toolCalls: [call("spawn-1::inner-1", "lookup")],
44
+ subagentRunId: "spawn-1",
45
+ },
46
+ {
47
+ id: "s1-result",
48
+ role: "tool",
49
+ toolCallId: "spawn-1::inner-1",
50
+ content: '"sunny"',
51
+ subagentRunId: "spawn-1",
52
+ },
53
+ {
54
+ id: "s2",
55
+ role: "assistant",
56
+ content: "Paris is sunny.",
57
+ subagentRunId: "spawn-1",
58
+ },
59
+ {
60
+ id: "t1",
61
+ role: "tool",
62
+ toolCallId: "spawn-1",
63
+ content: '"Paris is sunny."',
64
+ },
65
+ { id: "a2", role: "assistant", content: "It is sunny." },
66
+ ];
67
+
68
+ const announced = (messages: readonly unknown[], parents: [string, string][]) =>
69
+ fromAgUiSnapshotMessages(messages, undefined, new Map(parents)) as Message[];
70
+
71
+ describe("fromAgUiSnapshotMessages subagent attribution", () => {
72
+ it.each([
73
+ ["snake case", { tool_calls: [call("spawn")] }],
74
+ [
75
+ "inline",
76
+ {
77
+ content: [
78
+ {
79
+ type: "tool-call",
80
+ toolCallId: "spawn",
81
+ toolName: "research",
82
+ args: {},
83
+ },
84
+ ],
85
+ },
86
+ ],
87
+ ])("nests a run spawned by a %s call", (_name, shape) => {
88
+ const converted = announced(
89
+ [
90
+ { id: "a", role: "assistant", content: "", ...shape },
91
+ {
92
+ id: "s",
93
+ role: "assistant",
94
+ content: "Nested.",
95
+ subagentRunId: "run",
96
+ },
97
+ ],
98
+ [["run", "spawn"]],
99
+ );
100
+ expect(converted.map((message) => message.id)).toEqual(["a"]);
101
+ expect(texts(findCall(converted, "spawn")?.messages?.[0])).toEqual([
102
+ "Nested.",
103
+ ]);
104
+ });
105
+
106
+ it.each(["user", "system", "developer"])(
107
+ "preserves a tagged %s record",
108
+ (role) => {
109
+ const converted = announced(
110
+ [
111
+ {
112
+ id: "a",
113
+ role: "assistant",
114
+ content: "",
115
+ toolCalls: [call("spawn")],
116
+ },
117
+ { id: "tagged", role, content: "Keep me.", subagentRunId: "run" },
118
+ {
119
+ id: "s",
120
+ role: "assistant",
121
+ content: "Nested.",
122
+ subagentRunId: "run",
123
+ },
124
+ ],
125
+ [["run", "spawn"]],
126
+ );
127
+ expect(converted.find((message) => message.id === "tagged")).toEqual(
128
+ fromAgUiMessages([{ id: "tagged", role, content: "Keep me." }])[0],
129
+ );
130
+ },
131
+ );
132
+ it("nests a subagent's messages under the tool call that spawned it", () => {
133
+ const converted = announced(subagentTurn, [["spawn-1", "spawn-1"]]);
134
+
135
+ expect(converted.map((message) => message.id)).toEqual(["u1", "a1", "a2"]);
136
+ const spawn = findCall(converted, "spawn-1");
137
+ expect(spawn?.result).toBe("Paris is sunny.");
138
+ expect(spawn?.messages).toHaveLength(1);
139
+
140
+ const [nested] = spawn!.messages!;
141
+ expect(nested).toMatchObject({ id: "spawn-1", role: "assistant" });
142
+ const inner = findCall([nested!], "spawn-1::inner-1");
143
+ expect(inner?.result).toBe("sunny");
144
+ expect(texts(nested)).toContain("Paris is sunny.");
145
+ });
146
+
147
+ it("uses the announced spawning call when the run id names no tool call", () => {
148
+ const converted = fromAgUiSnapshotMessages(
149
+ [
150
+ {
151
+ id: "a1",
152
+ role: "assistant",
153
+ content: "",
154
+ toolCalls: [call("call-9")],
155
+ },
156
+ {
157
+ id: "s1",
158
+ role: "assistant",
159
+ content: "Done.",
160
+ subagentRunId: "run-x",
161
+ },
162
+ { id: "t1", role: "tool", toolCallId: "call-9", content: '"ok"' },
163
+ ],
164
+ undefined,
165
+ new Map([["run-x", "call-9"]]),
166
+ ) as Message[];
167
+
168
+ expect(converted.map((message) => message.id)).toEqual(["a1"]);
169
+ const [nested] = findCall(converted, "call-9")!.messages!;
170
+ expect(nested?.id).toBe("run-x");
171
+ expect(texts(nested)).toEqual(["Done."]);
172
+ });
173
+
174
+ it("nests a subagent of a subagent inside its parent's transcript", () => {
175
+ const converted = announced(
176
+ [
177
+ { id: "a1", role: "assistant", content: "", toolCalls: [call("top")] },
178
+ {
179
+ id: "m1",
180
+ role: "assistant",
181
+ content: "",
182
+ toolCalls: [call("top::mid", "leaf")],
183
+ subagentRunId: "top",
184
+ },
185
+ {
186
+ id: "l1",
187
+ role: "assistant",
188
+ content: "Leaf done.",
189
+ subagentRunId: "top::mid",
190
+ },
191
+ {
192
+ id: "m-r",
193
+ role: "tool",
194
+ toolCallId: "top::mid",
195
+ content: '"leaf"',
196
+ subagentRunId: "top",
197
+ },
198
+ { id: "t1", role: "tool", toolCallId: "top", content: '"mid"' },
199
+ ],
200
+ [
201
+ ["top", "top"],
202
+ ["top::mid", "top::mid"],
203
+ ],
204
+ );
205
+
206
+ expect(converted.map((message) => message.id)).toEqual(["a1"]);
207
+ const [middle] = findCall(converted, "top")!.messages!;
208
+ const leafCall = findCall([middle!], "top::mid");
209
+ expect(leafCall?.result).toBe("leaf");
210
+ const [leaf] = leafCall!.messages!;
211
+ expect(leaf?.id).toBe("top::mid");
212
+ expect(texts(leaf)).toEqual(["Leaf done."]);
213
+ });
214
+
215
+ it("keeps an untagged result with the nested call it answers", () => {
216
+ const converted = announced(
217
+ [
218
+ {
219
+ id: "a1",
220
+ role: "assistant",
221
+ content: "",
222
+ toolCalls: [call("spawn")],
223
+ },
224
+ {
225
+ id: "s1",
226
+ role: "assistant",
227
+ content: "",
228
+ toolCalls: [call("spawn::frontend", "confirm")],
229
+ subagentRunId: "spawn",
230
+ },
231
+ {
232
+ id: "r1",
233
+ role: "tool",
234
+ toolCallId: "spawn::frontend",
235
+ content: '"yes"',
236
+ },
237
+ ],
238
+ [["spawn", "spawn"]],
239
+ );
240
+
241
+ expect(converted.map((message) => message.id)).toEqual(["a1"]);
242
+ const [nested] = findCall(converted, "spawn")!.messages!;
243
+ expect(findCall([nested!], "spawn::frontend")?.result).toBe("yes");
244
+ });
245
+
246
+ it("leaves a subagent with no reachable spawning call in the parent thread", () => {
247
+ const converted = announced(
248
+ [
249
+ { id: "u1", role: "user", content: "hi" },
250
+ {
251
+ id: "s1",
252
+ role: "assistant",
253
+ content: "Orphan.",
254
+ subagentRunId: "unknown-run",
255
+ },
256
+ {
257
+ id: "c1",
258
+ role: "assistant",
259
+ content: "",
260
+ toolCalls: [call("a")],
261
+ subagentRunId: "b",
262
+ },
263
+ {
264
+ id: "c2",
265
+ role: "assistant",
266
+ content: "",
267
+ toolCalls: [call("b")],
268
+ subagentRunId: "a",
269
+ },
270
+ ],
271
+ [
272
+ ["unknown-run", "missing"],
273
+ ["a", "a"],
274
+ ["b", "b"],
275
+ ],
276
+ );
277
+
278
+ expect(converted.map((message) => message.id)).toEqual([
279
+ "u1",
280
+ "s1",
281
+ "c1",
282
+ "c2",
283
+ ]);
284
+ expect(findCall(converted, "a")?.messages).toBeUndefined();
285
+ expect(findCall(converted, "b")?.messages).toBeUndefined();
286
+ });
287
+
288
+ it("leaves a run that was never announced in the parent thread", () => {
289
+ const converted = announced(subagentTurn, []);
290
+
291
+ expect(converted.map((message) => message.id)).toEqual(
292
+ fromAgUiMessages(subagentTurn).map((message) => message.id),
293
+ );
294
+ expect(findCall(converted, "spawn-1")?.messages).toBeUndefined();
295
+ });
296
+
297
+ it("does not change what fromAgUiMessages returns for attributed history", () => {
298
+ const converted = fromAgUiMessages(subagentTurn) as Message[];
299
+
300
+ expect(converted.map((message) => message.id)).toEqual([
301
+ "u1",
302
+ "a1",
303
+ "s1",
304
+ "s2",
305
+ "a2",
306
+ ]);
307
+ expect(findCall(converted, "spawn-1")?.messages).toBeUndefined();
308
+ });
309
+
310
+ it("gives a nested run waiting on a call the requires-action status, and its spawning message too", () => {
311
+ const converted = announced(
312
+ [
313
+ {
314
+ id: "a1",
315
+ role: "assistant",
316
+ content: "",
317
+ toolCalls: [call("spawn")],
318
+ },
319
+ { id: "t1", role: "tool", toolCallId: "spawn", content: '"ok"' },
320
+ {
321
+ id: "s1",
322
+ role: "assistant",
323
+ content: "",
324
+ toolCalls: [call("spawn::n1", "search")],
325
+ subagentRunId: "spawn",
326
+ },
327
+ ],
328
+ [["spawn", "spawn"]],
329
+ ) as (Message & { status?: { type: string } })[];
330
+
331
+ const [nested] = findCall(converted, "spawn")!.messages! as (Message & {
332
+ status?: { type: string };
333
+ })[];
334
+ expect(nested?.status?.type).toBe("requires-action");
335
+ expect(converted[0]?.status?.type).toBe("requires-action");
336
+ });
337
+
338
+ it("admits a nested pending call despite the spawning message's own pending list", () => {
339
+ const converted = announced(
340
+ [
341
+ {
342
+ id: "a",
343
+ role: "assistant",
344
+ content: "",
345
+ toolCalls: [call("spawn")],
346
+ metadata: { custom: { agui: { pendingToolCallIds: ["spawn"] } } },
347
+ },
348
+ { id: "t", role: "tool", toolCallId: "spawn", content: '"ok"' },
349
+ {
350
+ id: "s",
351
+ role: "assistant",
352
+ content: "",
353
+ toolCalls: [call("inner")],
354
+ subagentRunId: "run",
355
+ metadata: { custom: { agui: { pendingToolCallIds: ["inner"] } } },
356
+ },
357
+ ],
358
+ [["run", "spawn"]],
359
+ ) as (Message & {
360
+ metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
361
+ })[];
362
+ expect(converted[0]?.metadata?.custom?.agui?.pendingToolCallIds).toContain(
363
+ "inner",
364
+ );
365
+ });
366
+
367
+ it("retains each nested record's pending-call admission", () => {
368
+ const converted = announced(
369
+ [
370
+ { id: "a", role: "assistant", content: "", toolCalls: [call("spawn")] },
371
+ { id: "t", role: "tool", toolCallId: "spawn", content: '"ok"' },
372
+ {
373
+ id: "s1",
374
+ role: "assistant",
375
+ content: "",
376
+ toolCalls: [call("one"), call("excluded")],
377
+ subagentRunId: "run",
378
+ metadata: {
379
+ custom: { agui: { pendingToolCallIds: ["one", "future"] } },
380
+ },
381
+ },
382
+ {
383
+ id: "s2",
384
+ role: "assistant",
385
+ content: "",
386
+ toolCalls: [call("two")],
387
+ subagentRunId: "run",
388
+ metadata: { custom: { agui: { pendingToolCallIds: ["two"] } } },
389
+ },
390
+ ],
391
+ [["run", "spawn"]],
392
+ ) as (Message & {
393
+ metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
394
+ })[];
395
+ const nested = findCall(converted, "spawn")?.messages?.[0] as Message & {
396
+ metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
397
+ };
398
+ expect(nested.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
399
+ "one",
400
+ "future",
401
+ "two",
402
+ ]);
403
+ expect(converted[0]?.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
404
+ "one",
405
+ "future",
406
+ "two",
407
+ ]);
408
+ });
409
+
410
+ it("keeps a deeply nested pending call visible through resolved spawning calls", () => {
411
+ const messages = [
412
+ { id: "a1", role: "assistant", content: "", toolCalls: [call("top")] },
413
+ { id: "t1", role: "tool", toolCallId: "top", content: '"ok"' },
414
+ {
415
+ id: "m1",
416
+ role: "assistant",
417
+ content: "",
418
+ toolCalls: [call("top::mid")],
419
+ subagentRunId: "top",
420
+ },
421
+ {
422
+ id: "m-result",
423
+ role: "tool",
424
+ toolCallId: "top::mid",
425
+ content: '"ok"',
426
+ subagentRunId: "top",
427
+ },
428
+ {
429
+ id: "l1",
430
+ role: "assistant",
431
+ content: "",
432
+ toolCalls: [call("top::mid::leaf", "confirm")],
433
+ subagentRunId: "top::mid",
434
+ },
435
+ ];
436
+ const parents: [string, string][] = [
437
+ ["top", "top"],
438
+ ["top::mid", "top::mid"],
439
+ ];
440
+ const converted = announced(messages, parents);
441
+
442
+ const [middle] = findCall(converted, "top")!.messages!;
443
+ const [leaf] = findCall([middle!], "top::mid")!.messages!;
444
+ expect(leaf).toMatchObject({ status: { type: "requires-action" } });
445
+ expect(middle).toMatchObject({ status: { type: "requires-action" } });
446
+ expect(converted[0]).toMatchObject({
447
+ status: { type: "requires-action" },
448
+ });
449
+
450
+ const resolved = announced(
451
+ [
452
+ ...messages,
453
+ {
454
+ id: "l-result",
455
+ role: "tool",
456
+ toolCallId: "top::mid::leaf",
457
+ content: '"confirmed"',
458
+ subagentRunId: "top::mid",
459
+ },
460
+ ],
461
+ parents,
462
+ );
463
+ const [resolvedMiddle] = findCall(resolved, "top")!.messages!;
464
+ const [resolvedLeaf] = findCall([resolvedMiddle!], "top::mid")!.messages!;
465
+ expect(resolvedLeaf).toMatchObject({ status: { type: "complete" } });
466
+ expect(resolvedMiddle).toMatchObject({ status: { type: "complete" } });
467
+ expect(resolved[0]).not.toHaveProperty("status");
468
+ });
469
+
470
+ it("surfaces a nested run's open interrupt on its spawning message", () => {
471
+ const converted = announced(
472
+ [
473
+ {
474
+ id: "a1",
475
+ role: "assistant",
476
+ content: "",
477
+ toolCalls: [call("spawn")],
478
+ },
479
+ { id: "t1", role: "tool", toolCallId: "spawn", content: '"ok"' },
480
+ {
481
+ id: "s1",
482
+ role: "assistant",
483
+ content: "",
484
+ toolCalls: [call("spawn::n1", "delete_file")],
485
+ subagentRunId: "spawn",
486
+ metadata: {
487
+ custom: {
488
+ agui: {
489
+ interrupts: [
490
+ { id: "int-1", reason: "tool_call", toolCallId: "spawn::n1" },
491
+ ],
492
+ },
493
+ },
494
+ },
495
+ },
496
+ ],
497
+ [["spawn", "spawn"]],
498
+ ) as (Message & {
499
+ status?: { type: string; reason?: string };
500
+ metadata?: { custom?: { agui?: { interrupts?: { id: string }[] } } };
501
+ })[];
502
+
503
+ expect(converted[0]?.status).toMatchObject({
504
+ type: "requires-action",
505
+ reason: "interrupt",
506
+ });
507
+ expect(
508
+ converted[0]?.metadata?.custom?.agui?.interrupts?.map(
509
+ (interrupt) => interrupt.id,
510
+ ),
511
+ ).toEqual(["int-1"]);
512
+ });
513
+ it("keeps tagged reasoning and activity with their run", () => {
514
+ const converted = fromAgUiSnapshotMessages(
515
+ [
516
+ { id: "a1", role: "assistant", content: "", toolCalls: [call("top")] },
517
+ { id: "t1", role: "tool", toolCallId: "top", content: '"ok"' },
518
+ {
519
+ id: "r1",
520
+ role: "reasoning",
521
+ content: "sub thought",
522
+ subagentRunId: "top",
523
+ },
524
+ {
525
+ id: "act1",
526
+ role: "activity",
527
+ activityType: "progress",
528
+ content: { step: 1 },
529
+ subagentRunId: "top",
530
+ },
531
+ {
532
+ id: "m1",
533
+ role: "assistant",
534
+ content: "sub answer",
535
+ subagentRunId: "top",
536
+ },
537
+ { id: "a2", role: "assistant", content: "parent answer" },
538
+ ],
539
+ { showThinking: true },
540
+ new Map([["top", "top"]]),
541
+ ) as Message[];
542
+
543
+ expect(JSON.stringify(converted.at(-1))).not.toContain("sub thought");
544
+ const [nested] = findCall(converted, "top")!.messages!;
545
+ expect(JSON.stringify(nested)).toContain("sub thought");
546
+ expect(JSON.stringify(nested)).toContain("sub answer");
547
+ expect(JSON.stringify(converted.at(-1))).not.toContain('"step":1');
548
+ expect(JSON.stringify(nested)).toContain('"step":1');
549
+ });
550
+ });