@assistant-ui/react-ag-ui 0.1.5 → 0.1.6

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.
@@ -344,3 +344,513 @@ describe("AgUiThreadController snapshot head moves", () => {
344
344
  });
345
345
  });
346
346
  });
347
+
348
+ const question = { id: "question", role: "user", content: "hello" };
349
+ const moveRecords = [
350
+ question,
351
+ { id: "answer", role: "assistant", content: "a1" },
352
+ { id: "other", role: "assistant", content: "o" },
353
+ ];
354
+ const openAnswer = [
355
+ start,
356
+ { type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
357
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a1" },
358
+ ];
359
+ const moveSnapshot = { type: "MESSAGES_SNAPSHOT", messages: moveRecords };
360
+ const answerEnd = { type: "TEXT_MESSAGE_END", messageId: "answer" };
361
+
362
+ const message = (core: AgUiThreadController, id: string) =>
363
+ core.getMessages().find((entry) => entry.id === id);
364
+
365
+ async function sampleBetween(
366
+ head: object[],
367
+ tail: object[],
368
+ sample: (core: AgUiThreadController) => unknown,
369
+ ) {
370
+ const encode = (events: object[]) =>
371
+ new TextEncoder().encode(
372
+ events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
373
+ );
374
+ let releaseTail = () => {};
375
+ const tailReleased = new Promise<void>((resolve) => {
376
+ releaseTail = resolve;
377
+ });
378
+ let headDelivered = () => {};
379
+ const headSent = new Promise<void>((resolve) => {
380
+ headDelivered = resolve;
381
+ });
382
+ const agent = new HttpAgent({
383
+ url: "https://example.invalid",
384
+ fetch: async () =>
385
+ new Response(
386
+ new ReadableStream<Uint8Array>({
387
+ async start(controller) {
388
+ controller.enqueue(encode(head));
389
+ headDelivered();
390
+ await tailReleased;
391
+ controller.enqueue(encode(tail));
392
+ controller.close();
393
+ },
394
+ }),
395
+ { headers: { "Content-Type": "text/event-stream" } },
396
+ ),
397
+ });
398
+ const core = new AgUiThreadController({
399
+ agent,
400
+ logger: makeLogger(),
401
+ showThinking: false,
402
+ onError: () => {},
403
+ });
404
+ const run = core.append(userMessage);
405
+ await headSent;
406
+ for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
407
+ const sampled = sample(core);
408
+ releaseTail();
409
+ await run;
410
+ return { core, sampled };
411
+ }
412
+
413
+ describe("AgUiThreadController events after a snapshot moves the head", () => {
414
+ it("appends later deltas to the answer they name (#10426)", async () => {
415
+ const core = createController(() => [
416
+ ...openAnswer,
417
+ moveSnapshot,
418
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" },
419
+ answerEnd,
420
+ finished,
421
+ ]);
422
+ await core.append(userMessage);
423
+
424
+ expect(message(core, "answer")).toMatchObject({
425
+ content: [{ type: "text", text: "a1a2" }],
426
+ status: { type: "complete" },
427
+ });
428
+ expect(message(core, "other")).toMatchObject({
429
+ content: [{ type: "text", text: "o" }],
430
+ status: { type: "complete" },
431
+ });
432
+ });
433
+
434
+ it("finishes an answer whose events follow the snapshot that introduced it", async () => {
435
+ const core = createController(() => [
436
+ start,
437
+ moveSnapshot,
438
+ { type: "TEXT_MESSAGE_START", messageId: "answer", role: "assistant" },
439
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" },
440
+ answerEnd,
441
+ finished,
442
+ ]);
443
+ await core.append(userMessage);
444
+
445
+ expect(
446
+ core.getMessages().map(({ id, status }) => [id, status?.type ?? null]),
447
+ ).toEqual([
448
+ ["question", null],
449
+ ["answer", "complete"],
450
+ ["other", "complete"],
451
+ ]);
452
+ expect(message(core, "answer")?.content).toEqual([
453
+ { type: "text", text: "a1a2" },
454
+ ]);
455
+ });
456
+
457
+ it("keeps snapshot text on an answer that a later tool call names", async () => {
458
+ const core = createController(() => [
459
+ start,
460
+ moveSnapshot,
461
+ {
462
+ type: "TOOL_CALL_START",
463
+ toolCallId: "lookup",
464
+ toolCallName: "lookup",
465
+ parentMessageId: "answer",
466
+ },
467
+ { type: "TOOL_CALL_ARGS", toolCallId: "lookup", delta: "{}" },
468
+ { type: "TOOL_CALL_END", toolCallId: "lookup" },
469
+ {
470
+ type: "TOOL_CALL_RESULT",
471
+ messageId: "tool-lookup",
472
+ toolCallId: "lookup",
473
+ content: "found",
474
+ },
475
+ finished,
476
+ ]);
477
+ await core.append(userMessage);
478
+
479
+ expect(message(core, "answer")).toMatchObject({
480
+ status: { type: "complete" },
481
+ content: [
482
+ { type: "text", text: "a1" },
483
+ { type: "tool-call", toolCallId: "lookup", result: "found" },
484
+ ],
485
+ });
486
+ });
487
+
488
+ it("does not continue the run for a result the snapshot delivered", async () => {
489
+ let requests = 0;
490
+ const agent = new HttpAgent({
491
+ url: "https://example.invalid",
492
+ fetch: async (_url: unknown, init: RequestInit) => {
493
+ requests++;
494
+ const wire = JSON.parse(init.body as string).messages ?? [];
495
+ const events =
496
+ requests > 1
497
+ ? [start, finished]
498
+ : [
499
+ start,
500
+ ...answerText,
501
+ {
502
+ type: "TOOL_CALL_START",
503
+ toolCallId: "task",
504
+ toolCallName: "task",
505
+ parentMessageId: "answer",
506
+ },
507
+ { type: "TOOL_CALL_ARGS", toolCallId: "task", delta: "{}" },
508
+ { type: "TOOL_CALL_END", toolCallId: "task" },
509
+ snapshot(
510
+ { messages: wire },
511
+ {
512
+ id: "tool-task",
513
+ role: "tool",
514
+ toolCallId: "task",
515
+ content: "done",
516
+ },
517
+ otherRecord,
518
+ ),
519
+ finished,
520
+ ];
521
+ return new Response(
522
+ events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
523
+ { headers: { "Content-Type": "text/event-stream" } },
524
+ );
525
+ },
526
+ });
527
+ const core = new AgUiThreadController({
528
+ agent,
529
+ logger: makeLogger(),
530
+ showThinking: false,
531
+ });
532
+ await core.append(userMessage);
533
+ for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
534
+
535
+ expect(requests).toBe(1);
536
+ expect(statuses(core).answer).toBe("complete");
537
+ expect(taskPartFor(core, "answer")).toMatchObject({ result: "done" });
538
+ });
539
+
540
+ it("settles an answer whose only open work was a sub-agent incomplete when the run errors", async () => {
541
+ const core = createController((wire) => [
542
+ start,
543
+ ...answerText,
544
+ ...taskAndSubagentEvents,
545
+ taskResult,
546
+ snapshot(wire, otherRecord),
547
+ { type: "RUN_ERROR", message: "boom" },
548
+ ]);
549
+ await core.append(userMessage).catch(() => {});
550
+
551
+ expect(statuses(core)).toEqual({
552
+ answer: "incomplete",
553
+ subagent: "incomplete",
554
+ });
555
+ });
556
+
557
+ it("asks for a call only the snapshot carried on the moved-off answer", async () => {
558
+ const core = createController((wire) => [
559
+ start,
560
+ ...answerText,
561
+ snapshot(wire, otherRecord),
562
+ { type: "TEXT_MESSAGE_START", messageId: "other", role: "assistant" },
563
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "other", delta: " More." },
564
+ { type: "TEXT_MESSAGE_END", messageId: "other" },
565
+ finished,
566
+ ]);
567
+ await core.append(userMessage);
568
+
569
+ expect(message(core, "answer")?.status).toMatchObject({
570
+ type: "requires-action",
571
+ reason: "tool-calls",
572
+ });
573
+ expect(core.getPendingToolCalls()).not.toBeNull();
574
+ });
575
+
576
+ it("keeps the answer running while its frame is open", async () => {
577
+ const { core, sampled } = await sampleBetween(
578
+ [...openAnswer, moveSnapshot],
579
+ [
580
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" },
581
+ answerEnd,
582
+ finished,
583
+ ],
584
+ (core) => message(core, "answer")?.status,
585
+ );
586
+
587
+ expect(sampled).toMatchObject({ type: "running" });
588
+ expect(message(core, "answer")).toMatchObject({
589
+ content: [{ type: "text", text: "a1a2" }],
590
+ status: { type: "complete" },
591
+ });
592
+ });
593
+
594
+ it("reopens the answer for a tool call that names it after its frame ended", async () => {
595
+ const lookupStart = {
596
+ type: "TOOL_CALL_START",
597
+ toolCallId: "lookup",
598
+ toolCallName: "lookup",
599
+ parentMessageId: "answer",
600
+ };
601
+ const lookupRest = [
602
+ { type: "TOOL_CALL_ARGS", toolCallId: "lookup", delta: '{"q":1}' },
603
+ { type: "TOOL_CALL_END", toolCallId: "lookup" },
604
+ {
605
+ type: "TOOL_CALL_RESULT",
606
+ messageId: "tool-lookup",
607
+ toolCallId: "lookup",
608
+ content: "found",
609
+ },
610
+ finished,
611
+ ];
612
+ const { core, sampled } = await sampleBetween(
613
+ [...openAnswer, moveSnapshot, answerEnd, lookupStart],
614
+ lookupRest,
615
+ (core) => message(core, "answer")?.status,
616
+ );
617
+
618
+ expect(sampled).toMatchObject({ type: "running" });
619
+ expect(message(core, "answer")).toMatchObject({
620
+ status: { type: "complete" },
621
+ content: [
622
+ { type: "text", text: "a1" },
623
+ {
624
+ type: "tool-call",
625
+ toolCallId: "lookup",
626
+ args: { q: 1 },
627
+ result: "found",
628
+ },
629
+ ],
630
+ });
631
+ expect(message(core, "other")?.content).toEqual([
632
+ { type: "text", text: "o" },
633
+ ]);
634
+ });
635
+
636
+ it("accumulates args for a call started before the move", async () => {
637
+ const core = createController(() => [
638
+ ...openAnswer,
639
+ answerEnd,
640
+ {
641
+ type: "TOOL_CALL_START",
642
+ toolCallId: "lookup",
643
+ toolCallName: "lookup",
644
+ parentMessageId: "answer",
645
+ },
646
+ { type: "TOOL_CALL_ARGS", toolCallId: "lookup", delta: '{"q":' },
647
+ {
648
+ type: "MESSAGES_SNAPSHOT",
649
+ messages: [
650
+ question,
651
+ {
652
+ id: "answer",
653
+ role: "assistant",
654
+ content: "a1",
655
+ toolCalls: [
656
+ {
657
+ id: "lookup",
658
+ type: "function",
659
+ function: { name: "lookup", arguments: '{"q":' },
660
+ },
661
+ ],
662
+ },
663
+ { id: "other", role: "assistant", content: "o" },
664
+ ],
665
+ },
666
+ { type: "TOOL_CALL_ARGS", toolCallId: "lookup", delta: '"x"}' },
667
+ { type: "TOOL_CALL_END", toolCallId: "lookup" },
668
+ {
669
+ type: "TOOL_CALL_RESULT",
670
+ messageId: "tool-lookup",
671
+ toolCallId: "lookup",
672
+ content: "found",
673
+ },
674
+ finished,
675
+ ]);
676
+ await core.append(userMessage);
677
+
678
+ expect(taskPartFor(core, "answer")).toBeUndefined();
679
+ expect(
680
+ message(core, "answer")?.content.find(
681
+ (part) => part.type === "tool-call",
682
+ ),
683
+ ).toMatchObject({
684
+ toolCallId: "lookup",
685
+ argsText: '{"q":"x"}',
686
+ args: { q: "x" },
687
+ result: "found",
688
+ });
689
+ expect(message(core, "answer")?.status?.type).toBe("complete");
690
+ });
691
+
692
+ it.each([
693
+ {
694
+ name: "finish",
695
+ terminal: {
696
+ type: "SUBAGENT_FINISHED",
697
+ subagentRunId: "sub",
698
+ result: "worker result",
699
+ },
700
+ status: { type: "complete", reason: "unknown" },
701
+ metadata: { result: "worker result" },
702
+ },
703
+ {
704
+ name: "error",
705
+ terminal: {
706
+ type: "SUBAGENT_ERROR",
707
+ subagentRunId: "sub",
708
+ message: "worker failed",
709
+ code: "WORKER_FAILED",
710
+ },
711
+ status: {
712
+ type: "incomplete",
713
+ reason: "error",
714
+ error: "worker failed",
715
+ },
716
+ metadata: { errorCode: "WORKER_FAILED" },
717
+ },
718
+ ])(
719
+ "keeps late sub-agent text and $name nested under its call (#10404)",
720
+ async ({ terminal, status, metadata }) => {
721
+ const core = createController((wire) => [
722
+ start,
723
+ ...answerText,
724
+ ...taskAndSubagentEvents,
725
+ snapshot(wire, otherRecord),
726
+ {
727
+ type: "TEXT_MESSAGE_START",
728
+ messageId: "s2",
729
+ role: "assistant",
730
+ subagentRunId: "sub",
731
+ },
732
+ {
733
+ type: "TEXT_MESSAGE_CONTENT",
734
+ messageId: "s2",
735
+ delta: " still working",
736
+ subagentRunId: "sub",
737
+ },
738
+ { type: "TEXT_MESSAGE_END", messageId: "s2", subagentRunId: "sub" },
739
+ terminal,
740
+ taskResult,
741
+ finished,
742
+ ]);
743
+ await core.append(userMessage);
744
+
745
+ expect(taskPartFor(core, "answer")).toMatchObject({
746
+ result: "done",
747
+ messages: [
748
+ {
749
+ id: "sub",
750
+ content: [
751
+ { type: "text", text: "working" },
752
+ { type: "text", text: " still working" },
753
+ ],
754
+ status,
755
+ metadata: { custom: { agui: metadata } },
756
+ },
757
+ ],
758
+ });
759
+ expect(statuses(core)).toEqual({
760
+ answer: "complete",
761
+ subagent: status.type,
762
+ });
763
+ expect(message(core, "other")?.content).toEqual([
764
+ { type: "text", text: "Next step." },
765
+ ]);
766
+ },
767
+ );
768
+
769
+ it("settles a held answer incomplete when the run errors", async () => {
770
+ const core = createController(() => [
771
+ ...openAnswer,
772
+ moveSnapshot,
773
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" },
774
+ { type: "RUN_ERROR", message: "boom" },
775
+ ]);
776
+ await core.append(userMessage).catch(() => {});
777
+
778
+ expect(message(core, "answer")).toMatchObject({
779
+ content: [{ type: "text", text: "a1a2" }],
780
+ status: { type: "incomplete", reason: "error" },
781
+ });
782
+ });
783
+
784
+ it("asks for the result of a call left open on the answer when the run finishes", async () => {
785
+ const core = createController(() => [
786
+ ...openAnswer,
787
+ moveSnapshot,
788
+ answerEnd,
789
+ {
790
+ type: "TOOL_CALL_START",
791
+ toolCallId: "ask",
792
+ toolCallName: "ask",
793
+ parentMessageId: "answer",
794
+ },
795
+ { type: "TOOL_CALL_ARGS", toolCallId: "ask", delta: "{}" },
796
+ { type: "TOOL_CALL_END", toolCallId: "ask" },
797
+ finished,
798
+ ]);
799
+ await core.append(userMessage);
800
+
801
+ expect(message(core, "answer")?.status).toMatchObject({
802
+ type: "requires-action",
803
+ reason: "tool-calls",
804
+ });
805
+ expect(message(core, "other")?.status?.type).toBe("complete");
806
+ });
807
+
808
+ it("keeps a held answer running across a repeated snapshot", async () => {
809
+ const { core, sampled } = await sampleBetween(
810
+ [
811
+ start,
812
+ ...answerText,
813
+ ...taskCallEvents,
814
+ snapshot({ messages: [question] }, otherRecord),
815
+ snapshot({ messages: [question] }, otherRecord),
816
+ ],
817
+ [taskResult, finished],
818
+ (core) => message(core, "answer")?.status,
819
+ );
820
+
821
+ expect(sampled).toMatchObject({ type: "running" });
822
+ expect(message(core, "answer")?.status?.type).toBe("complete");
823
+ });
824
+
825
+ it("control: concat-content keeps streaming into one message", async () => {
826
+ const agent = new HttpAgent({
827
+ url: "https://example.invalid",
828
+ fetch: async () =>
829
+ new Response(
830
+ [
831
+ ...openAnswer,
832
+ moveSnapshot,
833
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "answer", delta: "a2" },
834
+ answerEnd,
835
+ finished,
836
+ ]
837
+ .map((event) => `data: ${JSON.stringify(event)}\n\n`)
838
+ .join(""),
839
+ { headers: { "Content-Type": "text/event-stream" } },
840
+ ),
841
+ });
842
+ const core = new AgUiThreadController({
843
+ agent,
844
+ logger: makeLogger(),
845
+ showThinking: false,
846
+ joinStrategy: "concat-content",
847
+ });
848
+ await core.append(userMessage);
849
+
850
+ const assistants = core
851
+ .getMessages()
852
+ .filter((entry) => entry.role === "assistant");
853
+ expect(assistants).toHaveLength(1);
854
+ expect(assistants[0]?.status?.type).toBe("complete");
855
+ });
856
+ });