@ulearning/ai-sdk-adapter 0.0.0-stage → 1.0.0-next.0

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 (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +6 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +17 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +3 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +36 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +13 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +6 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +44 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +465 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/toCreateMessage.d.ts +4 -0
  20. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  21. package/dist/converters/toCreateMessage.js +65 -0
  22. package/dist/converters/toCreateMessage.js.map +1 -0
  23. package/dist/converters/toolApprovalAnswers.d.ts +3 -0
  24. package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
  25. package/dist/converters/toolApprovalAnswers.js +16 -0
  26. package/dist/converters/toolApprovalAnswers.js.map +1 -0
  27. package/dist/hooks.d.ts +17 -0
  28. package/dist/hooks.d.ts.map +1 -0
  29. package/dist/hooks.js +19 -0
  30. package/dist/hooks.js.map +1 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +13 -0
  34. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  35. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  36. package/dist/model-context/injectInteractableContext.js +54 -0
  37. package/dist/model-context/injectInteractableContext.js.map +1 -0
  38. package/dist/model-context/injectQuoteContext.d.ts +25 -0
  39. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  40. package/dist/model-context/injectQuoteContext.js +51 -0
  41. package/dist/model-context/injectQuoteContext.js.map +1 -0
  42. package/dist/runtime/AISDKChat.d.ts +13 -0
  43. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  44. package/dist/runtime/AISDKChat.js +34 -0
  45. package/dist/runtime/AISDKChat.js.map +1 -0
  46. package/dist/runtime/AISDKThreads.d.ts +24 -0
  47. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  48. package/dist/runtime/AISDKThreads.js +92 -0
  49. package/dist/runtime/AISDKThreads.js.map +1 -0
  50. package/dist/runtime/DynamicChatTransport.d.ts +50 -0
  51. package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
  52. package/dist/runtime/DynamicChatTransport.js +153 -0
  53. package/dist/runtime/DynamicChatTransport.js.map +1 -0
  54. package/dist/runtime/getResumableAdapter.d.ts +5 -0
  55. package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
  56. package/dist/runtime/getResumableAdapter.js +11 -0
  57. package/dist/runtime/getResumableAdapter.js.map +1 -0
  58. package/dist/runtime/toolHistoryCodec.d.ts +20 -0
  59. package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
  60. package/dist/runtime/toolHistoryCodec.js +122 -0
  61. package/dist/runtime/toolHistoryCodec.js.map +1 -0
  62. package/dist/runtime/useAISDKRuntime.d.ts +95 -0
  63. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  64. package/dist/runtime/useAISDKRuntime.js +754 -0
  65. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  66. package/dist/runtime/useChatRuntime.d.ts +17 -0
  67. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  68. package/dist/runtime/useChatRuntime.js +50 -0
  69. package/dist/runtime/useChatRuntime.js.map +1 -0
  70. package/dist/runtime/useChatSnapshot.d.ts +6 -0
  71. package/dist/runtime/useChatSnapshot.d.ts.map +1 -0
  72. package/dist/runtime/useChatSnapshot.js +62 -0
  73. package/dist/runtime/useChatSnapshot.js.map +1 -0
  74. package/dist/runtime/useChatThread.d.ts +121 -0
  75. package/dist/runtime/useChatThread.d.ts.map +1 -0
  76. package/dist/runtime/useChatThread.js +255 -0
  77. package/dist/runtime/useChatThread.js.map +1 -0
  78. package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
  79. package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
  80. package/dist/runtime/useDynamicChatTransport.js +26 -0
  81. package/dist/runtime/useDynamicChatTransport.js.map +1 -0
  82. package/dist/runtime/useExternalHistory.d.ts +12 -0
  83. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  84. package/dist/runtime/useExternalHistory.js +556 -0
  85. package/dist/runtime/useExternalHistory.js.map +1 -0
  86. package/dist/runtime/useMessageQueue.d.ts +14 -0
  87. package/dist/runtime/useMessageQueue.d.ts.map +1 -0
  88. package/dist/runtime/useMessageQueue.js +142 -0
  89. package/dist/runtime/useMessageQueue.js.map +1 -0
  90. package/dist/runtime/useResourceCleanup.d.ts +2 -0
  91. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  92. package/dist/runtime/useResourceCleanup.js +47 -0
  93. package/dist/runtime/useResourceCleanup.js.map +1 -0
  94. package/dist/runtime/useStreamingTiming.d.ts +12 -0
  95. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  96. package/dist/runtime/useStreamingTiming.js +36 -0
  97. package/dist/runtime/useStreamingTiming.js.map +1 -0
  98. package/dist/tools/generativeTools.d.ts +71 -0
  99. package/dist/tools/generativeTools.d.ts.map +1 -0
  100. package/dist/tools/generativeTools.js +258 -0
  101. package/dist/tools/generativeTools.js.map +1 -0
  102. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  103. package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
  104. package/dist/tools/mcp-stdio.node.js +1 -0
  105. package/dist/tools/mcp-stdio.unsupported.d.ts +3 -0
  106. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  107. package/dist/tools/mcp-stdio.unsupported.js +10 -0
  108. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  109. package/dist/transport/AssistantChatTransport.d.ts +20 -0
  110. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  111. package/dist/transport/AssistantChatTransport.js +151 -0
  112. package/dist/transport/AssistantChatTransport.js.map +1 -0
  113. package/dist/transport/resumable.d.ts +36 -0
  114. package/dist/transport/resumable.d.ts.map +1 -0
  115. package/dist/transport/resumable.js +112 -0
  116. package/dist/transport/resumable.js.map +1 -0
  117. package/dist/usage.d.ts +24 -0
  118. package/dist/usage.d.ts.map +1 -0
  119. package/dist/usage.js +121 -0
  120. package/dist/usage.js.map +1 -0
  121. package/dist/utils/getVercelAIMessages.d.ts +4 -0
  122. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  123. package/dist/utils/getVercelAIMessages.js +8 -0
  124. package/dist/utils/getVercelAIMessages.js.map +1 -0
  125. package/dist/utils/sliceMessagesUntil.d.ts +3 -0
  126. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  127. package/dist/utils/sliceMessagesUntil.js +10 -0
  128. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  129. package/package.json +96 -3
  130. package/src/__tests__/standalone.e2e.ts +118 -0
  131. package/src/adapters/aiSDKFormatAdapter.test.ts +36 -0
  132. package/src/adapters/aiSDKFormatAdapter.ts +18 -0
  133. package/src/adapters/vercelAttachmentAdapter.test.ts +52 -0
  134. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  135. package/src/aiSDKExtras.ts +31 -0
  136. package/src/converters/convertMessage.metadata-key.test.tsx +182 -0
  137. package/src/converters/convertMessage.test.ts +2428 -0
  138. package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
  139. package/src/converters/convertMessage.ts +919 -0
  140. package/src/converters/toCreateMessage.test.ts +716 -0
  141. package/src/converters/toCreateMessage.ts +110 -0
  142. package/src/converters/toolApprovalAnswers.ts +27 -0
  143. package/src/hooks.test.ts +168 -0
  144. package/src/hooks.ts +26 -0
  145. package/src/index.ts +29 -0
  146. package/src/model-context/injectInteractableContext.test.ts +167 -0
  147. package/src/model-context/injectInteractableContext.ts +64 -0
  148. package/src/model-context/injectQuoteContext.test.ts +33 -0
  149. package/src/model-context/injectQuoteContext.ts +63 -0
  150. package/src/runtime/AISDKChat.artifacts.test.ts +48 -0
  151. package/src/runtime/AISDKChat.integration.test.tsx +148 -0
  152. package/src/runtime/AISDKChat.test.ts +241 -0
  153. package/src/runtime/AISDKChat.ts +43 -0
  154. package/src/runtime/AISDKThreads.test.ts +811 -0
  155. package/src/runtime/AISDKThreads.ts +194 -0
  156. package/src/runtime/DynamicChatTransport.test.ts +228 -0
  157. package/src/runtime/DynamicChatTransport.ts +281 -0
  158. package/src/runtime/__tests__/controlled-transport.ts +70 -0
  159. package/src/runtime/__tests__/tap.ts +80 -0
  160. package/src/runtime/__tests__/vue.ts +46 -0
  161. package/src/runtime/getResumableAdapter.ts +16 -0
  162. package/src/runtime/toolHistoryCodec.test.ts +187 -0
  163. package/src/runtime/toolHistoryCodec.ts +245 -0
  164. package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1956 -0
  165. package/src/runtime/useAISDKRuntime.approval.test.tsx +580 -0
  166. package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
  167. package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +302 -0
  168. package/src/runtime/useAISDKRuntime.metadata-cache.test.tsx +157 -0
  169. package/src/runtime/useAISDKRuntime.queue.test.tsx +707 -0
  170. package/src/runtime/useAISDKRuntime.reload.test.tsx +217 -0
  171. package/src/runtime/useAISDKRuntime.test.ts +2345 -0
  172. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  173. package/src/runtime/useAISDKRuntime.ts +1503 -0
  174. package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
  175. package/src/runtime/useChatOnTap.test.ts +60 -0
  176. package/src/runtime/useChatRuntime.fast-refresh.test.tsx +83 -0
  177. package/src/runtime/useChatRuntime.integration.test.tsx +661 -0
  178. package/src/runtime/useChatRuntime.local-storage.test.tsx +114 -0
  179. package/src/runtime/useChatRuntime.native.test.ts +147 -0
  180. package/src/runtime/useChatRuntime.run-telemetry.test.tsx +226 -0
  181. package/src/runtime/useChatRuntime.test.ts +619 -0
  182. package/src/runtime/useChatRuntime.ts +80 -0
  183. package/src/runtime/useChatSnapshot.test.ts +60 -0
  184. package/src/runtime/useChatSnapshot.ts +86 -0
  185. package/src/runtime/useChatThread.binding.test.tsx +140 -0
  186. package/src/runtime/useChatThread.test.ts +993 -0
  187. package/src/runtime/useChatThread.transport.test.tsx +149 -0
  188. package/src/runtime/useChatThread.ts +533 -0
  189. package/src/runtime/useDynamicChatTransport.ts +26 -0
  190. package/src/runtime/useExternalHistory.test.ts +4187 -0
  191. package/src/runtime/useExternalHistory.ts +902 -0
  192. package/src/runtime/useMessageQueue.effects.test.tsx +88 -0
  193. package/src/runtime/useMessageQueue.test.tsx +256 -0
  194. package/src/runtime/useMessageQueue.ts +189 -0
  195. package/src/runtime/useResourceCleanup.ts +58 -0
  196. package/src/runtime/useStreamingTiming.test.tsx +96 -0
  197. package/src/runtime/useStreamingTiming.ts +69 -0
  198. package/src/tools/generativeTools.test.ts +1058 -0
  199. package/src/tools/generativeTools.ts +491 -0
  200. package/src/tools/mcp-stdio.node.ts +1 -0
  201. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  202. package/src/transport/AssistantChatTransport.test.ts +631 -0
  203. package/src/transport/AssistantChatTransport.ts +236 -0
  204. package/src/transport/resumable.test.ts +262 -0
  205. package/src/transport/resumable.ts +162 -0
  206. package/src/usage.test.ts +204 -0
  207. package/src/usage.ts +179 -0
  208. package/src/utils/getVercelAIMessages.ts +11 -0
  209. package/src/utils/sliceMessagesUntil.test.ts +51 -0
  210. package/src/utils/sliceMessagesUntil.ts +16 -0
@@ -0,0 +1,2428 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ getPartialJsonObjectFieldState,
4
+ type ReadonlyJSONObject,
5
+ } from "@ulearning/ai-stream/utils";
6
+ import {
7
+ AISDKMessageConverter,
8
+ convertAISDKMessage,
9
+ type AISDKMessageConverterMetadata,
10
+ } from "./convertMessage";
11
+
12
+ const { stableStringifySpy } = vi.hoisted(() => ({
13
+ stableStringifySpy: vi.fn(),
14
+ }));
15
+ vi.mock("@ulearning/ai-core/internal", async (importOriginal) => {
16
+ const actual =
17
+ await importOriginal<typeof import("@ulearning/ai-core/internal")>();
18
+ stableStringifySpy.mockImplementation(actual.stableStringifyToolArgs);
19
+ return { ...actual, stableStringifyToolArgs: stableStringifySpy };
20
+ });
21
+
22
+ describe("AISDKMessageConverter", () => {
23
+ it("flags the streaming assistant message as optimistic", () => {
24
+ const metadata: AISDKMessageConverterMetadata = {
25
+ optimisticMessageId: "a1",
26
+ };
27
+ const converted = AISDKMessageConverter.toThreadMessages(
28
+ [
29
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
30
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
31
+ ] as any,
32
+ true,
33
+ metadata,
34
+ );
35
+
36
+ expect(converted[0]?.metadata.isOptimistic).toBeFalsy();
37
+ expect(converted[1]?.metadata.isOptimistic).toBe(true);
38
+ });
39
+
40
+ it("keeps metadata outside the thread shape reachable under custom", () => {
41
+ const converted = AISDKMessageConverter.toThreadMessages([
42
+ {
43
+ id: "a1",
44
+ role: "assistant",
45
+ parts: [{ type: "text", text: "yo" }],
46
+ metadata: {
47
+ usage: { inputTokens: 40, outputTokens: 2 },
48
+ modelId: "gpt-6-luna",
49
+ custom: { source: "route" },
50
+ },
51
+ },
52
+ ] as any);
53
+
54
+ expect(converted[0]?.metadata.custom).toEqual({
55
+ usage: { inputTokens: 40, outputTokens: 2 },
56
+ modelId: "gpt-6-luna",
57
+ source: "route",
58
+ });
59
+ expect(converted[0]?.metadata).not.toHaveProperty("usage");
60
+ });
61
+
62
+ it("preserves prototype-named custom metadata", () => {
63
+ const metadata = JSON.parse(
64
+ '{"__proto__":{"source":"server"},"constructor":"model"}',
65
+ );
66
+ const converted = AISDKMessageConverter.toThreadMessages([
67
+ {
68
+ id: "a1",
69
+ role: "assistant",
70
+ parts: [{ type: "text", text: "yo" }],
71
+ metadata,
72
+ },
73
+ ] as any);
74
+ const custom = converted[0]?.metadata.custom;
75
+
76
+ expect(Object.hasOwn(custom!, "__proto__")).toBe(true);
77
+ expect(custom!["__proto__"]).toEqual({ source: "server" });
78
+ expect(custom!["constructor"]).toBe("model");
79
+ });
80
+
81
+ it("keeps modality metadata at the top level", () => {
82
+ const converted = AISDKMessageConverter.toThreadMessages([
83
+ {
84
+ id: "a1",
85
+ role: "assistant",
86
+ parts: [{ type: "text", text: "yo" }],
87
+ metadata: { modality: "voice" },
88
+ },
89
+ ] as any);
90
+
91
+ expect(converted[0]?.metadata.modality).toBe("voice");
92
+ expect(converted[0]?.metadata.custom).not.toHaveProperty("modality");
93
+ });
94
+
95
+ it("does not flag messages when no optimistic id is provided", () => {
96
+ const converted = AISDKMessageConverter.toThreadMessages([
97
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
98
+ ] as any);
99
+
100
+ expect(converted[0]?.metadata.isOptimistic).toBeFalsy();
101
+ });
102
+
103
+ it("converts user files into attachments and keeps text content", () => {
104
+ const converted = AISDKMessageConverter.toThreadMessages([
105
+ {
106
+ id: "u1",
107
+ role: "user",
108
+ parts: [
109
+ { type: "text", text: "hello" },
110
+ {
111
+ type: "file",
112
+ mediaType: "image/png",
113
+ url: "https://cdn/img.png",
114
+ filename: "img.png",
115
+ },
116
+ {
117
+ type: "file",
118
+ mediaType: "application/pdf",
119
+ url: "https://cdn/file.pdf",
120
+ filename: "file.pdf",
121
+ },
122
+ ],
123
+ } as any,
124
+ ]);
125
+
126
+ expect(converted).toHaveLength(1);
127
+ expect(converted[0]?.role).toBe("user");
128
+ expect(converted[0]?.content).toHaveLength(1);
129
+ expect(converted[0]?.content[0]).toMatchObject({
130
+ type: "text",
131
+ text: "hello",
132
+ });
133
+ expect(converted[0]?.attachments).toHaveLength(2);
134
+ expect(converted[0]?.attachments?.[0]?.type).toBe("image");
135
+ expect(converted[0]?.attachments?.[1]?.type).toBe("file");
136
+ });
137
+
138
+ it("degrades a user file part missing mediaType instead of throwing", () => {
139
+ const convert = () =>
140
+ AISDKMessageConverter.toThreadMessages([
141
+ {
142
+ id: "u1",
143
+ role: "user",
144
+ parts: [
145
+ {
146
+ type: "file",
147
+ url: "https://cdn/file.bin",
148
+ filename: "file.bin",
149
+ },
150
+ ],
151
+ } as any,
152
+ ]);
153
+
154
+ expect(convert).not.toThrow();
155
+ const attachment = convert()[0]?.attachments?.[0];
156
+ expect(attachment?.type).toBe("file");
157
+ expect(attachment?.contentType).toBe("unknown/unknown");
158
+ expect(attachment?.content[0]).toMatchObject({
159
+ type: "file",
160
+ mimeType: "unknown/unknown",
161
+ });
162
+ });
163
+
164
+ it("converts source-document parts into document sources", () => {
165
+ const converted = AISDKMessageConverter.toThreadMessages([
166
+ {
167
+ id: "a1",
168
+ role: "assistant",
169
+ parts: [
170
+ {
171
+ type: "source-document",
172
+ sourceId: "doc_123",
173
+ title: "proposal.pdf",
174
+ mediaType: "application/pdf",
175
+ filename: "proposal.pdf",
176
+ providerMetadata: {
177
+ openai: {
178
+ type: "file_citation",
179
+ fileId: "file_123",
180
+ index: 0,
181
+ },
182
+ },
183
+ },
184
+ ],
185
+ } as any,
186
+ ]);
187
+
188
+ expect(converted).toHaveLength(1);
189
+ expect(converted[0]?.role).toBe("assistant");
190
+
191
+ const sourcePart = converted[0]?.content.find(
192
+ (part): part is any => part.type === "source",
193
+ );
194
+
195
+ expect(sourcePart).toMatchObject({
196
+ type: "source",
197
+ sourceType: "document",
198
+ id: "doc_123",
199
+ title: "proposal.pdf",
200
+ mediaType: "application/pdf",
201
+ filename: "proposal.pdf",
202
+ providerMetadata: {
203
+ openai: {
204
+ type: "file_citation",
205
+ fileId: "file_123",
206
+ index: 0,
207
+ },
208
+ },
209
+ });
210
+ });
211
+
212
+ it("converts source-url parts without synthesizing missing optional fields", () => {
213
+ const converted = AISDKMessageConverter.toThreadMessages([
214
+ {
215
+ id: "a1",
216
+ role: "assistant",
217
+ parts: [
218
+ {
219
+ type: "source-url",
220
+ sourceId: "url_123",
221
+ url: "https://example.com/report",
222
+ providerMetadata: {
223
+ openai: {
224
+ type: "url_citation",
225
+ index: 1,
226
+ },
227
+ },
228
+ },
229
+ ],
230
+ } as any,
231
+ ]);
232
+
233
+ const sourcePart = converted[0]?.content.find(
234
+ (part): part is any => part.type === "source",
235
+ );
236
+
237
+ expect(sourcePart).toMatchObject({
238
+ type: "source",
239
+ sourceType: "url",
240
+ id: "url_123",
241
+ url: "https://example.com/report",
242
+ providerMetadata: {
243
+ openai: {
244
+ type: "url_citation",
245
+ index: 1,
246
+ },
247
+ },
248
+ });
249
+ expect(sourcePart).not.toHaveProperty("title");
250
+ });
251
+
252
+ it("converts assistant image file parts into file content", () => {
253
+ const converted = AISDKMessageConverter.toThreadMessages([
254
+ {
255
+ id: "a1",
256
+ role: "assistant",
257
+ parts: [
258
+ { type: "text", text: "Here is the image" },
259
+ {
260
+ type: "file",
261
+ mediaType: "image/png",
262
+ url: "https://cdn/generated.png",
263
+ filename: "generated.png",
264
+ },
265
+ ],
266
+ } as any,
267
+ ]);
268
+
269
+ expect(converted).toHaveLength(1);
270
+ expect(converted[0]?.role).toBe("assistant");
271
+ expect(converted[0]?.content).toHaveLength(2);
272
+ expect(converted[0]?.content[0]).toMatchObject({
273
+ type: "text",
274
+ text: "Here is the image",
275
+ });
276
+ expect(converted[0]?.content[1]).toMatchObject({
277
+ type: "file",
278
+ data: "https://cdn/generated.png",
279
+ mimeType: "image/png",
280
+ filename: "generated.png",
281
+ });
282
+ });
283
+
284
+ it("converts assistant non-image file parts into file content", () => {
285
+ const converted = AISDKMessageConverter.toThreadMessages([
286
+ {
287
+ id: "a1",
288
+ role: "assistant",
289
+ parts: [
290
+ { type: "text", text: "Here is the PDF" },
291
+ {
292
+ type: "file",
293
+ mediaType: "application/pdf",
294
+ url: "data:application/pdf;base64,abc123",
295
+ filename: "report.pdf",
296
+ },
297
+ ],
298
+ } as any,
299
+ ]);
300
+
301
+ expect(converted).toHaveLength(1);
302
+ expect(converted[0]?.role).toBe("assistant");
303
+ expect(converted[0]?.content).toHaveLength(2);
304
+ expect(converted[0]?.content[1]).toMatchObject({
305
+ type: "file",
306
+ data: "data:application/pdf;base64,abc123",
307
+ mimeType: "application/pdf",
308
+ filename: "report.pdf",
309
+ });
310
+ });
311
+
312
+ it("deduplicates tool calls by toolCallId and surfaces approval / interrupt state", () => {
313
+ const converted = AISDKMessageConverter.toThreadMessages(
314
+ [
315
+ {
316
+ id: "a1",
317
+ role: "assistant",
318
+ parts: [
319
+ {
320
+ type: "tool-weather",
321
+ toolCallId: "tc-1",
322
+ state: "output-available",
323
+ input: { city: "NYC" },
324
+ output: { temp: 72 },
325
+ },
326
+ {
327
+ type: "tool-weather",
328
+ toolCallId: "tc-1",
329
+ state: "output-available",
330
+ input: { city: "NYC" },
331
+ output: { temp: 73 },
332
+ },
333
+ {
334
+ type: "tool-approve",
335
+ toolCallId: "tc-2",
336
+ state: "approval-requested",
337
+ input: { action: "deploy" },
338
+ approval: { id: "appr-1" },
339
+ },
340
+ {
341
+ type: "tool-human",
342
+ toolCallId: "tc-3",
343
+ state: "input-available",
344
+ input: { task: "confirm" },
345
+ },
346
+ {
347
+ type: "tool-approve",
348
+ toolCallId: "tc-4",
349
+ state: "approval-responded",
350
+ input: { action: "rollback" },
351
+ approval: { id: "appr-2", approved: true, reason: "looks ok" },
352
+ },
353
+ {
354
+ type: "tool-approve",
355
+ toolCallId: "tc-5",
356
+ state: "approval-requested",
357
+ input: { action: "auto" },
358
+ approval: { id: "appr-3", isAutomatic: true },
359
+ },
360
+ {
361
+ type: "tool-approve",
362
+ toolCallId: "tc-6",
363
+ state: "output-denied",
364
+ input: { action: "wipe" },
365
+ approval: {
366
+ id: "appr-4",
367
+ approved: false,
368
+ reason: "user denied",
369
+ },
370
+ },
371
+ {
372
+ type: "tool-approve",
373
+ toolCallId: "tc-7",
374
+ state: "output-available",
375
+ input: { action: "ship" },
376
+ output: { ok: true },
377
+ approval: { id: "appr-5", approved: true, isAutomatic: true },
378
+ },
379
+ ],
380
+ } as any,
381
+ ],
382
+ false,
383
+ {
384
+ toolStatuses: {
385
+ "tc-3": {
386
+ type: "interrupt",
387
+ payload: { type: "human", payload: { kind: "human" } },
388
+ },
389
+ },
390
+ },
391
+ );
392
+
393
+ const toolCalls = converted[0]?.content.filter(
394
+ (part): part is any => part.type === "tool-call",
395
+ );
396
+ expect(toolCalls).toHaveLength(7);
397
+
398
+ expect(toolCalls?.filter((p) => p.toolCallId === "tc-1")).toHaveLength(1);
399
+
400
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-2")?.approval).toEqual({
401
+ id: "appr-1",
402
+ });
403
+ expect(
404
+ toolCalls?.find((p) => p.toolCallId === "tc-2")?.interrupt,
405
+ ).toBeUndefined();
406
+
407
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-3")?.interrupt).toEqual({
408
+ type: "human",
409
+ payload: { kind: "human" },
410
+ });
411
+ expect(
412
+ toolCalls?.find((p) => p.toolCallId === "tc-3")?.approval,
413
+ ).toBeUndefined();
414
+
415
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-4")?.approval).toEqual({
416
+ id: "appr-2",
417
+ approved: true,
418
+ reason: "looks ok",
419
+ });
420
+
421
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-5")?.approval).toEqual({
422
+ id: "appr-3",
423
+ isAutomatic: true,
424
+ });
425
+
426
+ const denied = toolCalls?.find((p) => p.toolCallId === "tc-6");
427
+ expect(denied?.approval).toEqual({
428
+ id: "appr-4",
429
+ approved: false,
430
+ reason: "user denied",
431
+ });
432
+ expect(denied?.isError).toBe(true);
433
+
434
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-7")?.approval).toEqual({
435
+ id: "appr-5",
436
+ approved: true,
437
+ isAutomatic: true,
438
+ });
439
+ });
440
+
441
+ it("preserves producer-defined approval fields and gives prompt precedence", () => {
442
+ const descriptor = { scope: "account:deploy" };
443
+ const converted = AISDKMessageConverter.toThreadMessages([
444
+ {
445
+ id: "a1",
446
+ role: "assistant",
447
+ parts: [
448
+ {
449
+ type: "tool-deploy",
450
+ toolCallId: "tc-1",
451
+ state: "approval-responded",
452
+ input: { environment: "production" },
453
+ approval: {
454
+ id: "approval-1",
455
+ approved: true,
456
+ reason: "approved by operator",
457
+ prompt: "Deploy to production?",
458
+ descriptor,
459
+ requestReason: "Production access requires approval",
460
+ signature: "signed-approval",
461
+ futureField: "preserved",
462
+ },
463
+ },
464
+ ],
465
+ } as any,
466
+ ]);
467
+
468
+ const toolCall = converted[0]?.content.find(
469
+ (part): part is any => part.type === "tool-call",
470
+ );
471
+ expect(toolCall?.approval).toEqual({
472
+ id: "approval-1",
473
+ approved: true,
474
+ reason: "approved by operator",
475
+ prompt: "Deploy to production?",
476
+ descriptor,
477
+ requestReason: "Production access requires approval",
478
+ signature: "signed-approval",
479
+ futureField: "preserved",
480
+ });
481
+ });
482
+
483
+ it("drops fields the AI SDK cannot answer and uses requestReason as the prompt", () => {
484
+ const converted = AISDKMessageConverter.toThreadMessages([
485
+ {
486
+ id: "a1",
487
+ role: "assistant",
488
+ parts: [
489
+ {
490
+ type: "tool-deploy",
491
+ toolCallId: "tc-1",
492
+ state: "approval-requested",
493
+ input: {},
494
+ approval: {
495
+ id: "approval-1",
496
+ display: "select",
497
+ allowFreeform: true,
498
+ options: [{ id: "once", kind: "allow-once" }],
499
+ optionId: "once",
500
+ text: "an answer",
501
+ resolution: "cancelled",
502
+ requestReason: "kept",
503
+ },
504
+ },
505
+ ],
506
+ } as any,
507
+ ]);
508
+
509
+ const toolCall = converted[0]?.content.find(
510
+ (part): part is any => part.type === "tool-call",
511
+ );
512
+ expect(toolCall?.approval).toEqual({
513
+ id: "approval-1",
514
+ prompt: "kept",
515
+ resolution: "cancelled",
516
+ requestReason: "kept",
517
+ });
518
+ });
519
+
520
+ it("preserves rich approval fields for a custom response channel", () => {
521
+ const metadata: AISDKMessageConverterMetadata = {
522
+ supportsRichToolApprovalResponses: true,
523
+ };
524
+ const converted = AISDKMessageConverter.toThreadMessages(
525
+ [
526
+ {
527
+ id: "a1",
528
+ role: "assistant",
529
+ parts: [
530
+ {
531
+ type: "tool-deploy",
532
+ toolCallId: "tc-1",
533
+ state: "approval-responded",
534
+ input: {},
535
+ approval: {
536
+ id: "approval-1",
537
+ display: "select",
538
+ allowFreeform: true,
539
+ options: [
540
+ {
541
+ id: "once",
542
+ kind: "allow-once",
543
+ label: "Only once",
544
+ grants: ["repository", 42],
545
+ confirm: {
546
+ title: "Confirm access",
547
+ description: { invalid: true },
548
+ },
549
+ },
550
+ "invalid",
551
+ { id: 1, kind: "allow-always" },
552
+ { id: "always", kind: 2 },
553
+ ],
554
+ optionId: "once",
555
+ text: "an answer",
556
+ },
557
+ },
558
+ ],
559
+ } as any,
560
+ ],
561
+ false,
562
+ metadata,
563
+ );
564
+
565
+ const toolCall = converted[0]?.content.find(
566
+ (part): part is any => part.type === "tool-call",
567
+ );
568
+ expect(toolCall?.approval).toEqual({
569
+ id: "approval-1",
570
+ display: "select",
571
+ allowFreeform: true,
572
+ options: [
573
+ {
574
+ id: "once",
575
+ kind: "allow-once",
576
+ label: "Only once",
577
+ grants: ["repository"],
578
+ confirm: { title: "Confirm access" },
579
+ },
580
+ ],
581
+ optionId: "once",
582
+ text: "an answer",
583
+ });
584
+ });
585
+
586
+ it("reads the request from the approval descriptor for a custom response channel", () => {
587
+ const metadata: AISDKMessageConverterMetadata = {
588
+ supportsRichToolApprovalResponses: true,
589
+ };
590
+ const descriptor = {
591
+ prompt: "Which environment?",
592
+ display: "select",
593
+ allowFreeform: true,
594
+ dismissible: true,
595
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
596
+ scope: "deploy",
597
+ };
598
+ const converted = AISDKMessageConverter.toThreadMessages(
599
+ [
600
+ {
601
+ id: "a1",
602
+ role: "assistant",
603
+ parts: [
604
+ {
605
+ type: "tool-deploy",
606
+ toolCallId: "tc-1",
607
+ state: "approval-requested",
608
+ input: {},
609
+ approval: {
610
+ id: "approval-1",
611
+ descriptor,
612
+ requestReason: "Production access requires approval",
613
+ },
614
+ },
615
+ {
616
+ type: "tool-deploy",
617
+ toolCallId: "tc-2",
618
+ state: "approval-responded",
619
+ input: {},
620
+ approval: {
621
+ id: "approval-2",
622
+ approved: true,
623
+ prompt: "Deploy?",
624
+ descriptor: {
625
+ prompt: "Which environment?",
626
+ display: "text",
627
+ optionId: "staging",
628
+ text: "staging only",
629
+ },
630
+ },
631
+ },
632
+ ],
633
+ } as any,
634
+ ],
635
+ false,
636
+ metadata,
637
+ );
638
+
639
+ const approvals = converted[0]?.content.map(
640
+ (part) => (part as { approval?: unknown }).approval,
641
+ );
642
+ expect(approvals).toEqual([
643
+ {
644
+ id: "approval-1",
645
+ prompt: "Which environment?",
646
+ display: "select",
647
+ allowFreeform: true,
648
+ dismissible: true,
649
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
650
+ descriptor,
651
+ requestReason: "Production access requires approval",
652
+ },
653
+ {
654
+ id: "approval-2",
655
+ approved: true,
656
+ prompt: "Deploy?",
657
+ display: "text",
658
+ optionId: "staging",
659
+ text: "staging only",
660
+ descriptor: {
661
+ prompt: "Which environment?",
662
+ display: "text",
663
+ optionId: "staging",
664
+ text: "staging only",
665
+ },
666
+ },
667
+ ]);
668
+ });
669
+
670
+ it("reads questions and recorded answers from the approval descriptor for a custom response channel", () => {
671
+ const questions = [
672
+ {
673
+ id: "scope",
674
+ prompt: "Which files?",
675
+ header: "Scope",
676
+ options: [
677
+ { id: "src", label: "src", description: "Sources" },
678
+ { id: 1, label: "invalid" },
679
+ ],
680
+ multiple: true,
681
+ },
682
+ { id: "note", prompt: "Anything else?", allowFreeform: "yes" },
683
+ { prompt: "No id" },
684
+ ];
685
+ const parts = [
686
+ {
687
+ type: "tool-ask",
688
+ toolCallId: "tc-1",
689
+ state: "approval-requested",
690
+ input: {},
691
+ approval: {
692
+ id: "approval-1",
693
+ descriptor: { display: "questions", questions },
694
+ },
695
+ },
696
+ {
697
+ type: "tool-ask",
698
+ toolCallId: "tc-2",
699
+ state: "approval-responded",
700
+ input: {},
701
+ approval: {
702
+ id: "approval-2",
703
+ approved: true,
704
+ descriptor: {
705
+ display: "questions",
706
+ questions: [{ id: "note", prompt: "Anything else?" }],
707
+ answers: {
708
+ note: { text: "no", optionIds: [1, "x"] },
709
+ bad: "x",
710
+ empty: { optionIds: [1] },
711
+ },
712
+ },
713
+ },
714
+ },
715
+ ];
716
+
717
+ const convert = (supportsRichToolApprovalResponses: boolean) => {
718
+ const metadata: AISDKMessageConverterMetadata = {
719
+ supportsRichToolApprovalResponses,
720
+ };
721
+ return AISDKMessageConverter.toThreadMessages(
722
+ [{ id: "a1", role: "assistant", parts } as any],
723
+ false,
724
+ metadata,
725
+ )[0]?.content.map((part) => (part as { approval?: any }).approval);
726
+ };
727
+
728
+ const [requested, responded] = convert(true)!;
729
+ expect(requested.display).toBe("questions");
730
+ expect(requested.questions).toEqual([
731
+ {
732
+ id: "scope",
733
+ prompt: "Which files?",
734
+ header: "Scope",
735
+ options: [{ id: "src", label: "src", description: "Sources" }],
736
+ multiple: true,
737
+ },
738
+ { id: "note", prompt: "Anything else?" },
739
+ ]);
740
+ expect(responded.approved).toBe(true);
741
+ expect(responded.answers).toEqual({
742
+ note: { text: "no", optionIds: ["x"] },
743
+ });
744
+ expect(
745
+ convertWith(JSON.parse('{"__proto__":{"text":"kept"}}')).answers
746
+ .__proto__,
747
+ ).toEqual({ text: "kept" });
748
+
749
+ function convertWith(answers: unknown) {
750
+ const metadata: AISDKMessageConverterMetadata = {
751
+ supportsRichToolApprovalResponses: true,
752
+ };
753
+ return AISDKMessageConverter.toThreadMessages(
754
+ [
755
+ {
756
+ id: "a2",
757
+ role: "assistant",
758
+ parts: [
759
+ {
760
+ type: "tool-ask",
761
+ toolCallId: "tc-3",
762
+ state: "approval-responded",
763
+ input: {},
764
+ approval: {
765
+ id: "approval-3",
766
+ approved: true,
767
+ descriptor: { display: "questions", answers },
768
+ },
769
+ },
770
+ ],
771
+ } as any,
772
+ ],
773
+ false,
774
+ metadata,
775
+ )[0]?.content.map((part) => (part as { approval?: any }).approval)[0];
776
+ }
777
+
778
+ const [builtIn] = convert(false)!;
779
+ expect(builtIn).not.toHaveProperty("display");
780
+ expect(builtIn).not.toHaveProperty("questions");
781
+ });
782
+
783
+ it("ignores a non-boolean dismissible field from the approval descriptor", () => {
784
+ const metadata: AISDKMessageConverterMetadata = {
785
+ supportsRichToolApprovalResponses: true,
786
+ };
787
+ const descriptor = {
788
+ prompt: "Which environment?",
789
+ display: "select",
790
+ dismissible: "yes",
791
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
792
+ };
793
+ const converted = AISDKMessageConverter.toThreadMessages(
794
+ [
795
+ {
796
+ id: "a1",
797
+ role: "assistant",
798
+ parts: [
799
+ {
800
+ type: "tool-deploy",
801
+ toolCallId: "tc-1",
802
+ state: "approval-requested",
803
+ input: {},
804
+ approval: { id: "approval-1", descriptor },
805
+ },
806
+ ],
807
+ } as any,
808
+ ],
809
+ false,
810
+ metadata,
811
+ );
812
+
813
+ const toolCall = converted[0]?.content.find(
814
+ (part): part is any => part.type === "tool-call",
815
+ );
816
+ expect(toolCall?.approval).toEqual({
817
+ id: "approval-1",
818
+ prompt: "Which environment?",
819
+ display: "select",
820
+ options: [{ id: "staging", kind: "_target", label: "Staging" }],
821
+ descriptor,
822
+ });
823
+ expect("dismissible" in toolCall.approval).toBe(false);
824
+ });
825
+
826
+ it("never lets a descriptor decide or identify its own request", () => {
827
+ const metadata: AISDKMessageConverterMetadata = {
828
+ supportsRichToolApprovalResponses: true,
829
+ };
830
+ const converted = AISDKMessageConverter.toThreadMessages(
831
+ [
832
+ {
833
+ id: "a1",
834
+ role: "assistant",
835
+ parts: [
836
+ {
837
+ type: "tool-deploy",
838
+ toolCallId: "tc-1",
839
+ state: "approval-requested",
840
+ input: {},
841
+ approval: {
842
+ id: "approval-1",
843
+ descriptor: {
844
+ id: "approval-9",
845
+ approved: true,
846
+ reason: "self approved",
847
+ isAutomatic: true,
848
+ requestReason: "descriptor reason",
849
+ display: "text",
850
+ },
851
+ },
852
+ },
853
+ {
854
+ type: "tool-deploy",
855
+ toolCallId: "tc-2",
856
+ state: "approval-requested",
857
+ input: {},
858
+ approval: { id: "approval-2", descriptor: ["display", "text"] },
859
+ },
860
+ {
861
+ type: "tool-deploy",
862
+ toolCallId: "tc-3",
863
+ state: "approval-requested",
864
+ input: {},
865
+ approval: { id: "approval-3", descriptor: "display:text" },
866
+ },
867
+ ],
868
+ } as any,
869
+ ],
870
+ false,
871
+ metadata,
872
+ );
873
+
874
+ const approvals = converted[0]?.content.map(
875
+ (part) => (part as { approval?: unknown }).approval,
876
+ );
877
+ expect(approvals).toEqual([
878
+ {
879
+ id: "approval-1",
880
+ display: "text",
881
+ descriptor: {
882
+ id: "approval-9",
883
+ approved: true,
884
+ reason: "self approved",
885
+ isAutomatic: true,
886
+ requestReason: "descriptor reason",
887
+ display: "text",
888
+ },
889
+ },
890
+ { id: "approval-2", descriptor: ["display", "text"] },
891
+ { id: "approval-3", descriptor: "display:text" },
892
+ ]);
893
+ });
894
+
895
+ it("keeps a host answer off a request the descriptor has resolved", () => {
896
+ const metadata: AISDKMessageConverterMetadata = {
897
+ supportsRichToolApprovalResponses: true,
898
+ toolApprovalResponses: new Map([
899
+ ["approval-1", { approvalId: "approval-1", approved: true }],
900
+ ]),
901
+ };
902
+ const converted = AISDKMessageConverter.toThreadMessages(
903
+ [
904
+ {
905
+ id: "a1",
906
+ role: "assistant",
907
+ parts: [
908
+ {
909
+ type: "tool-deploy",
910
+ toolCallId: "tc-1",
911
+ state: "approval-requested",
912
+ input: {},
913
+ approval: {
914
+ id: "approval-1",
915
+ descriptor: { resolution: "expired" },
916
+ },
917
+ },
918
+ ],
919
+ } as any,
920
+ ],
921
+ false,
922
+ metadata,
923
+ );
924
+
925
+ const toolCall = converted[0]?.content.find(
926
+ (part): part is any => part.type === "tool-call",
927
+ );
928
+ expect(toolCall?.approval).toEqual({
929
+ id: "approval-1",
930
+ resolution: "expired",
931
+ descriptor: { resolution: "expired" },
932
+ });
933
+ });
934
+
935
+ it("drops descriptor fields the AI SDK cannot answer without a custom response channel", () => {
936
+ const descriptor = {
937
+ prompt: "Which environment?",
938
+ display: "select",
939
+ allowFreeform: true,
940
+ options: [{ id: "staging", kind: "_target" }],
941
+ resolution: "expired",
942
+ };
943
+ const converted = AISDKMessageConverter.toThreadMessages([
944
+ {
945
+ id: "a1",
946
+ role: "assistant",
947
+ parts: [
948
+ {
949
+ type: "tool-deploy",
950
+ toolCallId: "tc-1",
951
+ state: "approval-requested",
952
+ input: {},
953
+ approval: { id: "approval-1", descriptor },
954
+ },
955
+ ],
956
+ } as any,
957
+ ]);
958
+
959
+ const toolCall = converted[0]?.content.find(
960
+ (part): part is any => part.type === "tool-call",
961
+ );
962
+ expect(toolCall?.approval).toEqual({
963
+ id: "approval-1",
964
+ prompt: "Which environment?",
965
+ resolution: "expired",
966
+ descriptor,
967
+ });
968
+ });
969
+
970
+ it("applies a host answer to an approval the message has not recorded", () => {
971
+ const metadata: AISDKMessageConverterMetadata = {
972
+ supportsRichToolApprovalResponses: true,
973
+ toolApprovalResponses: new Map([
974
+ [
975
+ "approval-1",
976
+ {
977
+ approvalId: "approval-1",
978
+ approved: true,
979
+ optionId: "staging",
980
+ text: "only staging",
981
+ },
982
+ ],
983
+ ["approval-2", { approvalId: "approval-2", approved: true }],
984
+ ["approval-3", { approvalId: "approval-3", approved: true }],
985
+ ]),
986
+ };
987
+ const converted = AISDKMessageConverter.toThreadMessages(
988
+ [
989
+ {
990
+ id: "a1",
991
+ role: "assistant",
992
+ parts: [
993
+ {
994
+ type: "tool-deploy",
995
+ toolCallId: "tc-1",
996
+ state: "approval-requested",
997
+ input: {},
998
+ approval: {
999
+ id: "approval-1",
1000
+ display: "select",
1001
+ options: [{ id: "staging", kind: "_target" }],
1002
+ },
1003
+ },
1004
+ {
1005
+ type: "tool-deploy",
1006
+ toolCallId: "tc-2",
1007
+ state: "approval-responded",
1008
+ input: {},
1009
+ approval: { id: "approval-2", approved: false, reason: "no" },
1010
+ },
1011
+ {
1012
+ type: "tool-deploy",
1013
+ toolCallId: "tc-3",
1014
+ state: "approval-requested",
1015
+ input: {},
1016
+ approval: { id: "approval-3", resolution: "expired" },
1017
+ },
1018
+ ],
1019
+ } as any,
1020
+ ],
1021
+ false,
1022
+ metadata,
1023
+ );
1024
+
1025
+ const approvals = converted[0]?.content.map(
1026
+ (part) => (part as { approval?: unknown }).approval,
1027
+ );
1028
+ expect(approvals).toEqual([
1029
+ {
1030
+ id: "approval-1",
1031
+ display: "select",
1032
+ options: [{ id: "staging", kind: "_target" }],
1033
+ approved: true,
1034
+ optionId: "staging",
1035
+ text: "only staging",
1036
+ },
1037
+ { id: "approval-2", approved: false, reason: "no" },
1038
+ { id: "approval-3", resolution: "expired" },
1039
+ ]);
1040
+ });
1041
+
1042
+ it("drops a resolution the core contract does not declare", () => {
1043
+ const converted = AISDKMessageConverter.toThreadMessages([
1044
+ {
1045
+ id: "a1",
1046
+ role: "assistant",
1047
+ parts: [
1048
+ {
1049
+ type: "tool-deploy",
1050
+ toolCallId: "tc-1",
1051
+ state: "approval-requested",
1052
+ input: {},
1053
+ approval: { id: "approval-1", resolution: "whatever" },
1054
+ },
1055
+ ],
1056
+ } as any,
1057
+ ]);
1058
+
1059
+ const toolCall = converted[0]?.content.find(
1060
+ (part): part is any => part.type === "tool-call",
1061
+ );
1062
+ expect(toolCall?.approval).toEqual({ id: "approval-1" });
1063
+ });
1064
+
1065
+ it("strips closing delimiters from streaming tool argsText", () => {
1066
+ const converted = AISDKMessageConverter.toThreadMessages([
1067
+ {
1068
+ id: "a1",
1069
+ role: "assistant",
1070
+ parts: [
1071
+ {
1072
+ type: "tool-weather",
1073
+ toolCallId: "tc-1",
1074
+ state: "input-streaming",
1075
+ input: { city: "NYC" },
1076
+ },
1077
+ ],
1078
+ } as any,
1079
+ ]);
1080
+
1081
+ const toolCall = converted[0]?.content.find(
1082
+ (part): part is any => part.type === "tool-call",
1083
+ );
1084
+ expect(toolCall?.argsText).toBe('{"city":"NYC');
1085
+ });
1086
+
1087
+ it("strips exactly the trailing run of closing characters", () => {
1088
+ const convertArgsText = (text: string) => {
1089
+ stableStringifySpy.mockReturnValueOnce(text);
1090
+ const converted = AISDKMessageConverter.toThreadMessages([
1091
+ {
1092
+ id: "a1",
1093
+ role: "assistant",
1094
+ parts: [
1095
+ {
1096
+ type: "tool-weather",
1097
+ toolCallId: "tc-1",
1098
+ state: "input-streaming",
1099
+ input: {},
1100
+ },
1101
+ ],
1102
+ },
1103
+ ] as any);
1104
+ return (converted[0]?.content[0] as any)?.argsText;
1105
+ };
1106
+ const alphabet = ["}", "]", '"', "a", "\\", "{", "[", "\n", " ", "😀"];
1107
+ let seed = 7;
1108
+ const next = () => (seed = (seed * 48271) % 0x7fffffff);
1109
+ const texts = ["", "}", '"]}', '{"a":"x"}', '{"a":"}}x"}', "\uD800}"];
1110
+ for (let i = 0; i < 300; i++) {
1111
+ texts.push(
1112
+ Array.from(
1113
+ { length: next() % 12 },
1114
+ () => alphabet[next() % alphabet.length],
1115
+ ).join(""),
1116
+ );
1117
+ }
1118
+
1119
+ for (const text of texts) {
1120
+ expect(convertArgsText(text)).toBe(text.replace(/[}\]"]+$/, ""));
1121
+ }
1122
+ });
1123
+
1124
+ it(
1125
+ "strips a long run of closing characters inside streaming input",
1126
+ { timeout: 5_000 },
1127
+ () => {
1128
+ const run = "}".repeat(200_000);
1129
+ const converted = AISDKMessageConverter.toThreadMessages([
1130
+ {
1131
+ id: "a1",
1132
+ role: "assistant",
1133
+ parts: [
1134
+ {
1135
+ type: "tool-weather",
1136
+ toolCallId: "tc-1",
1137
+ state: "input-streaming",
1138
+ input: { code: `${run}x` },
1139
+ },
1140
+ ],
1141
+ },
1142
+ ] as any);
1143
+
1144
+ expect((converted[0]?.content[0] as any)?.argsText).toBe(
1145
+ `{"code":"${run}x`,
1146
+ );
1147
+ },
1148
+ );
1149
+
1150
+ it("attaches partial-JSON meta marking the trailing streaming field", () => {
1151
+ const converted = AISDKMessageConverter.toThreadMessages([
1152
+ {
1153
+ id: "a1",
1154
+ role: "assistant",
1155
+ parts: [
1156
+ {
1157
+ type: "tool-weather",
1158
+ toolCallId: "tc-1",
1159
+ state: "input-streaming",
1160
+ input: { city: "NYC", units: "F" },
1161
+ },
1162
+ ],
1163
+ } as any,
1164
+ ]);
1165
+
1166
+ const toolCall = converted[0]?.content.find(
1167
+ (part): part is any => part.type === "tool-call",
1168
+ );
1169
+ expect(toolCall?.args).toMatchObject({ city: "NYC", units: "F" });
1170
+ expect(getPartialJsonObjectFieldState(toolCall!.args, ["city"])).toBe(
1171
+ "complete",
1172
+ );
1173
+ expect(getPartialJsonObjectFieldState(toolCall!.args, ["units"])).toBe(
1174
+ "partial",
1175
+ );
1176
+ });
1177
+
1178
+ it("keeps observed key order from streaming snapshots for final tool args", () => {
1179
+ const metadata: AISDKMessageConverterMetadata = {
1180
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
1181
+ };
1182
+
1183
+ const streaming = AISDKMessageConverter.toThreadMessages(
1184
+ [
1185
+ {
1186
+ id: "a1",
1187
+ role: "assistant",
1188
+ parts: [
1189
+ {
1190
+ type: "tool-stocks",
1191
+ toolCallId: "tc-order-1",
1192
+ state: "input-streaming",
1193
+ input: {
1194
+ type: "high_stock_model",
1195
+ limit: 5,
1196
+ filters: {
1197
+ region: "us",
1198
+ sector: "tech",
1199
+ },
1200
+ },
1201
+ },
1202
+ ],
1203
+ } as any,
1204
+ ],
1205
+ false,
1206
+ metadata,
1207
+ );
1208
+
1209
+ const streamingToolCall = streaming[0]?.content.find(
1210
+ (part): part is any => part.type === "tool-call",
1211
+ );
1212
+ expect(streamingToolCall?.argsText).toBe(
1213
+ '{"type":"high_stock_model","limit":5,"filters":{"region":"us","sector":"tech',
1214
+ );
1215
+
1216
+ const final = AISDKMessageConverter.toThreadMessages(
1217
+ [
1218
+ {
1219
+ id: "a1",
1220
+ role: "assistant",
1221
+ parts: [
1222
+ {
1223
+ type: "tool-stocks",
1224
+ toolCallId: "tc-order-1",
1225
+ state: "input-available",
1226
+ input: {
1227
+ filters: {
1228
+ sector: "tech",
1229
+ region: "us",
1230
+ },
1231
+ limit: 5,
1232
+ type: "high_stock_model",
1233
+ },
1234
+ },
1235
+ ],
1236
+ } as any,
1237
+ ],
1238
+ false,
1239
+ metadata,
1240
+ );
1241
+
1242
+ const finalToolCall = final[0]?.content.find(
1243
+ (part): part is any => part.type === "tool-call",
1244
+ );
1245
+ expect(finalToolCall?.argsText).toBe(
1246
+ '{"type":"high_stock_model","limit":5,"filters":{"region":"us","sector":"tech"}}',
1247
+ );
1248
+ });
1249
+
1250
+ it("merges duplicate toolCallId across assistant snapshots", () => {
1251
+ const metadata: AISDKMessageConverterMetadata = {
1252
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
1253
+ };
1254
+
1255
+ const converted = AISDKMessageConverter.toThreadMessages(
1256
+ [
1257
+ {
1258
+ id: "a1",
1259
+ role: "assistant",
1260
+ parts: [
1261
+ {
1262
+ type: "tool-stocks",
1263
+ toolCallId: "tc-order-1",
1264
+ state: "input-streaming",
1265
+ input: {
1266
+ type: "high_stock_model",
1267
+ limit: 5,
1268
+ },
1269
+ },
1270
+ ],
1271
+ } as any,
1272
+ {
1273
+ id: "a2",
1274
+ role: "assistant",
1275
+ parts: [
1276
+ {
1277
+ type: "tool-stocks",
1278
+ toolCallId: "tc-order-1",
1279
+ state: "input-available",
1280
+ input: {
1281
+ limit: 5,
1282
+ type: "high_stock_model",
1283
+ },
1284
+ },
1285
+ ],
1286
+ } as any,
1287
+ ],
1288
+ false,
1289
+ metadata,
1290
+ );
1291
+
1292
+ const toolCalls = converted[0]?.content.filter(
1293
+ (part): part is any => part.type === "tool-call",
1294
+ );
1295
+ expect(toolCalls).toHaveLength(1);
1296
+ expect(toolCalls?.[0]?.toolCallId).toBe("tc-order-1");
1297
+ expect(JSON.parse(toolCalls?.[0]?.argsText ?? "{}")).toEqual({
1298
+ type: "high_stock_model",
1299
+ limit: 5,
1300
+ });
1301
+ });
1302
+
1303
+ it("preserves last good input when AI SDK briefly emits null input", () => {
1304
+ const metadata: AISDKMessageConverterMetadata = {
1305
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
1306
+ toolLastInputCache: new Map<string, ReadonlyJSONObject>(),
1307
+ };
1308
+
1309
+ const convertWithInput = (input: unknown) =>
1310
+ AISDKMessageConverter.toThreadMessages(
1311
+ [
1312
+ {
1313
+ id: "a1",
1314
+ role: "assistant",
1315
+ parts: [
1316
+ {
1317
+ type: "tool-weather",
1318
+ toolCallId: "tc-1",
1319
+ state: "input-streaming",
1320
+ input,
1321
+ },
1322
+ ],
1323
+ } as any,
1324
+ ],
1325
+ false,
1326
+ metadata,
1327
+ )[0]?.content.find((part): part is any => part.type === "tool-call");
1328
+
1329
+ const first = convertWithInput({ city: "NYC" });
1330
+ expect(first?.argsText).toBe('{"city":"NYC');
1331
+ expect(first?.args).toMatchObject({ city: "NYC" });
1332
+
1333
+ const dropped = convertWithInput(null);
1334
+ expect(dropped?.argsText).toBe('{"city":"NYC');
1335
+ expect(dropped?.args).toMatchObject({ city: "NYC" });
1336
+
1337
+ const undef = convertWithInput(undefined);
1338
+ expect(undef?.argsText).toBe('{"city":"NYC');
1339
+ expect(undef?.args).toMatchObject({ city: "NYC" });
1340
+
1341
+ const grown = convertWithInput({ city: "NYC", units: "F" });
1342
+ expect(grown?.argsText).toBe('{"city":"NYC","units":"F');
1343
+ expect(grown?.args).toMatchObject({ city: "NYC", units: "F" });
1344
+ });
1345
+
1346
+ it("preserves last good input across terminal state transitions", () => {
1347
+ const metadata: AISDKMessageConverterMetadata = {
1348
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
1349
+ toolLastInputCache: new Map<string, ReadonlyJSONObject>(),
1350
+ };
1351
+
1352
+ AISDKMessageConverter.toThreadMessages(
1353
+ [
1354
+ {
1355
+ id: "a1",
1356
+ role: "assistant",
1357
+ parts: [
1358
+ {
1359
+ type: "tool-weather",
1360
+ toolCallId: "tc-1",
1361
+ state: "input-available",
1362
+ input: { city: "NYC" },
1363
+ },
1364
+ ],
1365
+ } as any,
1366
+ ],
1367
+ false,
1368
+ metadata,
1369
+ );
1370
+
1371
+ const terminal = AISDKMessageConverter.toThreadMessages(
1372
+ [
1373
+ {
1374
+ id: "a1",
1375
+ role: "assistant",
1376
+ parts: [
1377
+ {
1378
+ type: "tool-weather",
1379
+ toolCallId: "tc-1",
1380
+ state: "output-available",
1381
+ input: null,
1382
+ output: { temp: 70 },
1383
+ },
1384
+ ],
1385
+ } as any,
1386
+ ],
1387
+ false,
1388
+ metadata,
1389
+ );
1390
+
1391
+ const call = terminal[0]?.content.find(
1392
+ (part): part is any => part.type === "tool-call",
1393
+ );
1394
+ expect(Object.fromEntries(Object.entries(call?.args ?? {}))).toEqual({
1395
+ city: "NYC",
1396
+ });
1397
+ expect(call?.result).toEqual({ temp: 70 });
1398
+ });
1399
+
1400
+ it("unwraps the modelContent envelope produced by frontend tool execution", () => {
1401
+ const converted = AISDKMessageConverter.toThreadMessages([
1402
+ {
1403
+ id: "a1",
1404
+ role: "assistant",
1405
+ parts: [
1406
+ {
1407
+ type: "tool-readPdf",
1408
+ toolCallId: "tc-pdf",
1409
+ state: "output-available",
1410
+ input: {},
1411
+ output: {
1412
+ __aui_modelContent: [
1413
+ { type: "text", text: "PDF contents:" },
1414
+ {
1415
+ type: "file",
1416
+ data: "JVBERi0xLjQK",
1417
+ mediaType: "application/pdf",
1418
+ },
1419
+ ],
1420
+ value: { mediaType: "application/pdf", base64: "JVBERi0xLjQK" },
1421
+ },
1422
+ },
1423
+ ],
1424
+ } as any,
1425
+ ]);
1426
+
1427
+ const call = converted[0]?.content.find(
1428
+ (part): part is any => part.type === "tool-call",
1429
+ );
1430
+ expect(call?.result).toEqual({
1431
+ mediaType: "application/pdf",
1432
+ base64: "JVBERi0xLjQK",
1433
+ });
1434
+ expect(call?.modelContent).toEqual([
1435
+ { type: "text", text: "PDF contents:" },
1436
+ {
1437
+ type: "file",
1438
+ data: "JVBERi0xLjQK",
1439
+ mediaType: "application/pdf",
1440
+ },
1441
+ ]);
1442
+ });
1443
+
1444
+ it("leaves a plain output untouched when no envelope is present", () => {
1445
+ const converted = AISDKMessageConverter.toThreadMessages([
1446
+ {
1447
+ id: "a1",
1448
+ role: "assistant",
1449
+ parts: [
1450
+ {
1451
+ type: "tool-weather",
1452
+ toolCallId: "tc-1",
1453
+ state: "output-available",
1454
+ input: { city: "NYC" },
1455
+ output: { temp: 72 },
1456
+ },
1457
+ ],
1458
+ } as any,
1459
+ ]);
1460
+
1461
+ const call = converted[0]?.content.find(
1462
+ (part): part is any => part.type === "tool-call",
1463
+ );
1464
+ expect(call?.result).toEqual({ temp: 72 });
1465
+ expect(call?.modelContent).toBeUndefined();
1466
+ });
1467
+
1468
+ it.each([
1469
+ ["preliminary", true, true],
1470
+ ["final", undefined, undefined],
1471
+ ])(
1472
+ "marks a %s output-available part on the tool call",
1473
+ (_label, preliminary, isPreliminary) => {
1474
+ const converted = AISDKMessageConverter.toThreadMessages([
1475
+ {
1476
+ id: "a1",
1477
+ role: "assistant",
1478
+ parts: [
1479
+ {
1480
+ type: "tool-weather",
1481
+ toolCallId: "tc-1",
1482
+ state: "output-available",
1483
+ input: { city: "NYC" },
1484
+ output: { temp: 72 },
1485
+ ...(preliminary !== undefined && { preliminary }),
1486
+ },
1487
+ ],
1488
+ } as any,
1489
+ ]);
1490
+
1491
+ const call = converted[0]?.content.find(
1492
+ (part): part is any => part.type === "tool-call",
1493
+ );
1494
+ expect(call?.result).toEqual({ temp: 72 });
1495
+ expect(call?.isPreliminary).toBe(isPreliminary);
1496
+ },
1497
+ );
1498
+
1499
+ it("forwards callProviderMetadata.mcp.app onto ToolCallMessagePart.mcp.app", () => {
1500
+ const converted = AISDKMessageConverter.toThreadMessages([
1501
+ {
1502
+ id: "a1",
1503
+ role: "assistant",
1504
+ parts: [
1505
+ {
1506
+ type: "tool-search",
1507
+ toolCallId: "tc-1",
1508
+ state: "output-available",
1509
+ input: { query: "hi" },
1510
+ output: { results: [] },
1511
+ callProviderMetadata: {
1512
+ mcp: {
1513
+ app: {
1514
+ resourceUri: "ui://example/search",
1515
+ mimeType: "text/html;profile=mcp-app",
1516
+ visibility: ["app", "model", "bogus"],
1517
+ },
1518
+ },
1519
+ },
1520
+ },
1521
+ ],
1522
+ } as any,
1523
+ ]);
1524
+
1525
+ const call = converted[0]?.content.find(
1526
+ (part): part is any => part.type === "tool-call",
1527
+ );
1528
+ expect(call?.mcp?.app).toEqual({
1529
+ resourceUri: "ui://example/search",
1530
+ mimeType: "text/html;profile=mcp-app",
1531
+ visibility: ["app", "model"],
1532
+ });
1533
+ });
1534
+
1535
+ it("forwards toolMetadata.app without a tool-supplied serverId", () => {
1536
+ const converted = AISDKMessageConverter.toThreadMessages([
1537
+ {
1538
+ id: "a1",
1539
+ role: "assistant",
1540
+ parts: [
1541
+ {
1542
+ type: "tool-search",
1543
+ toolCallId: "tc-1",
1544
+ state: "output-available",
1545
+ input: { query: "hi" },
1546
+ output: { results: [] },
1547
+ toolMetadata: {
1548
+ app: {
1549
+ resourceUri: "ui://example/search",
1550
+ mimeType: "text/html;profile=mcp-app",
1551
+ visibility: ["app", "model", "bogus"],
1552
+ serverId: "search-server",
1553
+ },
1554
+ },
1555
+ },
1556
+ ],
1557
+ } as any,
1558
+ ]);
1559
+
1560
+ const call = converted[0]?.content.find(
1561
+ (part): part is any => part.type === "tool-call",
1562
+ );
1563
+ expect(call?.mcp?.app).toEqual({
1564
+ resourceUri: "ui://example/search",
1565
+ mimeType: "text/html;profile=mcp-app",
1566
+ visibility: ["app", "model"],
1567
+ });
1568
+ });
1569
+
1570
+ it.each([undefined, "", "other-server"])(
1571
+ "keeps provider serverId when the tool supplies %s",
1572
+ (serverId) => {
1573
+ const converted = AISDKMessageConverter.toThreadMessages([
1574
+ {
1575
+ id: "a1",
1576
+ role: "assistant",
1577
+ parts: [
1578
+ {
1579
+ type: "tool-search",
1580
+ toolCallId: "tc-1",
1581
+ state: "output-available",
1582
+ input: { query: "hi" },
1583
+ output: { results: [] },
1584
+ toolMetadata: {
1585
+ app: {
1586
+ resourceUri: "ui://example/search",
1587
+ mimeType: "text/html;profile=mcp-app",
1588
+ serverId,
1589
+ },
1590
+ },
1591
+ callProviderMetadata: {
1592
+ mcp: {
1593
+ app: {
1594
+ resourceUri: "ui://example/search",
1595
+ serverId: "search-server",
1596
+ },
1597
+ },
1598
+ },
1599
+ },
1600
+ ],
1601
+ } as any,
1602
+ ]);
1603
+
1604
+ const call = converted[0]?.content.find(
1605
+ (part): part is any => part.type === "tool-call",
1606
+ );
1607
+ expect(call?.mcp?.app).toEqual({
1608
+ resourceUri: "ui://example/search",
1609
+ mimeType: "text/html;profile=mcp-app",
1610
+ serverId: "search-server",
1611
+ });
1612
+ },
1613
+ );
1614
+
1615
+ it("falls back to provider app metadata when toolMetadata.app has an invalid URI", () => {
1616
+ const converted = AISDKMessageConverter.toThreadMessages([
1617
+ {
1618
+ id: "a1",
1619
+ role: "assistant",
1620
+ parts: [
1621
+ {
1622
+ type: "tool-search",
1623
+ toolCallId: "tc-1",
1624
+ state: "output-available",
1625
+ input: { query: "hi" },
1626
+ output: { results: [] },
1627
+ toolMetadata: {
1628
+ app: { resourceUri: "https://example.com/search" },
1629
+ },
1630
+ callProviderMetadata: {
1631
+ mcp: {
1632
+ app: {
1633
+ resourceUri: "ui://example/search",
1634
+ mimeType: "text/html;profile=mcp-app",
1635
+ },
1636
+ },
1637
+ },
1638
+ },
1639
+ ],
1640
+ } as any,
1641
+ ]);
1642
+
1643
+ const call = converted[0]?.content.find(
1644
+ (part): part is any => part.type === "tool-call",
1645
+ );
1646
+ expect(call?.mcp?.app).toEqual({
1647
+ resourceUri: "ui://example/search",
1648
+ mimeType: "text/html;profile=mcp-app",
1649
+ });
1650
+ });
1651
+
1652
+ it("omits an empty callProviderMetadata.mcp.app.serverId", () => {
1653
+ const converted = AISDKMessageConverter.toThreadMessages([
1654
+ {
1655
+ id: "a1",
1656
+ role: "assistant",
1657
+ parts: [
1658
+ {
1659
+ type: "tool-search",
1660
+ toolCallId: "tc-1",
1661
+ state: "output-available",
1662
+ input: { query: "hi" },
1663
+ output: { results: [] },
1664
+ callProviderMetadata: {
1665
+ mcp: {
1666
+ app: {
1667
+ resourceUri: "ui://example/search",
1668
+ serverId: "",
1669
+ },
1670
+ },
1671
+ },
1672
+ },
1673
+ ],
1674
+ } as any,
1675
+ ]);
1676
+
1677
+ const call = converted[0]?.content.find(
1678
+ (part): part is any => part.type === "tool-call",
1679
+ );
1680
+ expect(call?.mcp?.app).toEqual({
1681
+ resourceUri: "ui://example/search",
1682
+ });
1683
+ });
1684
+
1685
+ it("preserves providerMetadata on text and reasoning parts", () => {
1686
+ const converted = AISDKMessageConverter.toThreadMessages([
1687
+ {
1688
+ id: "a1",
1689
+ role: "assistant",
1690
+ parts: [
1691
+ {
1692
+ type: "text",
1693
+ text: "hello",
1694
+ providerMetadata: { acme: { agentName: "researcher" } },
1695
+ },
1696
+ {
1697
+ type: "reasoning",
1698
+ text: "thinking",
1699
+ providerMetadata: { acme: { agentName: "researcher" } },
1700
+ },
1701
+ { type: "text", text: "plain" },
1702
+ ],
1703
+ } as any,
1704
+ ]);
1705
+
1706
+ expect(converted[0]?.content[0]).toMatchObject({
1707
+ type: "text",
1708
+ text: "hello",
1709
+ providerMetadata: { acme: { agentName: "researcher" } },
1710
+ });
1711
+ expect(converted[0]?.content[1]).toMatchObject({
1712
+ type: "reasoning",
1713
+ text: "thinking",
1714
+ providerMetadata: { acme: { agentName: "researcher" } },
1715
+ });
1716
+ expect(converted[0]?.content[2]).not.toHaveProperty("providerMetadata");
1717
+ });
1718
+
1719
+ it("keeps a step scoped reasoning block id off the part", () => {
1720
+ const converted = AISDKMessageConverter.toThreadMessages([
1721
+ {
1722
+ id: "a1",
1723
+ role: "assistant",
1724
+ parts: [
1725
+ { type: "reasoning", id: "reasoning-0", text: "first step" },
1726
+ { type: "text", text: "answer" },
1727
+ { type: "reasoning", id: "reasoning-0", text: "second step" },
1728
+ ],
1729
+ } as any,
1730
+ ]);
1731
+
1732
+ expect(converted[0]?.content[0]).toMatchObject({ type: "reasoning" });
1733
+ expect(converted[0]?.content[0]).not.toHaveProperty("id");
1734
+ expect(converted[0]?.content[1]).not.toHaveProperty("id");
1735
+ expect(converted[0]?.content[2]).not.toHaveProperty("id");
1736
+ });
1737
+
1738
+ it("forwards data part ids", () => {
1739
+ const converted = AISDKMessageConverter.toThreadMessages([
1740
+ {
1741
+ id: "a1",
1742
+ role: "assistant",
1743
+ parts: [{ type: "data-chart", id: "data-1", data: { x: 1 } }],
1744
+ } as any,
1745
+ ]);
1746
+
1747
+ expect(converted[0]?.content[0]).toMatchObject({
1748
+ type: "data",
1749
+ id: "data-1",
1750
+ name: "chart",
1751
+ data: { x: 1 },
1752
+ });
1753
+ });
1754
+
1755
+ it("maps TextUIPart.state onto the per-part status", () => {
1756
+ const converted = AISDKMessageConverter.toThreadMessages([
1757
+ {
1758
+ id: "a1",
1759
+ role: "assistant",
1760
+ parts: [
1761
+ { type: "text", text: "streaming", state: "streaming" },
1762
+ { type: "text", text: "done", state: "done" },
1763
+ { type: "text", text: "unset" },
1764
+ ],
1765
+ } as any,
1766
+ ]);
1767
+
1768
+ expect(converted[0]?.content[0]).toMatchObject({
1769
+ type: "text",
1770
+ text: "streaming",
1771
+ status: { type: "running" },
1772
+ });
1773
+ expect(converted[0]?.content[1]).toMatchObject({
1774
+ type: "text",
1775
+ text: "done",
1776
+ status: { type: "complete" },
1777
+ });
1778
+ expect(converted[0]?.content[2]).not.toHaveProperty("status");
1779
+ });
1780
+
1781
+ it("maps ReasoningUIPart.state onto the per-part status", () => {
1782
+ const converted = AISDKMessageConverter.toThreadMessages([
1783
+ {
1784
+ id: "a1",
1785
+ role: "assistant",
1786
+ parts: [
1787
+ { type: "reasoning", text: "thinking", state: "streaming" },
1788
+ { type: "reasoning", text: "settled", state: "done" },
1789
+ { type: "reasoning", text: "unset" },
1790
+ ],
1791
+ } as any,
1792
+ ]);
1793
+
1794
+ expect(converted[0]?.content[0]).toMatchObject({
1795
+ type: "reasoning",
1796
+ text: "thinking",
1797
+ status: { type: "running" },
1798
+ });
1799
+ expect(converted[0]?.content[1]).toMatchObject({
1800
+ type: "reasoning",
1801
+ text: "settled",
1802
+ status: { type: "complete" },
1803
+ });
1804
+ expect(converted[0]?.content[2]).not.toHaveProperty("status");
1805
+ });
1806
+
1807
+ it("forwards callProviderMetadata onto ToolCallMessagePart.providerMetadata", () => {
1808
+ const converted = AISDKMessageConverter.toThreadMessages([
1809
+ {
1810
+ id: "a1",
1811
+ role: "assistant",
1812
+ parts: [
1813
+ {
1814
+ type: "tool-search",
1815
+ toolCallId: "tc-1",
1816
+ state: "output-available",
1817
+ input: { query: "hi" },
1818
+ output: { results: [] },
1819
+ callProviderMetadata: { acme: { agentName: "researcher" } },
1820
+ },
1821
+ {
1822
+ type: "tool-search",
1823
+ toolCallId: "tc-2",
1824
+ state: "output-available",
1825
+ input: { query: "yo" },
1826
+ output: { results: [] },
1827
+ },
1828
+ ],
1829
+ } as any,
1830
+ ]);
1831
+
1832
+ const calls = converted[0]?.content.filter(
1833
+ (part): part is any => part.type === "tool-call",
1834
+ );
1835
+ expect(calls?.[0]?.providerMetadata).toEqual({
1836
+ acme: { agentName: "researcher" },
1837
+ });
1838
+ expect(calls?.[1]).not.toHaveProperty("providerMetadata");
1839
+ });
1840
+
1841
+ it("extracts MCP app metadata from output._meta['ui/resourceUri']", () => {
1842
+ const converted = AISDKMessageConverter.toThreadMessages([
1843
+ {
1844
+ id: "a1",
1845
+ role: "assistant",
1846
+ parts: [
1847
+ {
1848
+ type: "tool-hello_ui",
1849
+ toolCallId: "tc-1",
1850
+ state: "output-available",
1851
+ input: {},
1852
+ output: {
1853
+ _meta: { "ui/resourceUri": "ui://app/hello_ui.html" },
1854
+ content: [{ type: "text", text: "" }],
1855
+ },
1856
+ },
1857
+ ],
1858
+ } as any,
1859
+ ]);
1860
+
1861
+ const call = converted[0]?.content.find(
1862
+ (part): part is any => part.type === "tool-call",
1863
+ );
1864
+ expect(call?.mcp?.app).toEqual({
1865
+ resourceUri: "ui://app/hello_ui.html",
1866
+ });
1867
+ });
1868
+
1869
+ it("adopts only spec'd ui fields from output._meta.ui", () => {
1870
+ const converted = AISDKMessageConverter.toThreadMessages([
1871
+ {
1872
+ id: "a1",
1873
+ role: "assistant",
1874
+ parts: [
1875
+ {
1876
+ type: "tool-hello_ui",
1877
+ toolCallId: "tc-1",
1878
+ state: "output-available",
1879
+ input: {},
1880
+ output: {
1881
+ _meta: {
1882
+ ui: {
1883
+ resourceUri: "ui://app/hello_ui.html",
1884
+ serverId: "srv",
1885
+ visibility: ["model"],
1886
+ },
1887
+ },
1888
+ content: [{ type: "text", text: "" }],
1889
+ },
1890
+ },
1891
+ ],
1892
+ } as any,
1893
+ ]);
1894
+
1895
+ const call = converted[0]?.content.find(
1896
+ (part): part is any => part.type === "tool-call",
1897
+ );
1898
+ expect(call?.mcp?.app).toEqual({
1899
+ resourceUri: "ui://app/hello_ui.html",
1900
+ visibility: ["model"],
1901
+ });
1902
+ });
1903
+
1904
+ it("prefers output._meta.ui.resourceUri over output._meta['ui/resourceUri']", () => {
1905
+ const converted = AISDKMessageConverter.toThreadMessages([
1906
+ {
1907
+ id: "a1",
1908
+ role: "assistant",
1909
+ parts: [
1910
+ {
1911
+ type: "tool-hello_ui",
1912
+ toolCallId: "tc-1",
1913
+ state: "output-available",
1914
+ input: {},
1915
+ output: {
1916
+ _meta: {
1917
+ ui: { resourceUri: "ui://app/nested.html" },
1918
+ "ui/resourceUri": "ui://app/flat.html",
1919
+ },
1920
+ content: [{ type: "text", text: "" }],
1921
+ },
1922
+ },
1923
+ ],
1924
+ } as any,
1925
+ ]);
1926
+
1927
+ const call = converted[0]?.content.find(
1928
+ (part): part is any => part.type === "tool-call",
1929
+ );
1930
+ expect(call?.mcp?.app).toEqual({
1931
+ resourceUri: "ui://app/nested.html",
1932
+ });
1933
+ });
1934
+
1935
+ it("falls back to output._meta['ui/resourceUri'] when the nested resourceUri is not a ui:// app uri", () => {
1936
+ const converted = AISDKMessageConverter.toThreadMessages([
1937
+ {
1938
+ id: "a1",
1939
+ role: "assistant",
1940
+ parts: [
1941
+ {
1942
+ type: "tool-hello_ui",
1943
+ toolCallId: "tc-1",
1944
+ state: "output-available",
1945
+ input: {},
1946
+ output: {
1947
+ _meta: {
1948
+ ui: { resourceUri: "https://example.com/not-an-app" },
1949
+ "ui/resourceUri": "ui://app/flat.html",
1950
+ },
1951
+ content: [{ type: "text", text: "" }],
1952
+ },
1953
+ },
1954
+ ],
1955
+ } as any,
1956
+ ]);
1957
+
1958
+ const call = converted[0]?.content.find(
1959
+ (part): part is any => part.type === "tool-call",
1960
+ );
1961
+ expect(call?.mcp?.app).toEqual({
1962
+ resourceUri: "ui://app/flat.html",
1963
+ });
1964
+ });
1965
+
1966
+ it("memoizes MCP app metadata across conversions by serverId and resourceUri", () => {
1967
+ const metadata: AISDKMessageConverterMetadata = {
1968
+ mcpAppMetadataCache: new Map(),
1969
+ };
1970
+
1971
+ const buildMessage = (id: string, serverId: string) => ({
1972
+ id,
1973
+ role: "assistant" as const,
1974
+ parts: [
1975
+ {
1976
+ type: "tool-search",
1977
+ toolCallId: `${id}-call`,
1978
+ state: "output-available",
1979
+ input: { q: "hi" },
1980
+ output: {},
1981
+ callProviderMetadata: {
1982
+ mcp: {
1983
+ app: { resourceUri: "ui://example/search", serverId },
1984
+ },
1985
+ },
1986
+ } as any,
1987
+ ],
1988
+ });
1989
+
1990
+ const first = AISDKMessageConverter.toThreadMessages(
1991
+ [buildMessage("a1", "search-server")],
1992
+ false,
1993
+ metadata,
1994
+ );
1995
+ const second = AISDKMessageConverter.toThreadMessages(
1996
+ [buildMessage("a2", "search-server")],
1997
+ false,
1998
+ metadata,
1999
+ );
2000
+ const third = AISDKMessageConverter.toThreadMessages(
2001
+ [buildMessage("a3", "other-server")],
2002
+ false,
2003
+ metadata,
2004
+ );
2005
+
2006
+ const firstApp = first[0]?.content.find(
2007
+ (p): p is any => p.type === "tool-call",
2008
+ )?.mcp?.app;
2009
+ const secondApp = second[0]?.content.find(
2010
+ (p): p is any => p.type === "tool-call",
2011
+ )?.mcp?.app;
2012
+ const thirdApp = third[0]?.content.find(
2013
+ (p): p is any => p.type === "tool-call",
2014
+ )?.mcp?.app;
2015
+ expect(firstApp).toBeDefined();
2016
+ expect(firstApp).toBe(secondApp);
2017
+ expect(thirdApp).not.toBe(firstApp);
2018
+ expect(thirdApp).toEqual({
2019
+ resourceUri: "ui://example/search",
2020
+ serverId: "other-server",
2021
+ });
2022
+ });
2023
+
2024
+ it("converts a reasoning-file part into a file part", () => {
2025
+ const converted = AISDKMessageConverter.toThreadMessages([
2026
+ {
2027
+ id: "a1",
2028
+ role: "assistant",
2029
+ parts: [
2030
+ {
2031
+ type: "reasoning-file",
2032
+ mediaType: "image/png",
2033
+ url: "data:image/png;base64,abc",
2034
+ },
2035
+ ],
2036
+ } as any,
2037
+ ]);
2038
+
2039
+ expect(converted[0]?.content).toHaveLength(1);
2040
+ expect(converted[0]?.content[0]).toMatchObject({
2041
+ type: "file",
2042
+ data: "data:image/png;base64,abc",
2043
+ mimeType: "image/png",
2044
+ });
2045
+ });
2046
+
2047
+ it("converts a custom part into a data part named by its kind", () => {
2048
+ const converted = AISDKMessageConverter.toThreadMessages([
2049
+ {
2050
+ id: "a1",
2051
+ role: "assistant",
2052
+ parts: [
2053
+ {
2054
+ type: "custom",
2055
+ kind: "acme.widget",
2056
+ providerMetadata: { acme: { foo: "bar" } },
2057
+ },
2058
+ ],
2059
+ } as any,
2060
+ ]);
2061
+
2062
+ expect(converted[0]?.content).toHaveLength(1);
2063
+ expect(converted[0]?.content[0]).toMatchObject({
2064
+ type: "data",
2065
+ name: "acme.widget",
2066
+ data: { acme: { foo: "bar" } },
2067
+ });
2068
+ });
2069
+
2070
+ it("preserves failed tool-call arguments from rawInput in the error snapshot", () => {
2071
+ // A tool that streamed complete arguments then failed schema validation
2072
+ // keeps those arguments in `rawInput`, not `input`. Converting from `input`
2073
+ // alone yields `{}`, hiding the real failed input from the UI.
2074
+ const metadata: AISDKMessageConverterMetadata = {
2075
+ toolArgsKeyOrderCache: new Map(),
2076
+ };
2077
+ const converted = AISDKMessageConverter.toThreadMessages(
2078
+ [
2079
+ {
2080
+ id: "a1",
2081
+ role: "assistant",
2082
+ parts: [
2083
+ {
2084
+ type: "tool-weather",
2085
+ toolCallId: "tc-1",
2086
+ state: "output-error",
2087
+ rawInput: { city: "NYC", units: "F" },
2088
+ errorText: "arguments failed schema validation",
2089
+ },
2090
+ ],
2091
+ },
2092
+ ] as any,
2093
+ false,
2094
+ metadata,
2095
+ );
2096
+
2097
+ const toolCall = converted[0]?.content.find(
2098
+ (part): part is any => part.type === "tool-call",
2099
+ );
2100
+ expect(Object.fromEntries(Object.entries(toolCall?.args ?? {}))).toEqual({
2101
+ city: "NYC",
2102
+ units: "F",
2103
+ });
2104
+ expect(toolCall?.argsText).toBe('{"city":"NYC","units":"F"}');
2105
+ });
2106
+
2107
+ it("releases the key-order entry once the tool call settles", () => {
2108
+ // Arrival order only matters while args stream; a settled input's own key
2109
+ // order is already deterministic, so the entry is released at settlement.
2110
+ const toolArgsKeyOrderCache: NonNullable<
2111
+ AISDKMessageConverterMetadata["toolArgsKeyOrderCache"]
2112
+ > = new Map();
2113
+ const metadata: AISDKMessageConverterMetadata = {
2114
+ toolArgsKeyOrderCache,
2115
+ toolArgsTextCache: new WeakMap(),
2116
+ };
2117
+ const part = (state: string) => ({
2118
+ type: "tool-weather",
2119
+ toolCallId: "tc-1",
2120
+ state,
2121
+ input: { a: 1 },
2122
+ ...(state === "output-available" && { output: { ok: true } }),
2123
+ });
2124
+ const message = (state: string) =>
2125
+ [{ id: "a1", role: "assistant", parts: [part(state)] }] as any;
2126
+
2127
+ AISDKMessageConverter.toThreadMessages(
2128
+ message("input-streaming"),
2129
+ true,
2130
+ metadata,
2131
+ );
2132
+ expect(toolArgsKeyOrderCache.size).toBe(1);
2133
+
2134
+ AISDKMessageConverter.toThreadMessages(
2135
+ message("output-available"),
2136
+ false,
2137
+ metadata,
2138
+ );
2139
+ expect(toolArgsKeyOrderCache.size).toBe(0);
2140
+ });
2141
+
2142
+ it("keeps frozen argsText per tool call when two calls settle on one shared input object", () => {
2143
+ // The frozen text depends on the call's streamed key order, so a shared
2144
+ // input object must not hand one call the other's text: that would turn
2145
+ // its streamed prefix into a non-prefix snapshot the tracker cannot close.
2146
+ const metadata: AISDKMessageConverterMetadata = {
2147
+ toolArgsKeyOrderCache: new Map(),
2148
+ toolArgsTextCache: new WeakMap(),
2149
+ };
2150
+ const tool = (toolCallId: string, state: string, input: object) => ({
2151
+ type: "tool-weather",
2152
+ toolCallId,
2153
+ state,
2154
+ input,
2155
+ ...(state === "output-available" && { output: { ok: true } }),
2156
+ });
2157
+ const convert = (parts: object[]) =>
2158
+ AISDKMessageConverter.toThreadMessages(
2159
+ [{ id: "a1", role: "assistant", parts }] as any,
2160
+ false,
2161
+ metadata,
2162
+ )[0]!.content.filter((part): part is any => part.type === "tool-call");
2163
+
2164
+ convert([
2165
+ tool("tc-a", "input-streaming", { a: 1, b: 2 }),
2166
+ tool("tc-b", "input-streaming", { b: 2, a: 1 }),
2167
+ ]);
2168
+ const shared = { a: 1, b: 2 };
2169
+ const settled = [
2170
+ tool("tc-a", "output-available", shared),
2171
+ tool("tc-b", "output-available", shared),
2172
+ ];
2173
+ const [a, b] = convert(settled);
2174
+
2175
+ expect(a.argsText).toBe('{"a":1,"b":2}');
2176
+ expect(b.argsText).toBe('{"b":2,"a":1}');
2177
+ expect(Object.keys(b.args)).toEqual(["a", "b"]);
2178
+
2179
+ // Both entries must survive a reconversion: the key-order entries are gone
2180
+ // by now, so a cache miss would re-serialize B in raw key order.
2181
+ stableStringifySpy.mockClear();
2182
+ const [a2, b2] = convert(settled);
2183
+ expect(a2.argsText).toBe('{"a":1,"b":2}');
2184
+ expect(b2.argsText).toBe('{"b":2,"a":1}');
2185
+ expect(stableStringifySpy).not.toHaveBeenCalled();
2186
+ });
2187
+
2188
+ it("serializes a settled tool call's argsText once when its input identity is stable", () => {
2189
+ // The frozen text is keyed weakly by the input object itself, so it becomes
2190
+ // collectible once that object is unreachable instead of outliving it.
2191
+ const metadata: AISDKMessageConverterMetadata = {
2192
+ toolArgsKeyOrderCache: new Map(),
2193
+ toolArgsTextCache: new WeakMap(),
2194
+ };
2195
+ const messages = [
2196
+ {
2197
+ id: "a1",
2198
+ role: "assistant",
2199
+ parts: [
2200
+ {
2201
+ type: "tool-weather",
2202
+ toolCallId: "tc-1",
2203
+ state: "output-available",
2204
+ input: { a: 1, b: 2 },
2205
+ output: { ok: true },
2206
+ },
2207
+ ],
2208
+ },
2209
+ ] as any;
2210
+
2211
+ stableStringifySpy.mockClear();
2212
+ for (let i = 0; i < 3; i++) {
2213
+ AISDKMessageConverter.toThreadMessages(messages, false, metadata);
2214
+ }
2215
+ expect(stableStringifySpy).toHaveBeenCalledTimes(1);
2216
+ });
2217
+
2218
+ it("skips null parts and parts without a type", () => {
2219
+ const converted = AISDKMessageConverter.toThreadMessages([
2220
+ {
2221
+ id: "u1",
2222
+ role: "user",
2223
+ parts: [null, { text: "no type" }, { type: "text", text: "hi" }],
2224
+ },
2225
+ {
2226
+ id: "a1",
2227
+ role: "assistant",
2228
+ parts: [null, { text: "no type" }, { type: "text", text: "yo" }],
2229
+ },
2230
+ ] as any);
2231
+
2232
+ expect(converted[0]?.content).toMatchObject([{ type: "text", text: "hi" }]);
2233
+ expect(converted[0]?.attachments).toEqual([]);
2234
+ expect(converted[1]?.content).toMatchObject([{ type: "text", text: "yo" }]);
2235
+ });
2236
+
2237
+ it("joins the text parts of a system message into one text part", () => {
2238
+ const converted = AISDKMessageConverter.toThreadMessages([
2239
+ {
2240
+ id: "s1",
2241
+ role: "system",
2242
+ parts: [
2243
+ {
2244
+ type: "text",
2245
+ text: "be ",
2246
+ providerMetadata: { p1: { k: "a" }, p2: { k: "b" } },
2247
+ },
2248
+ { type: "text", text: "brief", providerMetadata: { p2: { k: "c" } } },
2249
+ ],
2250
+ },
2251
+ { id: "s2", role: "system", parts: [] },
2252
+ ] as any);
2253
+
2254
+ expect(converted[0]?.content).toMatchObject([
2255
+ {
2256
+ type: "text",
2257
+ text: "be brief",
2258
+ providerMetadata: { p1: { k: "a" }, p2: { k: "c" } },
2259
+ },
2260
+ ]);
2261
+ expect(converted[1]?.content).toMatchObject([{ type: "text", text: "" }]);
2262
+ expect(converted[1]?.content[0]).not.toHaveProperty("providerMetadata");
2263
+ });
2264
+
2265
+ it("reads a user text part without text as empty text", () => {
2266
+ const converted = AISDKMessageConverter.toThreadMessages([
2267
+ { id: "u1", role: "user", parts: [{ type: "text" }] },
2268
+ ] as any);
2269
+
2270
+ expect(converted[0]?.content).toMatchObject([{ type: "text", text: "" }]);
2271
+ });
2272
+
2273
+ it("skips a file part without a url and floors a missing mediaType", () => {
2274
+ const converted = AISDKMessageConverter.toThreadMessages([
2275
+ {
2276
+ id: "u1",
2277
+ role: "user",
2278
+ parts: [{ type: "file", mediaType: "image/png", filename: "a.png" }],
2279
+ },
2280
+ {
2281
+ id: "a1",
2282
+ role: "assistant",
2283
+ parts: [
2284
+ { type: "file", mediaType: "image/png" },
2285
+ { type: "file", url: "https://cdn/file.bin" },
2286
+ { type: "reasoning-file", mediaType: "image/png" },
2287
+ { type: "reasoning-file", url: "https://cdn/thought.bin" },
2288
+ ],
2289
+ },
2290
+ ] as any);
2291
+
2292
+ expect(converted[0]?.attachments).toEqual([]);
2293
+ expect(converted[1]?.content).toMatchObject([
2294
+ {
2295
+ type: "file",
2296
+ data: "https://cdn/file.bin",
2297
+ mimeType: "unknown/unknown",
2298
+ },
2299
+ {
2300
+ type: "file",
2301
+ data: "https://cdn/thought.bin",
2302
+ mimeType: "unknown/unknown",
2303
+ },
2304
+ ]);
2305
+ });
2306
+
2307
+ it("gives a dynamic tool call without a toolName an empty name", () => {
2308
+ const converted = AISDKMessageConverter.toThreadMessages([
2309
+ {
2310
+ id: "a1",
2311
+ role: "assistant",
2312
+ parts: [
2313
+ {
2314
+ type: "dynamic-tool",
2315
+ toolCallId: "tc-1",
2316
+ state: "input-available",
2317
+ input: {},
2318
+ },
2319
+ ],
2320
+ },
2321
+ ] as any);
2322
+
2323
+ expect(converted[0]?.content[0]).toMatchObject({
2324
+ type: "tool-call",
2325
+ toolCallId: "tc-1",
2326
+ toolName: "",
2327
+ });
2328
+ });
2329
+ });
2330
+
2331
+ describe("convertAISDKMessage file provider metadata", () => {
2332
+ const providerMetadata = { google: { thoughtSignature: "signature" } };
2333
+
2334
+ it("keeps provider metadata on user image and file attachments", () => {
2335
+ expect(
2336
+ convertAISDKMessage(
2337
+ {
2338
+ id: "user-1",
2339
+ role: "user",
2340
+ parts: [
2341
+ {
2342
+ type: "file",
2343
+ url: "https://example.com/cat.png",
2344
+ mediaType: "image/png",
2345
+ filename: "cat.png",
2346
+ providerMetadata,
2347
+ },
2348
+ {
2349
+ type: "file",
2350
+ url: "https://example.com/guide.pdf",
2351
+ mediaType: "application/pdf",
2352
+ filename: "guide.pdf",
2353
+ providerMetadata,
2354
+ },
2355
+ ],
2356
+ },
2357
+ {},
2358
+ ),
2359
+ ).toMatchObject({
2360
+ attachments: [
2361
+ {
2362
+ content: [
2363
+ {
2364
+ type: "image",
2365
+ image: "https://example.com/cat.png",
2366
+ filename: "cat.png",
2367
+ providerMetadata,
2368
+ },
2369
+ ],
2370
+ },
2371
+ {
2372
+ content: [
2373
+ {
2374
+ type: "file",
2375
+ data: "https://example.com/guide.pdf",
2376
+ mimeType: "application/pdf",
2377
+ filename: "guide.pdf",
2378
+ providerMetadata,
2379
+ },
2380
+ ],
2381
+ },
2382
+ ],
2383
+ });
2384
+ });
2385
+
2386
+ it("keeps provider metadata on assistant files and reasoning files", () => {
2387
+ expect(
2388
+ convertAISDKMessage(
2389
+ {
2390
+ id: "assistant-1",
2391
+ role: "assistant",
2392
+ parts: [
2393
+ {
2394
+ type: "file",
2395
+ url: "https://example.com/guide.pdf",
2396
+ mediaType: "application/pdf",
2397
+ filename: "guide.pdf",
2398
+ providerMetadata,
2399
+ },
2400
+ {
2401
+ type: "reasoning-file",
2402
+ url: "https://example.com/thinking.png",
2403
+ mediaType: "image/png",
2404
+ providerMetadata,
2405
+ },
2406
+ ],
2407
+ },
2408
+ {},
2409
+ ),
2410
+ ).toMatchObject({
2411
+ content: [
2412
+ {
2413
+ type: "file",
2414
+ data: "https://example.com/guide.pdf",
2415
+ mimeType: "application/pdf",
2416
+ filename: "guide.pdf",
2417
+ providerMetadata,
2418
+ },
2419
+ {
2420
+ type: "file",
2421
+ data: "https://example.com/thinking.png",
2422
+ mimeType: "image/png",
2423
+ providerMetadata,
2424
+ },
2425
+ ],
2426
+ });
2427
+ });
2428
+ });