@assistant-ui/ai-sdk 0.0.7 → 0.0.9

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 (115) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1 -1
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +2 -2
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/vercelAttachmentAdapter.d.ts +1 -3
  6. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -1
  7. package/dist/adapters/vercelAttachmentAdapter.js +3 -3
  8. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -1
  9. package/dist/aiSDKExtras.d.ts +4 -6
  10. package/dist/aiSDKExtras.d.ts.map +1 -1
  11. package/dist/converters/convertMessage.d.ts +34 -24
  12. package/dist/converters/convertMessage.d.ts.map +1 -1
  13. package/dist/converters/convertMessage.js +56 -12
  14. package/dist/converters/convertMessage.js.map +1 -1
  15. package/dist/converters/modelContentEnvelope.d.ts +6 -7
  16. package/dist/converters/modelContentEnvelope.d.ts.map +1 -1
  17. package/dist/converters/toCreateMessage.d.ts +2 -4
  18. package/dist/converters/toCreateMessage.d.ts.map +1 -1
  19. package/dist/converters/toolOutputConversion.d.ts +20 -22
  20. package/dist/converters/toolOutputConversion.d.ts.map +1 -1
  21. package/dist/hooks.d.ts +2 -4
  22. package/dist/hooks.d.ts.map +1 -1
  23. package/dist/index.d.ts +17 -14
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.native.d.ts +15 -12
  26. package/dist/index.native.d.ts.map +1 -0
  27. package/dist/model-context/injectInteractableContext.d.ts +2 -4
  28. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  29. package/dist/model-context/injectQuoteContext.d.ts +1 -3
  30. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  31. package/dist/runtime/AISDKChat.d.ts +2 -4
  32. package/dist/runtime/AISDKChat.d.ts.map +1 -1
  33. package/dist/runtime/AISDKChat.js +1 -1
  34. package/dist/runtime/AISDKChat.js.map +1 -1
  35. package/dist/runtime/AISDKThreads.d.ts +29 -31
  36. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  37. package/dist/runtime/AISDKThreads.js +22 -23
  38. package/dist/runtime/AISDKThreads.js.map +1 -1
  39. package/dist/runtime/sdkIdentity.d.ts +1 -3
  40. package/dist/runtime/sdkIdentity.d.ts.map +1 -1
  41. package/dist/runtime/sdkIdentity.js +1 -1
  42. package/dist/runtime/useAISDKRuntime.d.ts +76 -70
  43. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  44. package/dist/runtime/useAISDKRuntime.js +311 -77
  45. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  46. package/dist/runtime/useChatRuntime.d.ts +6 -8
  47. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  48. package/dist/runtime/useChatRuntime.js +14 -10
  49. package/dist/runtime/useChatRuntime.js.map +1 -1
  50. package/dist/runtime/useChatThread.d.ts +94 -89
  51. package/dist/runtime/useChatThread.d.ts.map +1 -1
  52. package/dist/runtime/useChatThread.js +44 -15
  53. package/dist/runtime/useChatThread.js.map +1 -1
  54. package/dist/runtime/useExternalHistory.d.ts +7 -7
  55. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  56. package/dist/runtime/useExternalHistory.js +231 -60
  57. package/dist/runtime/useExternalHistory.js.map +1 -1
  58. package/dist/runtime/useResourceCleanup.d.ts +1 -3
  59. package/dist/runtime/useResourceCleanup.d.ts.map +1 -1
  60. package/dist/runtime/useResourceCleanup.js +38 -14
  61. package/dist/runtime/useResourceCleanup.js.map +1 -1
  62. package/dist/runtime/useStreamingTiming.d.ts +3 -5
  63. package/dist/runtime/useStreamingTiming.d.ts.map +1 -1
  64. package/dist/runtime/useStreamingTiming.js +1 -1
  65. package/dist/tools/frontendTools.d.ts +21 -23
  66. package/dist/tools/frontendTools.d.ts.map +1 -1
  67. package/dist/tools/generativeTools.d.ts +24 -26
  68. package/dist/tools/generativeTools.d.ts.map +1 -1
  69. package/dist/tools/mcp-stdio.node.d.ts +2 -2
  70. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  71. package/dist/tools/mcp-stdio.unsupported.d.ts +2 -4
  72. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -1
  73. package/dist/transport/AssistantChatTransport.d.ts +14 -16
  74. package/dist/transport/AssistantChatTransport.d.ts.map +1 -1
  75. package/dist/transport/AssistantChatTransport.js +9 -2
  76. package/dist/transport/AssistantChatTransport.js.map +1 -1
  77. package/dist/transport/resumable.d.ts +25 -27
  78. package/dist/transport/resumable.d.ts.map +1 -1
  79. package/dist/usage.d.ts +8 -10
  80. package/dist/usage.d.ts.map +1 -1
  81. package/dist/usage.js.map +1 -1
  82. package/dist/utils/getVercelAIMessages.d.ts +2 -4
  83. package/dist/utils/getVercelAIMessages.d.ts.map +1 -1
  84. package/dist/utils/sliceMessagesUntil.d.ts +1 -3
  85. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  86. package/package.json +13 -9
  87. package/src/adapters/vercelAttachmentAdapter.test.ts +23 -0
  88. package/src/adapters/vercelAttachmentAdapter.ts +2 -2
  89. package/src/converters/convertMessage.test.ts +541 -3
  90. package/src/converters/convertMessage.ts +123 -15
  91. package/src/index.native.ts +1 -1
  92. package/src/index.ts +1 -1
  93. package/src/runtime/AISDKChat.ts +0 -4
  94. package/src/runtime/AISDKThreads.cloud.test.ts +60 -2
  95. package/src/runtime/AISDKThreads.test.ts +105 -46
  96. package/src/runtime/AISDKThreads.ts +1 -1
  97. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1639 -19
  98. package/src/runtime/useAISDKRuntime.approval.test.tsx +252 -9
  99. package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +182 -0
  100. package/src/runtime/useAISDKRuntime.test.ts +275 -5
  101. package/src/runtime/useAISDKRuntime.ts +533 -60
  102. package/src/runtime/useAISDKRuntime.voice.test.tsx +50 -1
  103. package/src/runtime/useChatRuntime.fast-refresh.test.tsx +94 -0
  104. package/src/runtime/useChatRuntime.integration.test.tsx +143 -65
  105. package/src/runtime/useChatRuntime.test.ts +11 -7
  106. package/src/runtime/useChatRuntime.ts +6 -2
  107. package/src/runtime/useChatThread.test.ts +166 -2
  108. package/src/runtime/useChatThread.transport.test.tsx +152 -0
  109. package/src/runtime/useChatThread.ts +89 -22
  110. package/src/runtime/useExternalHistory.test.ts +943 -3
  111. package/src/runtime/useExternalHistory.ts +378 -15
  112. package/src/runtime/useResourceCleanup.ts +41 -15
  113. package/src/transport/AssistantChatTransport.test.ts +164 -0
  114. package/src/transport/AssistantChatTransport.ts +22 -2
  115. package/src/usage.ts +1 -1
