@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,914 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HttpAgent } from "@ag-ui/client";
3
+ import type { AppendMessage } from "@assistant-ui/core";
4
+ import { AgUiThreadController } from "./AgUiThreadController";
5
+ import { makeLogger } from "./logger";
6
+
7
+ const userMessage: AppendMessage = {
8
+ role: "user",
9
+ content: [{ type: "text", text: "hello" }],
10
+ attachments: [],
11
+ metadata: { custom: {} },
12
+ createdAt: new Date(0),
13
+ parentId: null,
14
+ sourceId: null,
15
+ runConfig: {},
16
+ startRun: true,
17
+ };
18
+
19
+ function createController(events: object[]) {
20
+ const agent = new HttpAgent({
21
+ url: "https://example.invalid",
22
+ fetch: async () =>
23
+ new Response(
24
+ events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
25
+ { headers: { "Content-Type": "text/event-stream" } },
26
+ ),
27
+ });
28
+ return new AgUiThreadController({
29
+ agent,
30
+ logger: makeLogger(),
31
+ showThinking: false,
32
+ });
33
+ }
34
+
35
+ const start = { type: "RUN_STARTED", threadId: "thread", runId: "run" };
36
+ const text = [
37
+ { type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
38
+ {
39
+ type: "TEXT_MESSAGE_CONTENT",
40
+ messageId: "answer",
41
+ delta: "partial answer",
42
+ },
43
+ ];
44
+
45
+ describe("AgUiThreadController HTTP stream closure", () => {
46
+ it.each([false, true])(
47
+ "settles a partial answer when EOF arrives without RUN_FINISHED (text ended: %s)",
48
+ async (textEnded) => {
49
+ const events = [
50
+ start,
51
+ ...text,
52
+ ...(textEnded
53
+ ? [{ type: "TEXT_MESSAGE_END", messageId: "answer" }]
54
+ : []),
55
+ ];
56
+ const core = createController(events);
57
+
58
+ await core.append(userMessage);
59
+
60
+ expect(core.isRunning()).toBe(false);
61
+ expect(core.getMessages().at(-1)).toMatchObject({
62
+ id: "answer",
63
+ content: [{ type: "text", text: "partial answer" }],
64
+ status: { type: "incomplete", reason: "error" },
65
+ });
66
+
67
+ events.splice(
68
+ 0,
69
+ events.length,
70
+ start,
71
+ { type: "TEXT_MESSAGE_START", messageId: "next", role: "assistant" },
72
+ {
73
+ type: "TEXT_MESSAGE_CONTENT",
74
+ messageId: "next",
75
+ delta: "next answer",
76
+ },
77
+ { type: "TEXT_MESSAGE_END", messageId: "next" },
78
+ {
79
+ type: "RUN_FINISHED",
80
+ threadId: "thread",
81
+ runId: "run",
82
+ },
83
+ );
84
+ await core.append({
85
+ ...userMessage,
86
+ parentId: core.getMessages().at(-1)!.id,
87
+ });
88
+ expect(core.isRunning()).toBe(false);
89
+ expect(core.getMessages().at(-1)).toMatchObject({
90
+ id: "next",
91
+ content: [{ type: "text", text: "next answer" }],
92
+ status: { type: "complete" },
93
+ });
94
+ },
95
+ );
96
+
97
+ it("continues from authoritative snapshot text before completing", async () => {
98
+ const core = createController([
99
+ start,
100
+ ...text,
101
+ { type: "CUSTOM", name: "before", value: { retained: true } },
102
+ {
103
+ type: "MESSAGES_SNAPSHOT",
104
+ messages: [
105
+ { id: "answer", role: "assistant", content: "snapshot answer" },
106
+ ],
107
+ },
108
+ {
109
+ type: "TEXT_MESSAGE_CONTENT",
110
+ messageId: "answer",
111
+ delta: " continued",
112
+ },
113
+ { type: "CUSTOM", name: "after", value: { retained: true } },
114
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
115
+ { type: "RUN_FINISHED", threadId: "thread", runId: "run" },
116
+ ]);
117
+
118
+ await core.append(userMessage);
119
+
120
+ expect(core.getMessages().at(-1)).toMatchObject({
121
+ id: "answer",
122
+ content: [
123
+ { type: "text", text: "snapshot answer continued" },
124
+ { type: "data", name: "before", data: { retained: true } },
125
+ { type: "data", name: "after", data: { retained: true } },
126
+ ],
127
+ status: { type: "complete" },
128
+ });
129
+ });
130
+
131
+ it("appends text after a snapshot that precedes the text stream", async () => {
132
+ const core = createController([
133
+ start,
134
+ {
135
+ type: "MESSAGES_SNAPSHOT",
136
+ messages: [
137
+ { id: "answer", role: "assistant", content: "snapshot answer" },
138
+ ],
139
+ },
140
+ { type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
141
+ {
142
+ type: "TEXT_MESSAGE_CONTENT",
143
+ messageId: "answer",
144
+ delta: " continued",
145
+ },
146
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
147
+ { type: "RUN_FINISHED", threadId: "thread", runId: "run" },
148
+ ]);
149
+
150
+ await core.append(userMessage);
151
+
152
+ expect(core.getMessages().at(-1)).toMatchObject({
153
+ id: "answer",
154
+ content: [{ type: "text", text: "snapshot answer continued" }],
155
+ status: { type: "complete" },
156
+ });
157
+ });
158
+
159
+ it("settles a nested assistant when EOF follows a snapshot", async () => {
160
+ const core = createController([
161
+ start,
162
+ ...text,
163
+ { type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
164
+ {
165
+ type: "SUBAGENT_STARTED",
166
+ subagentRunId: "sub",
167
+ name: "worker",
168
+ parentToolCallId: "task",
169
+ },
170
+ {
171
+ type: "TEXT_MESSAGE_START",
172
+ messageId: "sub-answer",
173
+ role: "assistant",
174
+ subagentRunId: "sub",
175
+ },
176
+ {
177
+ type: "TEXT_MESSAGE_CONTENT",
178
+ messageId: "sub-answer",
179
+ delta: "working",
180
+ subagentRunId: "sub",
181
+ },
182
+ { type: "TOOL_CALL_END", toolCallId: "task" },
183
+ {
184
+ type: "TOOL_CALL_RESULT",
185
+ messageId: "task-result",
186
+ toolCallId: "task",
187
+ content: "started",
188
+ role: "tool",
189
+ },
190
+ {
191
+ type: "MESSAGES_SNAPSHOT",
192
+ messages: [
193
+ {
194
+ id: "answer",
195
+ role: "assistant",
196
+ content: "snapshot answer",
197
+ toolCalls: [
198
+ {
199
+ id: "task",
200
+ type: "function",
201
+ function: { name: "task", arguments: "{}" },
202
+ },
203
+ ],
204
+ },
205
+ {
206
+ id: "task-result",
207
+ role: "tool",
208
+ toolCallId: "task",
209
+ content: "started",
210
+ },
211
+ ],
212
+ },
213
+ ]);
214
+
215
+ await core.append(userMessage);
216
+
217
+ expect(core.getMessages().at(-1)).toMatchObject({
218
+ id: "answer",
219
+ status: { type: "incomplete", reason: "error" },
220
+ content: [
221
+ { type: "text", text: "snapshot answer" },
222
+ {
223
+ type: "tool-call",
224
+ toolCallId: "task",
225
+ messages: [
226
+ {
227
+ id: "sub",
228
+ status: { type: "incomplete", reason: "error" },
229
+ content: [{ type: "text", text: "working" }],
230
+ },
231
+ ],
232
+ },
233
+ ],
234
+ });
235
+ });
236
+
237
+ it("settles a nested assistant when a snapshot advances past its step", async () => {
238
+ const core = createController([
239
+ start,
240
+ ...text,
241
+ { type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
242
+ {
243
+ type: "SUBAGENT_STARTED",
244
+ subagentRunId: "sub",
245
+ name: "worker",
246
+ parentToolCallId: "task",
247
+ },
248
+ {
249
+ type: "TEXT_MESSAGE_START",
250
+ messageId: "sub-answer",
251
+ role: "assistant",
252
+ subagentRunId: "sub",
253
+ },
254
+ {
255
+ type: "TEXT_MESSAGE_CONTENT",
256
+ messageId: "sub-answer",
257
+ delta: "working",
258
+ subagentRunId: "sub",
259
+ },
260
+ { type: "TOOL_CALL_END", toolCallId: "task" },
261
+ {
262
+ type: "TOOL_CALL_RESULT",
263
+ messageId: "task-result",
264
+ toolCallId: "task",
265
+ content: "started",
266
+ role: "tool",
267
+ },
268
+ {
269
+ type: "MESSAGES_SNAPSHOT",
270
+ messages: [
271
+ {
272
+ id: "answer",
273
+ role: "assistant",
274
+ content: "snapshot answer",
275
+ toolCalls: [
276
+ {
277
+ id: "task",
278
+ type: "function",
279
+ function: { name: "task", arguments: "{}" },
280
+ },
281
+ ],
282
+ },
283
+ {
284
+ id: "task-result",
285
+ role: "tool",
286
+ toolCallId: "task",
287
+ content: "started",
288
+ },
289
+ { id: "next", role: "assistant", content: "next step" },
290
+ ],
291
+ },
292
+ ]);
293
+
294
+ await core.append(userMessage);
295
+
296
+ const answer = core.getMessages().find(({ id }) => id === "answer");
297
+ expect(answer?.status?.type).toBe("complete");
298
+ expect(answer?.content[1]).toMatchObject({
299
+ type: "tool-call",
300
+ toolCallId: "task",
301
+ messages: [{ id: "sub", status: { type: "complete" } }],
302
+ });
303
+ });
304
+
305
+ it("streams a nested assistant that starts after a snapshot", async () => {
306
+ const core = createController([
307
+ start,
308
+ ...text,
309
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
310
+ { type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
311
+ { type: "TOOL_CALL_END", toolCallId: "task" },
312
+ {
313
+ type: "MESSAGES_SNAPSHOT",
314
+ messages: [
315
+ {
316
+ id: "answer",
317
+ role: "assistant",
318
+ content: "snapshot answer",
319
+ toolCalls: [
320
+ {
321
+ id: "task",
322
+ type: "function",
323
+ function: { name: "task", arguments: "{}" },
324
+ },
325
+ ],
326
+ },
327
+ ],
328
+ },
329
+ {
330
+ type: "SUBAGENT_STARTED",
331
+ subagentRunId: "sub",
332
+ name: "worker",
333
+ parentToolCallId: "task",
334
+ },
335
+ {
336
+ type: "TEXT_MESSAGE_START",
337
+ messageId: "sub-answer",
338
+ role: "assistant",
339
+ subagentRunId: "sub",
340
+ },
341
+ {
342
+ type: "TEXT_MESSAGE_CONTENT",
343
+ messageId: "sub-answer",
344
+ delta: "working",
345
+ subagentRunId: "sub",
346
+ },
347
+ {
348
+ type: "TEXT_MESSAGE_END",
349
+ messageId: "sub-answer",
350
+ subagentRunId: "sub",
351
+ },
352
+ { type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
353
+ { type: "RUN_FINISHED", threadId: "thread", runId: "run" },
354
+ ]);
355
+
356
+ await core.append(userMessage);
357
+
358
+ expect(core.getMessages().at(-1)).toMatchObject({
359
+ id: "answer",
360
+ content: [
361
+ { type: "text", text: "snapshot answer" },
362
+ {
363
+ type: "tool-call",
364
+ toolCallId: "task",
365
+ messages: [
366
+ {
367
+ id: "sub",
368
+ status: { type: "complete" },
369
+ content: [{ type: "text", text: "working" }],
370
+ },
371
+ ],
372
+ },
373
+ ],
374
+ });
375
+ });
376
+
377
+ it("retains snapshot tool calls when later events emit before RUN_FINISHED", async () => {
378
+ const core = createController([
379
+ start,
380
+ ...text,
381
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
382
+ {
383
+ type: "MESSAGES_SNAPSHOT",
384
+ messages: [
385
+ {
386
+ id: "answer",
387
+ role: "assistant",
388
+ content: "snapshot answer",
389
+ toolCalls: [
390
+ {
391
+ id: "lookup",
392
+ type: "function",
393
+ function: { name: "lookup", arguments: "{}" },
394
+ },
395
+ ],
396
+ },
397
+ {
398
+ id: "lookup-result",
399
+ role: "tool",
400
+ toolCallId: "lookup",
401
+ content: "ok",
402
+ },
403
+ ],
404
+ },
405
+ { type: "CUSTOM", name: "after", value: { retained: true } },
406
+ { type: "RUN_FINISHED", threadId: "thread", runId: "run" },
407
+ ]);
408
+
409
+ await core.append(userMessage);
410
+
411
+ expect(core.getMessages().at(-1)).toMatchObject({
412
+ id: "answer",
413
+ content: [
414
+ { type: "text", text: "snapshot answer" },
415
+ { type: "tool-call", toolCallId: "lookup", result: "ok" },
416
+ { type: "data", name: "after", data: { retained: true } },
417
+ ],
418
+ status: { type: "complete" },
419
+ });
420
+ });
421
+
422
+ it("keeps a nested snapshot call pending after RUN_FINISHED", async () => {
423
+ const core = createController([
424
+ start,
425
+ {
426
+ type: "SUBAGENT_STARTED",
427
+ subagentRunId: "sub",
428
+ name: "worker",
429
+ parentToolCallId: "task",
430
+ },
431
+ {
432
+ type: "MESSAGES_SNAPSHOT",
433
+ messages: [
434
+ {
435
+ id: "snapshot-answer",
436
+ role: "assistant",
437
+ content: "snapshot answer",
438
+ toolCalls: [
439
+ {
440
+ id: "task",
441
+ type: "function",
442
+ function: { name: "task", arguments: "{}" },
443
+ },
444
+ ],
445
+ },
446
+ {
447
+ id: "task-result",
448
+ role: "tool",
449
+ toolCallId: "task",
450
+ content: "started",
451
+ },
452
+ {
453
+ id: "sub-answer",
454
+ role: "assistant",
455
+ content: "waiting",
456
+ toolCalls: [
457
+ {
458
+ id: "inner",
459
+ type: "function",
460
+ function: { name: "lookup", arguments: "{}" },
461
+ },
462
+ ],
463
+ subagentRunId: "sub",
464
+ },
465
+ ],
466
+ },
467
+ { type: "SUBAGENT_FINISHED", subagentRunId: "sub" },
468
+ { type: "RUN_FINISHED", threadId: "thread", runId: "run" },
469
+ ]);
470
+
471
+ await core.append(userMessage);
472
+
473
+ expect(core.getMessages().at(-1)?.status).toMatchObject({
474
+ type: "requires-action",
475
+ reason: "tool-calls",
476
+ });
477
+ expect(core.getPendingToolCalls()).toEqual({
478
+ messageId: "snapshot-answer",
479
+ toolCallIds: ["inner"],
480
+ });
481
+ });
482
+
483
+ it("does not reopen a snapshot call excluded by the producer", async () => {
484
+ const core = createController([
485
+ start,
486
+ ...text,
487
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
488
+ {
489
+ type: "MESSAGES_SNAPSHOT",
490
+ messages: [
491
+ {
492
+ id: "answer",
493
+ role: "assistant",
494
+ content: "snapshot answer",
495
+ toolCalls: [
496
+ {
497
+ id: "task",
498
+ type: "function",
499
+ function: { name: "task", arguments: "{}" },
500
+ },
501
+ {
502
+ id: "resolved",
503
+ type: "function",
504
+ function: { name: "lookup", arguments: "{}" },
505
+ },
506
+ ],
507
+ },
508
+ {
509
+ id: "resolved-result",
510
+ role: "tool",
511
+ toolCallId: "resolved",
512
+ content: "done",
513
+ },
514
+ ],
515
+ },
516
+ {
517
+ type: "RUN_FINISHED",
518
+ threadId: "thread",
519
+ runId: "run",
520
+ outcome: { type: "success", pendingToolCallIds: ["resolved"] },
521
+ },
522
+ ]);
523
+
524
+ await core.append(userMessage);
525
+
526
+ expect(core.getMessages().at(-1)?.status).toMatchObject({
527
+ type: "complete",
528
+ reason: "unknown",
529
+ });
530
+ expect(core.getPendingToolCalls()).toBeNull();
531
+ });
532
+
533
+ it("lists only the snapshot calls the producer left pending", async () => {
534
+ const core = createController([
535
+ start,
536
+ {
537
+ type: "MESSAGES_SNAPSHOT",
538
+ messages: [
539
+ {
540
+ id: "answer",
541
+ role: "assistant",
542
+ content: "snapshot answer",
543
+ toolCalls: [
544
+ {
545
+ id: "listed",
546
+ type: "function",
547
+ function: { name: "task", arguments: "{}" },
548
+ },
549
+ {
550
+ id: "unlisted",
551
+ type: "function",
552
+ function: { name: "task", arguments: "{}" },
553
+ },
554
+ ],
555
+ },
556
+ ],
557
+ },
558
+ {
559
+ type: "RUN_FINISHED",
560
+ threadId: "thread",
561
+ runId: "run",
562
+ outcome: { type: "success", pendingToolCallIds: ["listed"] },
563
+ },
564
+ ]);
565
+
566
+ await core.append(userMessage);
567
+
568
+ expect(core.getMessages().at(-1)?.status).toMatchObject({
569
+ type: "requires-action",
570
+ reason: "tool-calls",
571
+ });
572
+ expect(core.getPendingToolCalls()).toEqual({
573
+ messageId: "answer",
574
+ toolCallIds: ["listed"],
575
+ });
576
+ });
577
+
578
+ it.each([false, true])(
579
+ "settles a known answer with a top-level snapshot call (RUN_FINISHED: %s)",
580
+ async (runFinished) => {
581
+ const core = createController([
582
+ start,
583
+ ...text,
584
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
585
+ {
586
+ type: "MESSAGES_SNAPSHOT",
587
+ messages: [
588
+ {
589
+ id: "answer",
590
+ role: "assistant",
591
+ content: "snapshot answer",
592
+ toolCalls: [
593
+ {
594
+ id: "task",
595
+ type: "function",
596
+ function: { name: "task", arguments: "{}" },
597
+ },
598
+ ],
599
+ },
600
+ ],
601
+ },
602
+ ...(runFinished
603
+ ? [{ type: "RUN_FINISHED", threadId: "thread", runId: "run" }]
604
+ : []),
605
+ ]);
606
+
607
+ await core.append(userMessage);
608
+
609
+ expect(core.getMessages().at(-1)?.status).toMatchObject(
610
+ runFinished
611
+ ? { type: "requires-action", reason: "tool-calls" }
612
+ : { type: "incomplete", reason: "error" },
613
+ );
614
+ expect(core.getPendingToolCalls()).toEqual(
615
+ runFinished ? { messageId: "answer", toolCallIds: ["task"] } : null,
616
+ );
617
+ },
618
+ );
619
+
620
+ it.each(["eof", "finished", "error"])(
621
+ "preserves snapshot content when the stream closes (%s)",
622
+ async (terminal) => {
623
+ const core = createController([
624
+ start,
625
+ ...text,
626
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
627
+ {
628
+ type: "MESSAGES_SNAPSHOT",
629
+ messages: [
630
+ { id: "history", role: "assistant", content: "previous answer" },
631
+ { id: "answer", role: "assistant", content: "snapshot answer" },
632
+ ],
633
+ },
634
+ ...(terminal === "finished"
635
+ ? [{ type: "RUN_FINISHED", threadId: "thread", runId: "run" }]
636
+ : terminal === "error"
637
+ ? [{ type: "RUN_ERROR", message: "provider failed" }]
638
+ : []),
639
+ ]);
640
+
641
+ const run = core.append(userMessage);
642
+ if (terminal === "error")
643
+ await expect(run).rejects.toThrow("provider failed");
644
+ else await run;
645
+
646
+ expect(core.isRunning()).toBe(false);
647
+ expect(core.getMessages().at(-1)).toMatchObject({
648
+ id: "answer",
649
+ content: [{ type: "text", text: "snapshot answer" }],
650
+ status:
651
+ terminal === "finished"
652
+ ? { type: "complete" }
653
+ : { type: "incomplete", reason: "error" },
654
+ });
655
+ expect(
656
+ core.getMessages().find((message) => message.id === "history")?.status,
657
+ ).toMatchObject({ type: "complete" });
658
+ },
659
+ );
660
+
661
+ it("keeps an unresolved-call snapshot running until the stream ends", async () => {
662
+ let releaseStream!: () => void;
663
+ const streamCanClose = new Promise<void>((resolve) => {
664
+ releaseStream = resolve;
665
+ });
666
+ const events = [
667
+ start,
668
+ ...text,
669
+ { type: "TEXT_MESSAGE_END", messageId: "answer" },
670
+ { type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
671
+ { type: "TOOL_CALL_END", toolCallId: "task" },
672
+ {
673
+ type: "SUBAGENT_STARTED",
674
+ subagentRunId: "sub",
675
+ name: "worker",
676
+ parentToolCallId: "task",
677
+ },
678
+ {
679
+ type: "TEXT_MESSAGE_START",
680
+ messageId: "sub-answer",
681
+ role: "assistant",
682
+ subagentRunId: "sub",
683
+ },
684
+ {
685
+ type: "TEXT_MESSAGE_CONTENT",
686
+ messageId: "sub-answer",
687
+ delta: "working",
688
+ subagentRunId: "sub",
689
+ },
690
+ {
691
+ type: "TEXT_MESSAGE_END",
692
+ messageId: "sub-answer",
693
+ subagentRunId: "sub",
694
+ },
695
+ {
696
+ type: "MESSAGES_SNAPSHOT",
697
+ messages: [
698
+ {
699
+ id: "answer",
700
+ role: "assistant",
701
+ content: "partial answer",
702
+ toolCalls: [
703
+ {
704
+ id: "task",
705
+ type: "function",
706
+ function: { name: "task", arguments: "{}" },
707
+ },
708
+ ],
709
+ },
710
+ ],
711
+ },
712
+ { type: "STATE_SNAPSHOT", snapshot: { marker: "snapshot-imported" } },
713
+ ];
714
+ const agent = new HttpAgent({
715
+ url: "https://example.invalid",
716
+ fetch: async () =>
717
+ new Response(
718
+ new ReadableStream<Uint8Array>({
719
+ async start(controller) {
720
+ controller.enqueue(
721
+ new TextEncoder().encode(
722
+ events
723
+ .map((event) => `data: ${JSON.stringify(event)}\n\n`)
724
+ .join(""),
725
+ ),
726
+ );
727
+ await streamCanClose;
728
+ controller.close();
729
+ },
730
+ }),
731
+ { headers: { "Content-Type": "text/event-stream" } },
732
+ ),
733
+ });
734
+ const core = new AgUiThreadController({
735
+ agent,
736
+ logger: makeLogger(),
737
+ showThinking: false,
738
+ });
739
+ const snapshotImported = new Promise<void>((resolve) => {
740
+ const unsubscribe = core.subscribe(() => {
741
+ if (
742
+ JSON.stringify(core.getState()) ===
743
+ JSON.stringify({ marker: "snapshot-imported" })
744
+ ) {
745
+ unsubscribe();
746
+ resolve();
747
+ }
748
+ });
749
+ });
750
+
751
+ const run = core.append(userMessage);
752
+ await snapshotImported;
753
+ const answerDuringRun = core
754
+ .getMessages()
755
+ .find(({ id }) => id === "answer");
756
+ const toolCallDuringRun = answerDuringRun?.content.find(
757
+ (part) => part.type === "tool-call",
758
+ );
759
+ const subagentDuringRun =
760
+ toolCallDuringRun?.type === "tool-call"
761
+ ? toolCallDuringRun.messages?.find(({ id }) => id === "sub")
762
+ : undefined;
763
+ const statusDuringRun = {
764
+ isRunning: core.isRunning(),
765
+ answerStatus: answerDuringRun?.status,
766
+ subagentStatus: subagentDuringRun?.status,
767
+ };
768
+
769
+ releaseStream();
770
+ await run;
771
+
772
+ expect(statusDuringRun).toMatchObject({
773
+ isRunning: true,
774
+ answerStatus: { type: "running" },
775
+ subagentStatus: { type: "running" },
776
+ });
777
+ expect(core.isRunning()).toBe(false);
778
+ const answerAfterRun = core.getMessages().find(({ id }) => id === "answer");
779
+ expect(answerAfterRun?.status).toMatchObject({
780
+ type: "incomplete",
781
+ reason: "error",
782
+ });
783
+ const toolCallAfterRun = answerAfterRun?.content.find(
784
+ (part) => part.type === "tool-call",
785
+ );
786
+ const subagentAfterRun =
787
+ toolCallAfterRun?.type === "tool-call"
788
+ ? toolCallAfterRun.messages?.find(({ id }) => id === "sub")
789
+ : undefined;
790
+ expect(subagentAfterRun?.status).toMatchObject({
791
+ type: "incomplete",
792
+ reason: "error",
793
+ });
794
+ });
795
+
796
+ it.each([
797
+ {
798
+ ending: "EOF",
799
+ runFinished: false,
800
+ expectedStatus: { type: "incomplete", reason: "error" },
801
+ },
802
+ {
803
+ ending: "RUN_FINISHED",
804
+ runFinished: true,
805
+ expectedStatus: { type: "requires-action", reason: "tool-calls" },
806
+ },
807
+ ])(
808
+ "tracks an answer introduced only by a snapshot through $ending",
809
+ async ({ runFinished, expectedStatus }) => {
810
+ let releaseStream!: () => void;
811
+ const streamCanClose = new Promise<void>((resolve) => {
812
+ releaseStream = resolve;
813
+ });
814
+ const snapshotAnswer = {
815
+ id: "snapshot-answer",
816
+ role: "assistant",
817
+ content: "Let me check.",
818
+ toolCalls: [
819
+ {
820
+ id: "snapshot-task",
821
+ type: "function",
822
+ function: { name: "task", arguments: "{}" },
823
+ },
824
+ ],
825
+ };
826
+ const agent = new HttpAgent({
827
+ url: "https://example.invalid",
828
+ fetch: async (_url: unknown, init: RequestInit) => {
829
+ const messages = JSON.parse(init.body as string).messages ?? [];
830
+ const events = [
831
+ start,
832
+ {
833
+ type: "MESSAGES_SNAPSHOT",
834
+ messages: [...messages, snapshotAnswer],
835
+ },
836
+ {
837
+ type: "STATE_SNAPSHOT",
838
+ snapshot: { marker: "snapshot-imported" },
839
+ },
840
+ ];
841
+ return new Response(
842
+ new ReadableStream<Uint8Array>({
843
+ async start(controller) {
844
+ controller.enqueue(
845
+ new TextEncoder().encode(
846
+ events
847
+ .map((event) => `data: ${JSON.stringify(event)}\n\n`)
848
+ .join(""),
849
+ ),
850
+ );
851
+ await streamCanClose;
852
+ if (runFinished) {
853
+ controller.enqueue(
854
+ new TextEncoder().encode(
855
+ `data: ${JSON.stringify({
856
+ type: "RUN_FINISHED",
857
+ threadId: "thread",
858
+ runId: "run",
859
+ })}\n\n`,
860
+ ),
861
+ );
862
+ }
863
+ controller.close();
864
+ },
865
+ }),
866
+ { headers: { "Content-Type": "text/event-stream" } },
867
+ );
868
+ },
869
+ });
870
+ const core = new AgUiThreadController({
871
+ agent,
872
+ logger: makeLogger(),
873
+ showThinking: false,
874
+ });
875
+ const snapshotImported = new Promise<void>((resolve) => {
876
+ const unsubscribe = core.subscribe(() => {
877
+ if (
878
+ JSON.stringify(core.getState()) ===
879
+ JSON.stringify({ marker: "snapshot-imported" })
880
+ ) {
881
+ unsubscribe();
882
+ resolve();
883
+ }
884
+ });
885
+ });
886
+
887
+ const run = core.append(userMessage);
888
+ await snapshotImported;
889
+ const answerDuringRun = core
890
+ .getMessages()
891
+ .find(({ id }) => id === "snapshot-answer");
892
+ const statusDuringRun = {
893
+ isRunning: core.isRunning(),
894
+ answerStatus: answerDuringRun?.status,
895
+ };
896
+
897
+ releaseStream();
898
+ await run;
899
+
900
+ expect(
901
+ core.getMessages().find(({ id }) => id === "snapshot-answer")?.status,
902
+ ).toMatchObject(expectedStatus);
903
+ expect(core.getPendingToolCalls()).toEqual(
904
+ runFinished
905
+ ? { messageId: "snapshot-answer", toolCallIds: ["snapshot-task"] }
906
+ : null,
907
+ );
908
+ expect(statusDuringRun).toMatchObject({
909
+ isRunning: true,
910
+ answerStatus: { type: "running" },
911
+ });
912
+ },
913
+ );
914
+ });