@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
@@ -138,6 +138,135 @@ describe("toAgUiMessages content metadata", () => {
138
138
  },
139
139
  );
140
140
 
141
+ it("restores each of two file sources that share a handle", () => {
142
+ const original = {
143
+ id: "u-shared",
144
+ role: "user",
145
+ content: [
146
+ {
147
+ type: "document",
148
+ source: {
149
+ type: "file",
150
+ value: "shared",
151
+ mimeType: "application/pdf",
152
+ },
153
+ metadata: { filename: "a.pdf" },
154
+ },
155
+ {
156
+ type: "document",
157
+ source: {
158
+ type: "file",
159
+ value: "shared",
160
+ provider: "openai",
161
+ mimeType: "application/pdf",
162
+ },
163
+ metadata: { filename: "b.pdf" },
164
+ },
165
+ ],
166
+ };
167
+
168
+ expect(toAgUiMessages(fromAgUiMessages([original]))).toEqual([original]);
169
+ });
170
+
171
+ it.each(["image", "audio", "video"])(
172
+ "exports a %s file source without a mime type once",
173
+ (type) => {
174
+ const original = {
175
+ id: "u-untyped",
176
+ role: "user",
177
+ content: [{ type, source: { type: "file", value: `${type}-handle` } }],
178
+ };
179
+
180
+ expect(toAgUiMessages(fromAgUiMessages([original]))).toEqual([original]);
181
+ },
182
+ );
183
+
184
+ it("restores a file source a message carries only in its import metadata", () => {
185
+ const original = {
186
+ id: "u-legacy",
187
+ role: "user",
188
+ content: [
189
+ { type: "text", text: "label" },
190
+ {
191
+ type: "image",
192
+ source: { type: "url", value: "https://example.com/other.png" },
193
+ },
194
+ {
195
+ type: "document",
196
+ source: { type: "file", value: "doc-handle", provider: "openai" },
197
+ },
198
+ ],
199
+ };
200
+ const [imported] = fromAgUiMessages([original]);
201
+ const withoutHandles = {
202
+ ...imported!,
203
+ attachments: imported!.attachments?.filter((attachment) =>
204
+ attachment.content?.every(
205
+ (part) => part.type !== "file" || part.sourceType !== "id",
206
+ ),
207
+ ),
208
+ };
209
+
210
+ expect(withoutHandles.attachments).toHaveLength(1);
211
+ expect(toAgUiMessages([withoutHandles])).toEqual([original]);
212
+ });
213
+
214
+ describe.each(["content", "attachment"])("%s file handles", (location) => {
215
+ it.each([
216
+ ["document", "application/pdf", "file-pdf"],
217
+ ["image", "image/png", "https://provider.example/files/image"],
218
+ ["audio", "audio/wav", "file-audio"],
219
+ ["video", "video/mp4", "file-video"],
220
+ ])(
221
+ "preserves a %s handle instead of sending bytes",
222
+ (type, mimeType, data) => {
223
+ const part = {
224
+ type: "file",
225
+ data,
226
+ mimeType,
227
+ sourceType: "id",
228
+ filename: "upload.bin",
229
+ providerMetadata: { agui: { trace: "kept" } },
230
+ };
231
+ const message =
232
+ location === "content"
233
+ ? userMessage([part])
234
+ : userMessage([], [{ name: "upload.bin", content: [part] }]);
235
+ const exported = toAgUiMessages([message]);
236
+ const expected = {
237
+ id: "u1",
238
+ role: "user",
239
+ content: [
240
+ {
241
+ type,
242
+ source: { type: "file", value: data, mimeType },
243
+ metadata: { trace: "kept", filename: "upload.bin" },
244
+ },
245
+ ],
246
+ };
247
+
248
+ const imported = fromAgUiMessages(exported);
249
+
250
+ expect(MessageSchema.parse(exported[0])).toEqual(expected);
251
+ expect(imported[0]?.attachments).toEqual([
252
+ expect.objectContaining({
253
+ content: [
254
+ {
255
+ type: "file",
256
+ data,
257
+ mimeType,
258
+ sourceType: "id",
259
+ filename: "upload.bin",
260
+ providerMetadata: { agui: { trace: "kept" } },
261
+ },
262
+ ],
263
+ }),
264
+ ]);
265
+ expect(toAgUiMessages(imported)).toEqual([expected]);
266
+ },
267
+ );
268
+ });
269
+
141
270
  it("emits AgUiMessage values assignable to AG-UI Message", () => {
142
271
  expectTypeOf<AgUiMessage>().toExtend<AgUiWireMessage>();
143
272
  });
@@ -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
+ });