@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
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  getPartialJsonObjectFieldState,
4
4
  type ReadonlyJSONObject,
@@ -8,6 +8,16 @@ import {
8
8
  type AISDKMessageConverterMetadata,
9
9
  } from "./convertMessage";
10
10
 
11
+ const { stableStringifySpy } = vi.hoisted(() => ({
12
+ stableStringifySpy: vi.fn(),
13
+ }));
14
+ vi.mock("@assistant-ui/core/internal", async (importOriginal) => {
15
+ const actual =
16
+ await importOriginal<typeof import("@assistant-ui/core/internal")>();
17
+ stableStringifySpy.mockImplementation(actual.stableStringifyToolArgs);
18
+ return { ...actual, stableStringifyToolArgs: stableStringifySpy };
19
+ });
20
+
11
21
  describe("AISDKMessageConverter", () => {
12
22
  it("flags the streaming assistant message as optimistic", () => {
13
23
  const metadata: AISDKMessageConverterMetadata = {
@@ -34,7 +44,7 @@ describe("AISDKMessageConverter", () => {
34
44
  parts: [{ type: "text", text: "yo" }],
35
45
  metadata: {
36
46
  usage: { inputTokens: 40, outputTokens: 2 },
37
- modelId: "gpt-5.6-luna",
47
+ modelId: "gpt-6-luna",
38
48
  custom: { source: "route" },
39
49
  },
40
50
  },
@@ -42,12 +52,31 @@ describe("AISDKMessageConverter", () => {
42
52
 
43
53
  expect(converted[0]?.metadata.custom).toEqual({
44
54
  usage: { inputTokens: 40, outputTokens: 2 },
45
- modelId: "gpt-5.6-luna",
55
+ modelId: "gpt-6-luna",
46
56
  source: "route",
47
57
  });
48
58
  expect(converted[0]?.metadata).not.toHaveProperty("usage");
49
59
  });
50
60
 
61
+ it("preserves prototype-named custom metadata", () => {
62
+ const metadata = JSON.parse(
63
+ '{"__proto__":{"source":"server"},"constructor":"model"}',
64
+ );
65
+ const converted = AISDKMessageConverter.toThreadMessages([
66
+ {
67
+ id: "a1",
68
+ role: "assistant",
69
+ parts: [{ type: "text", text: "yo" }],
70
+ metadata,
71
+ },
72
+ ] as any);
73
+ const custom = converted[0]?.metadata.custom;
74
+
75
+ expect(Object.hasOwn(custom!, "__proto__")).toBe(true);
76
+ expect(custom!["__proto__"]).toEqual({ source: "server" });
77
+ expect(custom!["constructor"]).toBe("model");
78
+ });
79
+
51
80
  it("keeps modality metadata at the top level", () => {
52
81
  const converted = AISDKMessageConverter.toThreadMessages([
53
82
  {
@@ -553,6 +582,277 @@ describe("AISDKMessageConverter", () => {
553
582
  });
554
583
  });
555
584
 
585
+ it("reads the request from the approval descriptor for a custom response channel", () => {
586
+ const metadata: AISDKMessageConverterMetadata = {
587
+ supportsRichToolApprovalResponses: true,
588
+ };
589
+ const descriptor = {
590
+ prompt: "Which environment?",
591
+ display: "select",
592
+ allowFreeform: true,
593
+ dismissible: true,
594
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
595
+ scope: "deploy",
596
+ };
597
+ const converted = AISDKMessageConverter.toThreadMessages(
598
+ [
599
+ {
600
+ id: "a1",
601
+ role: "assistant",
602
+ parts: [
603
+ {
604
+ type: "tool-deploy",
605
+ toolCallId: "tc-1",
606
+ state: "approval-requested",
607
+ input: {},
608
+ approval: {
609
+ id: "approval-1",
610
+ descriptor,
611
+ requestReason: "Production access requires approval",
612
+ },
613
+ },
614
+ {
615
+ type: "tool-deploy",
616
+ toolCallId: "tc-2",
617
+ state: "approval-responded",
618
+ input: {},
619
+ approval: {
620
+ id: "approval-2",
621
+ approved: true,
622
+ prompt: "Deploy?",
623
+ descriptor: {
624
+ prompt: "Which environment?",
625
+ display: "text",
626
+ optionId: "staging",
627
+ text: "staging only",
628
+ },
629
+ },
630
+ },
631
+ ],
632
+ } as any,
633
+ ],
634
+ false,
635
+ metadata,
636
+ );
637
+
638
+ const approvals = converted[0]?.content.map(
639
+ (part) => (part as { approval?: unknown }).approval,
640
+ );
641
+ expect(approvals).toEqual([
642
+ {
643
+ id: "approval-1",
644
+ prompt: "Which environment?",
645
+ display: "select",
646
+ allowFreeform: true,
647
+ dismissible: true,
648
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
649
+ descriptor,
650
+ requestReason: "Production access requires approval",
651
+ },
652
+ {
653
+ id: "approval-2",
654
+ approved: true,
655
+ prompt: "Deploy?",
656
+ display: "text",
657
+ optionId: "staging",
658
+ text: "staging only",
659
+ descriptor: {
660
+ prompt: "Which environment?",
661
+ display: "text",
662
+ optionId: "staging",
663
+ text: "staging only",
664
+ },
665
+ },
666
+ ]);
667
+ });
668
+
669
+ it("ignores a non-boolean dismissible field from the approval descriptor", () => {
670
+ const metadata: AISDKMessageConverterMetadata = {
671
+ supportsRichToolApprovalResponses: true,
672
+ };
673
+ const descriptor = {
674
+ prompt: "Which environment?",
675
+ display: "select",
676
+ dismissible: "yes",
677
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
678
+ };
679
+ const converted = AISDKMessageConverter.toThreadMessages(
680
+ [
681
+ {
682
+ id: "a1",
683
+ role: "assistant",
684
+ parts: [
685
+ {
686
+ type: "tool-deploy",
687
+ toolCallId: "tc-1",
688
+ state: "approval-requested",
689
+ input: {},
690
+ approval: { id: "approval-1", descriptor },
691
+ },
692
+ ],
693
+ } as any,
694
+ ],
695
+ false,
696
+ metadata,
697
+ );
698
+
699
+ const toolCall = converted[0]?.content.find(
700
+ (part): part is any => part.type === "tool-call",
701
+ );
702
+ expect(toolCall?.approval).toEqual({
703
+ id: "approval-1",
704
+ prompt: "Which environment?",
705
+ display: "select",
706
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
707
+ descriptor,
708
+ });
709
+ expect("dismissible" in toolCall.approval).toBe(false);
710
+ });
711
+
712
+ it("never lets a descriptor decide or identify its own request", () => {
713
+ const metadata: AISDKMessageConverterMetadata = {
714
+ supportsRichToolApprovalResponses: true,
715
+ };
716
+ const converted = AISDKMessageConverter.toThreadMessages(
717
+ [
718
+ {
719
+ id: "a1",
720
+ role: "assistant",
721
+ parts: [
722
+ {
723
+ type: "tool-deploy",
724
+ toolCallId: "tc-1",
725
+ state: "approval-requested",
726
+ input: {},
727
+ approval: {
728
+ id: "approval-1",
729
+ descriptor: {
730
+ id: "approval-9",
731
+ approved: true,
732
+ reason: "self approved",
733
+ isAutomatic: true,
734
+ requestReason: "descriptor reason",
735
+ display: "text",
736
+ },
737
+ },
738
+ },
739
+ {
740
+ type: "tool-deploy",
741
+ toolCallId: "tc-2",
742
+ state: "approval-requested",
743
+ input: {},
744
+ approval: { id: "approval-2", descriptor: ["display", "text"] },
745
+ },
746
+ {
747
+ type: "tool-deploy",
748
+ toolCallId: "tc-3",
749
+ state: "approval-requested",
750
+ input: {},
751
+ approval: { id: "approval-3", descriptor: "display:text" },
752
+ },
753
+ ],
754
+ } as any,
755
+ ],
756
+ false,
757
+ metadata,
758
+ );
759
+
760
+ const approvals = converted[0]?.content.map(
761
+ (part) => (part as { approval?: unknown }).approval,
762
+ );
763
+ expect(approvals).toEqual([
764
+ {
765
+ id: "approval-1",
766
+ display: "text",
767
+ descriptor: {
768
+ id: "approval-9",
769
+ approved: true,
770
+ reason: "self approved",
771
+ isAutomatic: true,
772
+ requestReason: "descriptor reason",
773
+ display: "text",
774
+ },
775
+ },
776
+ { id: "approval-2", descriptor: ["display", "text"] },
777
+ { id: "approval-3", descriptor: "display:text" },
778
+ ]);
779
+ });
780
+
781
+ it("keeps a host answer off a request the descriptor has resolved", () => {
782
+ const metadata: AISDKMessageConverterMetadata = {
783
+ supportsRichToolApprovalResponses: true,
784
+ toolApprovalResponses: new Map([
785
+ ["approval-1", { approvalId: "approval-1", approved: true }],
786
+ ]),
787
+ };
788
+ const converted = AISDKMessageConverter.toThreadMessages(
789
+ [
790
+ {
791
+ id: "a1",
792
+ role: "assistant",
793
+ parts: [
794
+ {
795
+ type: "tool-deploy",
796
+ toolCallId: "tc-1",
797
+ state: "approval-requested",
798
+ input: {},
799
+ approval: {
800
+ id: "approval-1",
801
+ descriptor: { resolution: "expired" },
802
+ },
803
+ },
804
+ ],
805
+ } as any,
806
+ ],
807
+ false,
808
+ metadata,
809
+ );
810
+
811
+ const toolCall = converted[0]?.content.find(
812
+ (part): part is any => part.type === "tool-call",
813
+ );
814
+ expect(toolCall?.approval).toEqual({
815
+ id: "approval-1",
816
+ resolution: "expired",
817
+ descriptor: { resolution: "expired" },
818
+ });
819
+ });
820
+
821
+ it("drops descriptor fields the AI SDK cannot answer without a custom response channel", () => {
822
+ const descriptor = {
823
+ prompt: "Which environment?",
824
+ display: "select",
825
+ allowFreeform: true,
826
+ options: [{ id: "staging", kind: "_target" }],
827
+ resolution: "expired",
828
+ };
829
+ const converted = AISDKMessageConverter.toThreadMessages([
830
+ {
831
+ id: "a1",
832
+ role: "assistant",
833
+ parts: [
834
+ {
835
+ type: "tool-deploy",
836
+ toolCallId: "tc-1",
837
+ state: "approval-requested",
838
+ input: {},
839
+ approval: { id: "approval-1", descriptor },
840
+ },
841
+ ],
842
+ } as any,
843
+ ]);
844
+
845
+ const toolCall = converted[0]?.content.find(
846
+ (part): part is any => part.type === "tool-call",
847
+ );
848
+ expect(toolCall?.approval).toEqual({
849
+ id: "approval-1",
850
+ prompt: "Which environment?",
851
+ resolution: "expired",
852
+ descriptor,
853
+ });
854
+ });
855
+
556
856
  it("applies a host answer to an approval the message has not recorded", () => {
557
857
  const metadata: AISDKMessageConverterMetadata = {
558
858
  supportsRichToolApprovalResponses: true,
@@ -670,6 +970,69 @@ describe("AISDKMessageConverter", () => {
670
970
  expect(toolCall?.argsText).toBe('{"city":"NYC');
671
971
  });
672
972
 
973
+ it("strips exactly the trailing run of closing characters", () => {
974
+ const convertArgsText = (text: string) => {
975
+ stableStringifySpy.mockReturnValueOnce(text);
976
+ const converted = AISDKMessageConverter.toThreadMessages([
977
+ {
978
+ id: "a1",
979
+ role: "assistant",
980
+ parts: [
981
+ {
982
+ type: "tool-weather",
983
+ toolCallId: "tc-1",
984
+ state: "input-streaming",
985
+ input: {},
986
+ },
987
+ ],
988
+ },
989
+ ] as any);
990
+ return (converted[0]?.content[0] as any)?.argsText;
991
+ };
992
+ const alphabet = ["}", "]", '"', "a", "\\", "{", "[", "\n", " ", "😀"];
993
+ let seed = 7;
994
+ const next = () => (seed = (seed * 48271) % 0x7fffffff);
995
+ const texts = ["", "}", '"]}', '{"a":"x"}', '{"a":"}}x"}', "\uD800}"];
996
+ for (let i = 0; i < 300; i++) {
997
+ texts.push(
998
+ Array.from(
999
+ { length: next() % 12 },
1000
+ () => alphabet[next() % alphabet.length],
1001
+ ).join(""),
1002
+ );
1003
+ }
1004
+
1005
+ for (const text of texts) {
1006
+ expect(convertArgsText(text)).toBe(text.replace(/[}\]"]+$/, ""));
1007
+ }
1008
+ });
1009
+
1010
+ it(
1011
+ "strips a long run of closing characters inside streaming input",
1012
+ { timeout: 5_000 },
1013
+ () => {
1014
+ const run = "}".repeat(200_000);
1015
+ const converted = AISDKMessageConverter.toThreadMessages([
1016
+ {
1017
+ id: "a1",
1018
+ role: "assistant",
1019
+ parts: [
1020
+ {
1021
+ type: "tool-weather",
1022
+ toolCallId: "tc-1",
1023
+ state: "input-streaming",
1024
+ input: { code: `${run}x` },
1025
+ },
1026
+ ],
1027
+ },
1028
+ ] as any);
1029
+
1030
+ expect((converted[0]?.content[0] as any)?.argsText).toBe(
1031
+ `{"code":"${run}x`,
1032
+ );
1033
+ },
1034
+ );
1035
+
673
1036
  it("attaches partial-JSON meta marking the trailing streaming field", () => {
674
1037
  const converted = AISDKMessageConverter.toThreadMessages([
675
1038
  {
@@ -986,6 +1349,37 @@ describe("AISDKMessageConverter", () => {
986
1349
  expect(call?.modelContent).toBeUndefined();
987
1350
  });
988
1351
 
1352
+ it.each([
1353
+ ["preliminary", true, true],
1354
+ ["final", undefined, undefined],
1355
+ ])(
1356
+ "marks a %s output-available part on the tool call",
1357
+ (_label, preliminary, isPreliminary) => {
1358
+ const converted = AISDKMessageConverter.toThreadMessages([
1359
+ {
1360
+ id: "a1",
1361
+ role: "assistant",
1362
+ parts: [
1363
+ {
1364
+ type: "tool-weather",
1365
+ toolCallId: "tc-1",
1366
+ state: "output-available",
1367
+ input: { city: "NYC" },
1368
+ output: { temp: 72 },
1369
+ ...(preliminary !== undefined && { preliminary }),
1370
+ },
1371
+ ],
1372
+ } as any,
1373
+ ]);
1374
+
1375
+ const call = converted[0]?.content.find(
1376
+ (part): part is any => part.type === "tool-call",
1377
+ );
1378
+ expect(call?.result).toEqual({ temp: 72 });
1379
+ expect(call?.isPreliminary).toBe(isPreliminary);
1380
+ },
1381
+ );
1382
+
989
1383
  it("forwards callProviderMetadata.mcp.app onto ToolCallMessagePart.mcp.app", () => {
990
1384
  const converted = AISDKMessageConverter.toThreadMessages([
991
1385
  {
@@ -1403,4 +1797,148 @@ describe("AISDKMessageConverter", () => {
1403
1797
  data: { acme: { foo: "bar" } },
1404
1798
  });
1405
1799
  });
1800
+
1801
+ it("preserves failed tool-call arguments from rawInput in the error snapshot", () => {
1802
+ // A tool that streamed complete arguments then failed schema validation
1803
+ // keeps those arguments in `rawInput`, not `input`. Converting from `input`
1804
+ // alone yields `{}`, hiding the real failed input from the UI.
1805
+ const metadata: AISDKMessageConverterMetadata = {
1806
+ toolArgsKeyOrderCache: new Map(),
1807
+ };
1808
+ const converted = AISDKMessageConverter.toThreadMessages(
1809
+ [
1810
+ {
1811
+ id: "a1",
1812
+ role: "assistant",
1813
+ parts: [
1814
+ {
1815
+ type: "tool-weather",
1816
+ toolCallId: "tc-1",
1817
+ state: "output-error",
1818
+ rawInput: { city: "NYC", units: "F" },
1819
+ errorText: "arguments failed schema validation",
1820
+ },
1821
+ ],
1822
+ },
1823
+ ] as any,
1824
+ false,
1825
+ metadata,
1826
+ );
1827
+
1828
+ const toolCall = converted[0]?.content.find(
1829
+ (part): part is any => part.type === "tool-call",
1830
+ );
1831
+ expect(toolCall?.args).toEqual({ city: "NYC", units: "F" });
1832
+ expect(toolCall?.argsText).toBe('{"city":"NYC","units":"F"}');
1833
+ });
1834
+
1835
+ it("releases the key-order entry once the tool call settles", () => {
1836
+ // Arrival order only matters while args stream; a settled input's own key
1837
+ // order is already deterministic, so the entry is released at settlement.
1838
+ const toolArgsKeyOrderCache: NonNullable<
1839
+ AISDKMessageConverterMetadata["toolArgsKeyOrderCache"]
1840
+ > = new Map();
1841
+ const metadata: AISDKMessageConverterMetadata = {
1842
+ toolArgsKeyOrderCache,
1843
+ toolArgsTextCache: new WeakMap(),
1844
+ };
1845
+ const part = (state: string) => ({
1846
+ type: "tool-weather",
1847
+ toolCallId: "tc-1",
1848
+ state,
1849
+ input: { a: 1 },
1850
+ ...(state === "output-available" && { output: { ok: true } }),
1851
+ });
1852
+ const message = (state: string) =>
1853
+ [{ id: "a1", role: "assistant", parts: [part(state)] }] as any;
1854
+
1855
+ AISDKMessageConverter.toThreadMessages(
1856
+ message("input-streaming"),
1857
+ true,
1858
+ metadata,
1859
+ );
1860
+ expect(toolArgsKeyOrderCache.size).toBe(1);
1861
+
1862
+ AISDKMessageConverter.toThreadMessages(
1863
+ message("output-available"),
1864
+ false,
1865
+ metadata,
1866
+ );
1867
+ expect(toolArgsKeyOrderCache.size).toBe(0);
1868
+ });
1869
+
1870
+ it("keeps frozen argsText per tool call when two calls settle on one shared input object", () => {
1871
+ // The frozen text depends on the call's streamed key order, so a shared
1872
+ // input object must not hand one call the other's text: that would turn
1873
+ // its streamed prefix into a non-prefix snapshot the tracker cannot close.
1874
+ const metadata: AISDKMessageConverterMetadata = {
1875
+ toolArgsKeyOrderCache: new Map(),
1876
+ toolArgsTextCache: new WeakMap(),
1877
+ };
1878
+ const tool = (toolCallId: string, state: string, input: object) => ({
1879
+ type: "tool-weather",
1880
+ toolCallId,
1881
+ state,
1882
+ input,
1883
+ ...(state === "output-available" && { output: { ok: true } }),
1884
+ });
1885
+ const convert = (parts: object[]) =>
1886
+ AISDKMessageConverter.toThreadMessages(
1887
+ [{ id: "a1", role: "assistant", parts }] as any,
1888
+ false,
1889
+ metadata,
1890
+ )[0]!.content.filter((part): part is any => part.type === "tool-call");
1891
+
1892
+ convert([
1893
+ tool("tc-a", "input-streaming", { a: 1, b: 2 }),
1894
+ tool("tc-b", "input-streaming", { b: 2, a: 1 }),
1895
+ ]);
1896
+ const shared = { a: 1, b: 2 };
1897
+ const settled = [
1898
+ tool("tc-a", "output-available", shared),
1899
+ tool("tc-b", "output-available", shared),
1900
+ ];
1901
+ const [a, b] = convert(settled);
1902
+
1903
+ expect(a.argsText).toBe('{"a":1,"b":2}');
1904
+ expect(b.argsText).toBe('{"b":2,"a":1}');
1905
+
1906
+ // Both entries must survive a reconversion: the key-order entries are gone
1907
+ // by now, so a cache miss would re-serialize B in raw key order.
1908
+ stableStringifySpy.mockClear();
1909
+ const [a2, b2] = convert(settled);
1910
+ expect(a2.argsText).toBe('{"a":1,"b":2}');
1911
+ expect(b2.argsText).toBe('{"b":2,"a":1}');
1912
+ expect(stableStringifySpy).not.toHaveBeenCalled();
1913
+ });
1914
+
1915
+ it("serializes a settled tool call's argsText once when its input identity is stable", () => {
1916
+ // The frozen text is keyed weakly by the input object itself, so it becomes
1917
+ // collectible once that object is unreachable instead of outliving it.
1918
+ const metadata: AISDKMessageConverterMetadata = {
1919
+ toolArgsKeyOrderCache: new Map(),
1920
+ toolArgsTextCache: new WeakMap(),
1921
+ };
1922
+ const messages = [
1923
+ {
1924
+ id: "a1",
1925
+ role: "assistant",
1926
+ parts: [
1927
+ {
1928
+ type: "tool-weather",
1929
+ toolCallId: "tc-1",
1930
+ state: "output-available",
1931
+ input: { a: 1, b: 2 },
1932
+ output: { ok: true },
1933
+ },
1934
+ ],
1935
+ },
1936
+ ] as any;
1937
+
1938
+ stableStringifySpy.mockClear();
1939
+ for (let i = 0; i < 3; i++) {
1940
+ AISDKMessageConverter.toThreadMessages(messages, false, metadata);
1941
+ }
1942
+ expect(stableStringifySpy).toHaveBeenCalledTimes(1);
1943
+ });
1406
1944
  });