@@ -10,6 +10,8 @@ import type {
10
10
  ThreadAssistantMessage,
11
11
  ThreadHistoryAdapter,
12
12
  ThreadMessage,
13
+ RespondToToolApprovalOptions,
14
+ Unstable_ToolInteractionLog,
13
15
  } from "@assistant-ui/core";
14
16
 
15
17
  const mocks = vi.hoisted(() => ({
@@ -349,7 +351,12 @@ describe("useExternalHistory persistence", () => {
349
351
  mocks.listeners.clear();
350
352
  });
351
353
 
352
- type InnerMessage = { id: string; parts: string[] };
354
+ type InnerMessage = {
355
+ id: string;
356
+ parts: unknown[];
357
+ role?: string;
358
+ metadata?: Record<string, unknown>;
359
+ };
353
360
 
354
361
  const persistenceStorageFormat: MessageFormatAdapter<
355
362
  InnerMessage,
@@ -393,6 +400,13 @@ describe("useExternalHistory persistence", () => {
393
400
  loadMessages?: MessageFormatRepository<InnerMessage>;
394
401
  toThreadMessages?: (messages: InnerMessage[]) => ThreadMessage[];
395
402
  initialIsRunning?: boolean;
403
+ onSetMessages?: (messages: InnerMessage[]) => void;
404
+ toolArtifacts?: Map<string, unknown>;
405
+ onToolArtifactsRestored?: () => void;
406
+ toolInteractions?: Map<string, Unstable_ToolInteractionLog>;
407
+ onToolInteractionsRestored?: () => void;
408
+ toolApprovalResponses?: Map<string, RespondToToolApprovalOptions>;
409
+ onToolApprovalResponsesRestored?: () => void;
396
410
  },
397
411
  ) => {
398
412
  const append = vi.fn(
@@ -430,13 +444,14 @@ describe("useExternalHistory persistence", () => {
430
444
  let isRunning = options?.initialIsRunning ?? false;
431
445
  let messages: ThreadMessage[] = [];
432
446
  const getState = vi.fn(() => ({ isRunning, messages }));
447
+ const importMessages = vi.fn();
433
448
  const thread = {
434
449
  subscribe: (nextListener: () => void) => {
435
450
  listener = nextListener;
436
451
  return () => {};
437
452
  },
438
453
  getState,
439
- import: vi.fn(),
454
+ import: importMessages,
440
455
  export: vi.fn(() => ({ headId: null, messages: [] })),
441
456
  } as unknown as AssistantRuntime["thread"];
442
457
  const persistenceRuntimeRef = {
@@ -452,7 +467,13 @@ describe("useExternalHistory persistence", () => {
452
467
  historyAdapter,
453
468
  options?.toThreadMessages ?? (() => []),
454
469
  persistenceStorageFormat,
455
- () => {},
470
+ options?.onSetMessages ?? (() => {}),
471
+ options?.toolArtifacts,
472
+ options?.onToolArtifactsRestored,
473
+ options?.toolInteractions,
474
+ options?.onToolInteractionsRestored,
475
+ options?.toolApprovalResponses,
476
+ options?.onToolApprovalResponsesRestored,
456
477
  ),
457
478
  );
458
479
 
@@ -488,9 +509,12 @@ describe("useExternalHistory persistence", () => {
488
509
  update,
489
510
  deleteItems,
490
511
  formattedAdapter,
512
+ importMessages,
491
513
  deleteMessage: result.current.deleteMessage,
492
514
  reportTelemetry,
493
515
  load,
516
+ persistToolInteractions: result.current.persistToolInteractions,
517
+ persistToolApprovalResponses: result.current.persistToolApprovalResponses,
494
518
  runCycle,
495
519
  flush,
496
520
  step,
@@ -498,6 +522,40 @@ describe("useExternalHistory persistence", () => {
498
522
  };
499
523
  };
500
524
 
525
+ it("clears tool data and invokes restore callbacks when loaded history is empty", async () => {
526
+ const toolArtifacts = new Map<string, unknown>([
527
+ ["call-1", { preview: "72°F and sunny" }],
528
+ ]);
529
+ const toolInteractions = new Map<string, Unstable_ToolInteractionLog>([
530
+ ["call-1", { entries: [{ type: "action", occurredAt: 1, payload: {} }] }],
531
+ ]);
532
+ const toolApprovalResponses = new Map<string, RespondToToolApprovalOptions>(
533
+ [["approval-1", { approvalId: "approval-1", approved: true }]],
534
+ );
535
+ const onToolArtifactsRestored = vi.fn();
536
+ const onToolInteractionsRestored = vi.fn();
537
+ const onToolApprovalResponsesRestored = vi.fn();
538
+ const { importMessages, load } = createPersistenceHarness(false, {
539
+ loadMessages: { messages: [] },
540
+ toolArtifacts,
541
+ onToolArtifactsRestored,
542
+ toolInteractions,
543
+ onToolInteractionsRestored,
544
+ toolApprovalResponses,
545
+ onToolApprovalResponsesRestored,
546
+ });
547
+
548
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
549
+
550
+ expect(toolArtifacts).toEqual(new Map());
551
+ expect(toolInteractions).toEqual(new Map());
552
+ expect(toolApprovalResponses).toEqual(new Map());
553
+ expect(onToolArtifactsRestored).toHaveBeenCalledOnce();
554
+ expect(onToolInteractionsRestored).toHaveBeenCalledOnce();
555
+ expect(onToolApprovalResponsesRestored).toHaveBeenCalledOnce();
556
+ expect(importMessages).not.toHaveBeenCalled();
557
+ });
558
+
501
559
  it("persists a message that lands while the thread is idle", async () => {
502
560
  const { append, step } = createPersistenceHarness(false);
503
561
  const message = createAssistantMessage(
@@ -514,6 +572,888 @@ describe("useExternalHistory persistence", () => {
514
572
  });
515
573
  });
516
574
 
575
+ it("updates stored tool artifacts without mutating chat messages", async () => {
576
+ const toolArtifacts = new Map<string, unknown>();
577
+ const { append, update, runCycle } = createPersistenceHarness(true, {
578
+ toolArtifacts,
579
+ });
580
+ const innerMessage: InnerMessage = {
581
+ id: "inner-a",
582
+ role: "assistant",
583
+ parts: [
584
+ {
585
+ type: "tool-weather",
586
+ toolCallId: "call-1",
587
+ state: "output-available",
588
+ input: { city: "New York" },
589
+ output: { temperature: 72 },
590
+ },
591
+ ],
592
+ };
593
+ const content: ThreadAssistantMessage["content"] = [
594
+ {
595
+ type: "tool-call",
596
+ toolCallId: "call-1",
597
+ toolName: "weather",
598
+ args: { city: "New York" },
599
+ argsText: '{"city":"New York"}',
600
+ result: { temperature: 72 },
601
+ isError: false,
602
+ },
603
+ ];
604
+ const message = Object.assign(
605
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
606
+ innerMessage,
607
+ ]),
608
+ { content },
609
+ );
610
+
611
+ await runCycle([message]);
612
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
613
+ expect(append).toHaveBeenCalledWith({
614
+ parentId: null,
615
+ message: innerMessage,
616
+ });
617
+
618
+ toolArtifacts.set("call-1", { preview: "72°F and sunny" });
619
+ await runCycle([message]);
620
+
621
+ await waitFor(() =>
622
+ expect(update).toHaveBeenCalledWith(
623
+ {
624
+ parentId: null,
625
+ message: {
626
+ ...innerMessage,
627
+ metadata: {
628
+ __aui_toolArtifacts: {
629
+ "call-1": { preview: "72°F and sunny" },
630
+ },
631
+ },
632
+ },
633
+ },
634
+ "inner-a",
635
+ ),
636
+ );
637
+ expect(innerMessage.metadata).toBeUndefined();
638
+ });
639
+
640
+ it("keeps tool artifacts out of run telemetry", async () => {
641
+ const toolArtifacts = new Map<string, unknown>([
642
+ ["call-1", { preview: "72°F and sunny" }],
643
+ ]);
644
+ const { append, load, reportTelemetry, runCycle } =
645
+ createPersistenceHarness(false, { toolArtifacts });
646
+ const innerMessage: InnerMessage = {
647
+ id: "inner-a",
648
+ role: "assistant",
649
+ parts: [
650
+ {
651
+ type: "tool-weather",
652
+ toolCallId: "call-1",
653
+ state: "output-available",
654
+ },
655
+ ],
656
+ };
657
+ const message = Object.assign(
658
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
659
+ innerMessage,
660
+ ]),
661
+ {
662
+ content: [
663
+ {
664
+ type: "tool-call" as const,
665
+ toolCallId: "call-1",
666
+ toolName: "weather",
667
+ args: {},
668
+ argsText: "{}",
669
+ result: { temperature: 72 },
670
+ isError: false,
671
+ },
672
+ ],
673
+ },
674
+ );
675
+
676
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
677
+ toolArtifacts.set("call-1", { preview: "72°F and sunny" });
678
+
679
+ await runCycle([message]);
680
+
681
+ await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
682
+ expect(append.mock.calls[0]?.[0].message).toHaveProperty(
683
+ "metadata.__aui_toolArtifacts",
684
+ );
685
+ expect(reportTelemetry.mock.calls[0]?.[0]).toEqual([
686
+ { parentId: null, message: innerMessage },
687
+ ]);
688
+ });
689
+
690
+ it("keeps tool interactions and approval responses out of run telemetry", async () => {
691
+ const toolInteractions = new Map<string, Unstable_ToolInteractionLog>([
692
+ [
693
+ "call-1",
694
+ {
695
+ entries: [
696
+ {
697
+ type: "action",
698
+ occurredAt: 1,
699
+ payload: { refresh: true },
700
+ },
701
+ ],
702
+ },
703
+ ],
704
+ ]);
705
+ const toolApprovalResponses = new Map<string, RespondToToolApprovalOptions>(
706
+ [
707
+ [
708
+ "approval-1",
709
+ { approvalId: "approval-1", approved: true, reason: "Approved" },
710
+ ],
711
+ ],
712
+ );
713
+ const { append, load, reportTelemetry, runCycle } =
714
+ createPersistenceHarness(false, {
715
+ toolInteractions,
716
+ toolApprovalResponses,
717
+ });
718
+ const innerMessage: InnerMessage = {
719
+ id: "inner-a",
720
+ role: "assistant",
721
+ parts: [
722
+ {
723
+ type: "tool-weather",
724
+ toolCallId: "call-1",
725
+ state: "output-available",
726
+ },
727
+ {
728
+ type: "tool-deploy",
729
+ toolCallId: "call-2",
730
+ state: "approval-requested",
731
+ approval: { id: "approval-1" },
732
+ },
733
+ ],
734
+ };
735
+ const message = Object.assign(
736
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
737
+ innerMessage,
738
+ ]),
739
+ {
740
+ content: [
741
+ {
742
+ type: "tool-call" as const,
743
+ toolCallId: "call-1",
744
+ toolName: "weather",
745
+ args: {},
746
+ argsText: "{}",
747
+ result: { temperature: 72 },
748
+ isError: false,
749
+ },
750
+ {
751
+ type: "tool-call" as const,
752
+ toolCallId: "call-2",
753
+ toolName: "deploy",
754
+ args: {},
755
+ argsText: "{}",
756
+ result: undefined,
757
+ isError: false,
758
+ approval: { id: "approval-1" },
759
+ },
760
+ ],
761
+ },
762
+ );
763
+
764
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
765
+ toolInteractions.set("call-1", {
766
+ entries: [
767
+ {
768
+ type: "action",
769
+ occurredAt: 1,
770
+ payload: { refresh: true },
771
+ },
772
+ ],
773
+ });
774
+ toolApprovalResponses.set("approval-1", {
775
+ approvalId: "approval-1",
776
+ approved: true,
777
+ reason: "Approved",
778
+ });
779
+
780
+ await runCycle([message]);
781
+
782
+ await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
783
+ expect(append.mock.calls[0]?.[0].message).toMatchObject({
784
+ metadata: {
785
+ __aui_toolInteractions: { "call-1": toolInteractions.get("call-1") },
786
+ __aui_toolApprovalResponses: {
787
+ "approval-1": { approved: true, reason: "Approved" },
788
+ },
789
+ },
790
+ });
791
+ expect(reportTelemetry.mock.calls[0]?.[0]).toEqual([
792
+ { parentId: null, message: innerMessage },
793
+ ]);
794
+ });
795
+
796
+ it("stores host approval responses with the first message write", async () => {
797
+ const toolApprovalResponses = new Map<string, RespondToToolApprovalOptions>(
798
+ [
799
+ [
800
+ "approval-1",
801
+ {
802
+ approvalId: "approval-1",
803
+ approved: true,
804
+ optionId: "allow-once",
805
+ text: "Staging only",
806
+ reason: "Approved by operator",
807
+ },
808
+ ],
809
+ ],
810
+ );
811
+ const { append, load, runCycle } = createPersistenceHarness(false, {
812
+ toolApprovalResponses,
813
+ });
814
+ const innerMessage: InnerMessage = {
815
+ id: "inner-a",
816
+ role: "assistant",
817
+ parts: [
818
+ {
819
+ type: "tool-deploy",
820
+ toolCallId: "call-1",
821
+ state: "approval-requested",
822
+ approval: { id: "approval-1" },
823
+ },
824
+ ],
825
+ };
826
+ const message = Object.assign(
827
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
828
+ innerMessage,
829
+ ]),
830
+ {
831
+ content: [
832
+ {
833
+ type: "tool-call" as const,
834
+ toolCallId: "call-1",
835
+ toolName: "deploy",
836
+ args: {},
837
+ argsText: "{}",
838
+ result: undefined,
839
+ isError: false,
840
+ approval: { id: "approval-1" },
841
+ },
842
+ ],
843
+ },
844
+ );
845
+
846
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
847
+ toolApprovalResponses.set("approval-1", {
848
+ approvalId: "approval-1",
849
+ approved: true,
850
+ optionId: "allow-once",
851
+ text: "Staging only",
852
+ reason: "Approved by operator",
853
+ });
854
+
855
+ await runCycle([message]);
856
+
857
+ await waitFor(() =>
858
+ expect(append).toHaveBeenCalledWith({
859
+ parentId: null,
860
+ message: {
861
+ ...innerMessage,
862
+ metadata: {
863
+ __aui_toolApprovalResponses: {
864
+ "approval-1": {
865
+ approved: true,
866
+ optionId: "allow-once",
867
+ text: "Staging only",
868
+ reason: "Approved by operator",
869
+ },
870
+ },
871
+ },
872
+ },
873
+ }),
874
+ );
875
+ expect(innerMessage.metadata).toBeUndefined();
876
+ });
877
+
878
+ it("logs and retries a failed host approval response update after a settled run", async () => {
879
+ const toolApprovalResponses = new Map<
880
+ string,
881
+ RespondToToolApprovalOptions
882
+ >();
883
+ const { append, update, persistToolApprovalResponses, runCycle } =
884
+ createPersistenceHarness(true, { toolApprovalResponses });
885
+ const innerMessage: InnerMessage = {
886
+ id: "inner-a",
887
+ role: "assistant",
888
+ parts: [
889
+ {
890
+ type: "tool-deploy",
891
+ toolCallId: "call-1",
892
+ state: "approval-requested",
893
+ approval: { id: "approval-1" },
894
+ },
895
+ ],
896
+ };
897
+ const message = Object.assign(
898
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
899
+ innerMessage,
900
+ ]),
901
+ {
902
+ content: [
903
+ {
904
+ type: "tool-call" as const,
905
+ toolCallId: "call-1",
906
+ toolName: "deploy",
907
+ args: {},
908
+ argsText: "{}",
909
+ result: undefined,
910
+ isError: false,
911
+ approval: { id: "approval-1" },
912
+ },
913
+ ],
914
+ },
915
+ );
916
+
917
+ await runCycle([message]);
918
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
919
+
920
+ toolApprovalResponses.set("approval-1", {
921
+ approvalId: "approval-1",
922
+ approved: false,
923
+ reason: "Not now",
924
+ });
925
+ const failure = new Error("storage unavailable");
926
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
927
+ update.mockRejectedValueOnce(failure);
928
+
929
+ try {
930
+ await act(async () => {
931
+ await persistToolApprovalResponses("inner-a");
932
+ });
933
+
934
+ expect(errorSpy).toHaveBeenCalledWith(
935
+ "Failed to persist tool data:",
936
+ failure,
937
+ );
938
+ await act(async () => {
939
+ await persistToolApprovalResponses("inner-a");
940
+ });
941
+
942
+ expect(update).toHaveBeenCalledTimes(2);
943
+ expect(update).toHaveBeenLastCalledWith(
944
+ {
945
+ parentId: null,
946
+ message: {
947
+ ...innerMessage,
948
+ metadata: {
949
+ __aui_toolApprovalResponses: {
950
+ "approval-1": { approved: false, reason: "Not now" },
951
+ },
952
+ },
953
+ },
954
+ },
955
+ "inner-a",
956
+ );
957
+ expect(append).toHaveBeenCalledTimes(1);
958
+ expect(innerMessage.metadata).toBeUndefined();
959
+ } finally {
960
+ errorSpy.mockRestore();
961
+ }
962
+ });
963
+
964
+ it("stores tool interactions with the first message write", async () => {
965
+ const toolInteractions = new Map<string, Unstable_ToolInteractionLog>([
966
+ [
967
+ "call-1",
968
+ {
969
+ entries: [
970
+ {
971
+ type: "action",
972
+ occurredAt: 1,
973
+ payload: { refresh: true },
974
+ },
975
+ ],
976
+ },
977
+ ],
978
+ ]);
979
+ const { append, load, runCycle } = createPersistenceHarness(false, {
980
+ toolArtifacts: new Map(),
981
+ toolInteractions,
982
+ });
983
+ const innerMessage: InnerMessage = {
984
+ id: "inner-a",
985
+ role: "assistant",
986
+ parts: [
987
+ {
988
+ type: "tool-weather",
989
+ toolCallId: "call-1",
990
+ state: "output-available",
991
+ },
992
+ ],
993
+ };
994
+ const message = Object.assign(
995
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
996
+ innerMessage,
997
+ ]),
998
+ {
999
+ content: [
1000
+ {
1001
+ type: "tool-call" as const,
1002
+ toolCallId: "call-1",
1003
+ toolName: "weather",
1004
+ args: {},
1005
+ argsText: "{}",
1006
+ result: { temperature: 72 },
1007
+ isError: false,
1008
+ },
1009
+ ],
1010
+ },
1011
+ );
1012
+
1013
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
1014
+ toolInteractions.set("call-1", {
1015
+ entries: [
1016
+ {
1017
+ type: "action",
1018
+ occurredAt: 1,
1019
+ payload: { refresh: true },
1020
+ },
1021
+ ],
1022
+ });
1023
+
1024
+ await runCycle([message]);
1025
+
1026
+ await waitFor(() =>
1027
+ expect(append).toHaveBeenCalledWith({
1028
+ parentId: null,
1029
+ message: {
1030
+ ...innerMessage,
1031
+ metadata: {
1032
+ __aui_toolInteractions: {
1033
+ "call-1": toolInteractions.get("call-1"),
1034
+ },
1035
+ },
1036
+ },
1037
+ }),
1038
+ );
1039
+ expect(innerMessage.metadata).toBeUndefined();
1040
+ });
1041
+
1042
+ it("leaves stored rows unchanged without tool artifacts", async () => {
1043
+ const toolArtifacts = new Map<string, unknown>();
1044
+ const { append, runCycle } = createPersistenceHarness(false, {
1045
+ toolArtifacts,
1046
+ });
1047
+ const innerMessage = { id: "inner-a", parts: ["answer"] };
1048
+ const message = createAssistantMessage(
1049
+ { type: "complete", reason: "stop" },
1050
+ [innerMessage],
1051
+ );
1052
+
1053
+ await runCycle([message]);
1054
+
1055
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
1056
+ expect(append).toHaveBeenCalledWith({
1057
+ parentId: null,
1058
+ message: innerMessage,
1059
+ });
1060
+ expect(append.mock.calls[0]?.[0].message).toBe(innerMessage);
1061
+ });
1062
+
1063
+ it("logs and retries a failed tool interaction update after a settled run", async () => {
1064
+ const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
1065
+ const { append, update, persistToolInteractions, runCycle } =
1066
+ createPersistenceHarness(true, { toolInteractions });
1067
+ const initialInnerMessage: InnerMessage = {
1068
+ id: "inner-a",
1069
+ role: "assistant",
1070
+ parts: [
1071
+ {
1072
+ type: "tool-weather",
1073
+ toolCallId: "call-1",
1074
+ state: "output-available",
1075
+ },
1076
+ ],
1077
+ };
1078
+ const createMessage = (innerMessage: InnerMessage) =>
1079
+ Object.assign(
1080
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
1081
+ innerMessage,
1082
+ ]),
1083
+ {
1084
+ content: [
1085
+ {
1086
+ type: "tool-call" as const,
1087
+ toolCallId: "call-1",
1088
+ toolName: "weather",
1089
+ args: {},
1090
+ argsText: "{}",
1091
+ result: undefined,
1092
+ isError: false,
1093
+ },
1094
+ ],
1095
+ },
1096
+ );
1097
+
1098
+ await runCycle([createMessage(initialInnerMessage)]);
1099
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
1100
+
1101
+ const log: Unstable_ToolInteractionLog = {
1102
+ entries: [
1103
+ {
1104
+ type: "human-response",
1105
+ occurredAt: 2,
1106
+ payload: "yes",
1107
+ },
1108
+ ],
1109
+ };
1110
+ toolInteractions.set("call-1", log);
1111
+ const failure = new Error("storage unavailable");
1112
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1113
+ update.mockRejectedValueOnce(failure);
1114
+
1115
+ try {
1116
+ await act(async () => {
1117
+ await persistToolInteractions("assistant-a");
1118
+ });
1119
+
1120
+ expect(errorSpy).toHaveBeenCalledWith(
1121
+ "Failed to persist tool data:",
1122
+ failure,
1123
+ );
1124
+ await act(async () => {
1125
+ await persistToolInteractions("assistant-a");
1126
+ });
1127
+
1128
+ expect(update).toHaveBeenCalledTimes(2);
1129
+ expect(update).toHaveBeenLastCalledWith(
1130
+ {
1131
+ parentId: null,
1132
+ message: {
1133
+ ...initialInnerMessage,
1134
+ metadata: {
1135
+ __aui_toolInteractions: { "call-1": log },
1136
+ },
1137
+ },
1138
+ },
1139
+ "inner-a",
1140
+ );
1141
+ expect(append).toHaveBeenCalledTimes(1);
1142
+
1143
+ const rewrittenInnerMessage = {
1144
+ ...initialInnerMessage,
1145
+ parts: [...initialInnerMessage.parts, { type: "text", text: "done" }],
1146
+ };
1147
+ await runCycle([createMessage(rewrittenInnerMessage)]);
1148
+
1149
+ await waitFor(() =>
1150
+ expect(update).toHaveBeenLastCalledWith(
1151
+ {
1152
+ parentId: null,
1153
+ message: {
1154
+ ...rewrittenInnerMessage,
1155
+ metadata: {
1156
+ __aui_toolInteractions: { "call-1": log },
1157
+ },
1158
+ },
1159
+ },
1160
+ "inner-a",
1161
+ ),
1162
+ );
1163
+ } finally {
1164
+ errorSpy.mockRestore();
1165
+ }
1166
+ });
1167
+
1168
+ it("leaves stored rows unchanged without tool artifacts or interactions", async () => {
1169
+ const toolArtifacts = new Map<string, unknown>();
1170
+ const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
1171
+ const { append, runCycle } = createPersistenceHarness(false, {
1172
+ toolArtifacts,
1173
+ toolInteractions,
1174
+ });
1175
+ const innerMessage = {
1176
+ id: "inner-a",
1177
+ parts: [
1178
+ {
1179
+ type: "tool-weather",
1180
+ toolCallId: "call-1",
1181
+ state: "output-available",
1182
+ },
1183
+ ],
1184
+ };
1185
+ const message = Object.assign(
1186
+ createAssistantMessage({ type: "complete", reason: "stop" }, [
1187
+ innerMessage,
1188
+ ]),
1189
+ {
1190
+ content: [
1191
+ {
1192
+ type: "tool-call" as const,
1193
+ toolCallId: "call-1",
1194
+ toolName: "weather",
1195
+ args: {},
1196
+ argsText: "{}",
1197
+ result: undefined,
1198
+ isError: false,
1199
+ },
1200
+ ],
1201
+ },
1202
+ );
1203
+
1204
+ await runCycle([message]);
1205
+
1206
+ await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
1207
+ expect(append).toHaveBeenCalledWith({
1208
+ parentId: null,
1209
+ message: innerMessage,
1210
+ });
1211
+ expect(append.mock.calls[0]?.[0].message).toBe(innerMessage);
1212
+ });
1213
+
1214
+ it("restores stored tool interactions without returning metadata to the chat", async () => {
1215
+ const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
1216
+ const onSetMessages = vi.fn();
1217
+ const log: Unstable_ToolInteractionLog = {
1218
+ entries: [
1219
+ {
1220
+ type: "human-response",
1221
+ occurredAt: 3,
1222
+ payload: "yes",
1223
+ },
1224
+ ],
1225
+ };
1226
+ const storedMessage: InnerMessage = {
1227
+ id: "inner-a",
1228
+ role: "assistant",
1229
+ parts: [
1230
+ {
1231
+ type: "tool-weather",
1232
+ toolCallId: "call-1",
1233
+ state: "output-available",
1234
+ },
1235
+ ],
1236
+ metadata: {
1237
+ __aui_toolInteractions: { "call-1": log },
1238
+ },
1239
+ };
1240
+ const { importMessages, load } = createPersistenceHarness(true, {
1241
+ loadMessages: {
1242
+ messages: [{ parentId: null, message: storedMessage }],
1243
+ },
1244
+ toThreadMessages: (messages) =>
1245
+ messages.map((message) => {
1246
+ const tool = message.parts[0] as { toolCallId: string };
1247
+ const converted = createAssistantMessage(
1248
+ { type: "complete", reason: "stop" },
1249
+ [message],
1250
+ message.id,
1251
+ ) as ThreadAssistantMessage;
1252
+ return Object.assign(converted, {
1253
+ content: [
1254
+ {
1255
+ type: "tool-call" as const,
1256
+ toolCallId: tool.toolCallId,
1257
+ toolName: "weather",
1258
+ args: {},
1259
+ argsText: "{}",
1260
+ result: undefined,
1261
+ isError: false,
1262
+ unstable_interactions: toolInteractions.get(tool.toolCallId),
1263
+ },
1264
+ ],
1265
+ });
1266
+ }),
1267
+ onSetMessages,
1268
+ toolInteractions,
1269
+ });
1270
+
1271
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
1272
+ await waitFor(() => expect(importMessages).toHaveBeenCalledTimes(1));
1273
+
1274
+ const imported = importMessages.mock.calls[0]?.[0] as {
1275
+ messages: { message: ThreadMessage }[];
1276
+ };
1277
+ expect(imported.messages[0]?.message.content[0]).toMatchObject({
1278
+ type: "tool-call",
1279
+ toolCallId: "call-1",
1280
+ unstable_interactions: log,
1281
+ });
1282
+ expect(toolInteractions.get("call-1")).toEqual(log);
1283
+ expect(onSetMessages.mock.calls[0]?.[0][0]).not.toHaveProperty(
1284
+ "metadata.__aui_toolInteractions",
1285
+ );
1286
+ });
1287
+
1288
+ it("restores host approval responses without returning metadata to the chat", async () => {
1289
+ const toolApprovalResponses = new Map<
1290
+ string,
1291
+ RespondToToolApprovalOptions
1292
+ >();
1293
+ const onSetMessages = vi.fn();
1294
+ const storedMessage: InnerMessage = {
1295
+ id: "inner-a",
1296
+ role: "assistant",
1297
+ parts: [
1298
+ {
1299
+ type: "tool-deploy",
1300
+ toolCallId: "call-1",
1301
+ state: "approval-requested",
1302
+ approval: { id: "approval-1" },
1303
+ },
1304
+ ],
1305
+ metadata: {
1306
+ __aui_toolApprovalResponses: {
1307
+ "approval-1": {
1308
+ approved: true,
1309
+ optionId: "allow-once",
1310
+ text: "Staging only",
1311
+ reason: "Approved by operator",
1312
+ },
1313
+ },
1314
+ },
1315
+ };
1316
+ const { importMessages, load } = createPersistenceHarness(true, {
1317
+ loadMessages: {
1318
+ messages: [{ parentId: null, message: storedMessage }],
1319
+ },
1320
+ toThreadMessages: (messages) =>
1321
+ messages.map((message) => {
1322
+ const tool = message.parts[0] as {
1323
+ toolCallId: string;
1324
+ approval: { id: string };
1325
+ };
1326
+ const response = toolApprovalResponses.get(tool.approval.id);
1327
+ const converted = createAssistantMessage(
1328
+ { type: "complete", reason: "stop" },
1329
+ [message],
1330
+ message.id,
1331
+ ) as ThreadAssistantMessage;
1332
+ return Object.assign(converted, {
1333
+ content: [
1334
+ {
1335
+ type: "tool-call" as const,
1336
+ toolCallId: tool.toolCallId,
1337
+ toolName: "deploy",
1338
+ args: {},
1339
+ argsText: "{}",
1340
+ result: undefined,
1341
+ isError: false,
1342
+ approval: {
1343
+ id: tool.approval.id,
1344
+ ...(response && { approved: response.approved }),
1345
+ ...(response?.optionId && { optionId: response.optionId }),
1346
+ ...(response?.text && { text: response.text }),
1347
+ ...(response?.reason && { reason: response.reason }),
1348
+ },
1349
+ },
1350
+ ],
1351
+ });
1352
+ }),
1353
+ onSetMessages,
1354
+ toolApprovalResponses,
1355
+ });
1356
+
1357
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
1358
+ await waitFor(() => expect(importMessages).toHaveBeenCalledTimes(1));
1359
+
1360
+ const imported = importMessages.mock.calls[0]?.[0] as {
1361
+ messages: { message: ThreadMessage }[];
1362
+ };
1363
+ expect(imported.messages[0]?.message.content[0]).toMatchObject({
1364
+ type: "tool-call",
1365
+ toolCallId: "call-1",
1366
+ approval: {
1367
+ id: "approval-1",
1368
+ approved: true,
1369
+ optionId: "allow-once",
1370
+ text: "Staging only",
1371
+ reason: "Approved by operator",
1372
+ },
1373
+ });
1374
+ expect(toolApprovalResponses.get("approval-1")).toEqual({
1375
+ approvalId: "approval-1",
1376
+ approved: true,
1377
+ optionId: "allow-once",
1378
+ text: "Staging only",
1379
+ reason: "Approved by operator",
1380
+ });
1381
+ expect(onSetMessages.mock.calls[0]?.[0][0]).not.toHaveProperty(
1382
+ "metadata.__aui_toolApprovalResponses",
1383
+ );
1384
+ });
1385
+
1386
+ it("restores stored tool artifacts without returning metadata to the chat", async () => {
1387
+ const toolArtifacts = new Map<string, unknown>();
1388
+ const onSetMessages = vi.fn();
1389
+ const storedMessage: InnerMessage = {
1390
+ id: "inner-a",
1391
+ role: "assistant",
1392
+ parts: [
1393
+ {
1394
+ type: "tool-weather",
1395
+ toolCallId: "call-1",
1396
+ state: "output-available",
1397
+ input: { city: "New York" },
1398
+ output: { temperature: 72 },
1399
+ },
1400
+ ],
1401
+ metadata: {
1402
+ __aui_toolArtifacts: {
1403
+ "call-1": { preview: "72°F and sunny" },
1404
+ },
1405
+ },
1406
+ };
1407
+ const { importMessages, load } = createPersistenceHarness(true, {
1408
+ loadMessages: {
1409
+ messages: [{ parentId: null, message: storedMessage }],
1410
+ },
1411
+ toThreadMessages: (messages) =>
1412
+ messages.map((message) => {
1413
+ const tool = message.parts[0] as { toolCallId: string };
1414
+ const converted = createAssistantMessage(
1415
+ { type: "complete", reason: "stop" },
1416
+ [message],
1417
+ message.id,
1418
+ ) as ThreadAssistantMessage;
1419
+ return Object.assign(converted, {
1420
+ content: [
1421
+ {
1422
+ type: "tool-call" as const,
1423
+ toolCallId: tool.toolCallId,
1424
+ toolName: "weather",
1425
+ args: { city: "New York" },
1426
+ argsText: '{"city":"New York"}',
1427
+ result: { temperature: 72 },
1428
+ isError: false,
1429
+ artifact: toolArtifacts.get(tool.toolCallId),
1430
+ },
1431
+ ],
1432
+ });
1433
+ }),
1434
+ onSetMessages,
1435
+ toolArtifacts,
1436
+ });
1437
+
1438
+ await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
1439
+ await waitFor(() => expect(importMessages).toHaveBeenCalledTimes(1));
1440
+
1441
+ const imported = importMessages.mock.calls[0]?.[0] as {
1442
+ messages: { message: ThreadMessage }[];
1443
+ };
1444
+ expect(imported.messages[0]?.message.content[0]).toMatchObject({
1445
+ type: "tool-call",
1446
+ toolCallId: "call-1",
1447
+ artifact: { preview: "72°F and sunny" },
1448
+ });
1449
+ expect(toolArtifacts.get("call-1")).toEqual({
1450
+ preview: "72°F and sunny",
1451
+ });
1452
+ expect(onSetMessages.mock.calls[0]?.[0][0]).not.toHaveProperty(
1453
+ "metadata.__aui_toolArtifacts",
1454
+ );
1455
+ });
1456
+
517
1457
  it("schedules one idle persistence attempt per landed message when append fails", async () => {
518
1458
  const { append, step, flush } = createPersistenceHarness(false);
519
1459
  append.mockRejectedValueOnce(new Error("offline"));