@assistant-ui/ai-sdk 0.0.1

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 (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/dist/adapters/aiSDKFormatAdapter.d.ts +8 -0
  4. package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
  5. package/dist/adapters/aiSDKFormatAdapter.js +26 -0
  6. package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
  7. package/dist/adapters/vercelAttachmentAdapter.d.ts +6 -0
  8. package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
  9. package/dist/adapters/vercelAttachmentAdapter.js +37 -0
  10. package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
  11. package/dist/aiSDKExtras.d.ts +11 -0
  12. package/dist/aiSDKExtras.d.ts.map +1 -0
  13. package/dist/aiSDKExtras.js +7 -0
  14. package/dist/aiSDKExtras.js.map +1 -0
  15. package/dist/converters/convertMessage.d.ts +28 -0
  16. package/dist/converters/convertMessage.d.ts.map +1 -0
  17. package/dist/converters/convertMessage.js +249 -0
  18. package/dist/converters/convertMessage.js.map +1 -0
  19. package/dist/converters/modelContentEnvelope.d.ts +16 -0
  20. package/dist/converters/modelContentEnvelope.d.ts.map +1 -0
  21. package/dist/converters/modelContentEnvelope.js +22 -0
  22. package/dist/converters/modelContentEnvelope.js.map +1 -0
  23. package/dist/converters/toCreateMessage.d.ts +7 -0
  24. package/dist/converters/toCreateMessage.d.ts.map +1 -0
  25. package/dist/converters/toCreateMessage.js +58 -0
  26. package/dist/converters/toCreateMessage.js.map +1 -0
  27. package/dist/converters/toolOutputConversion.d.ts +29 -0
  28. package/dist/converters/toolOutputConversion.d.ts.map +1 -0
  29. package/dist/converters/toolOutputConversion.js +31 -0
  30. package/dist/converters/toolOutputConversion.js.map +1 -0
  31. package/dist/hooks.d.ts +19 -0
  32. package/dist/hooks.d.ts.map +1 -0
  33. package/dist/hooks.js +20 -0
  34. package/dist/hooks.js.map +1 -0
  35. package/dist/index.d.ts +14 -0
  36. package/dist/index.js +13 -0
  37. package/dist/index.native.d.ts +12 -0
  38. package/dist/index.native.js +11 -0
  39. package/dist/model-context/injectInteractableContext.d.ts +37 -0
  40. package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
  41. package/dist/model-context/injectInteractableContext.js +52 -0
  42. package/dist/model-context/injectInteractableContext.js.map +1 -0
  43. package/dist/model-context/injectQuoteContext.d.ts +27 -0
  44. package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
  45. package/dist/model-context/injectQuoteContext.js +51 -0
  46. package/dist/model-context/injectQuoteContext.js.map +1 -0
  47. package/dist/runtime/AISDKChat.d.ts +16 -0
  48. package/dist/runtime/AISDKChat.d.ts.map +1 -0
  49. package/dist/runtime/AISDKChat.js +69 -0
  50. package/dist/runtime/AISDKChat.js.map +1 -0
  51. package/dist/runtime/AISDKThreads.d.ts +45 -0
  52. package/dist/runtime/AISDKThreads.d.ts.map +1 -0
  53. package/dist/runtime/AISDKThreads.js +203 -0
  54. package/dist/runtime/AISDKThreads.js.map +1 -0
  55. package/dist/runtime/useAISDKRuntime.d.ts +49 -0
  56. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
  57. package/dist/runtime/useAISDKRuntime.js +266 -0
  58. package/dist/runtime/useAISDKRuntime.js.map +1 -0
  59. package/dist/runtime/useChatRuntime.d.ts +13 -0
  60. package/dist/runtime/useChatRuntime.d.ts.map +1 -0
  61. package/dist/runtime/useChatRuntime.js +52 -0
  62. package/dist/runtime/useChatRuntime.js.map +1 -0
  63. package/dist/runtime/useChatThread.d.ts +77 -0
  64. package/dist/runtime/useChatThread.d.ts.map +1 -0
  65. package/dist/runtime/useChatThread.js +134 -0
  66. package/dist/runtime/useChatThread.js.map +1 -0
  67. package/dist/runtime/useExternalHistory.d.ts +11 -0
  68. package/dist/runtime/useExternalHistory.d.ts.map +1 -0
  69. package/dist/runtime/useExternalHistory.js +278 -0
  70. package/dist/runtime/useExternalHistory.js.map +1 -0
  71. package/dist/runtime/useResourceCleanup.d.ts +5 -0
  72. package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
  73. package/dist/runtime/useResourceCleanup.js +47 -0
  74. package/dist/runtime/useResourceCleanup.js.map +1 -0
  75. package/dist/runtime/useStreamingTiming.d.ts +15 -0
  76. package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
  77. package/dist/runtime/useStreamingTiming.js +38 -0
  78. package/dist/runtime/useStreamingTiming.js.map +1 -0
  79. package/dist/tools/frontendTools.d.ts +33 -0
  80. package/dist/tools/frontendTools.d.ts.map +1 -0
  81. package/dist/tools/frontendTools.js +33 -0
  82. package/dist/tools/frontendTools.js.map +1 -0
  83. package/dist/tools/generativeTools.d.ts +73 -0
  84. package/dist/tools/generativeTools.d.ts.map +1 -0
  85. package/dist/tools/generativeTools.js +245 -0
  86. package/dist/tools/generativeTools.js.map +1 -0
  87. package/dist/tools/mcp-stdio.node.d.ts +2 -0
  88. package/dist/tools/mcp-stdio.node.js +2 -0
  89. package/dist/tools/mcp-stdio.unsupported.d.ts +6 -0
  90. package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
  91. package/dist/tools/mcp-stdio.unsupported.js +11 -0
  92. package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
  93. package/dist/transport/AssistantChatTransport.d.ts +23 -0
  94. package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
  95. package/dist/transport/AssistantChatTransport.js +134 -0
  96. package/dist/transport/AssistantChatTransport.js.map +1 -0
  97. package/dist/transport/resumable.d.ts +38 -0
  98. package/dist/transport/resumable.d.ts.map +1 -0
  99. package/dist/transport/resumable.js +113 -0
  100. package/dist/transport/resumable.js.map +1 -0
  101. package/dist/usage.d.ts +19 -0
  102. package/dist/usage.d.ts.map +1 -0
  103. package/dist/usage.js +127 -0
  104. package/dist/usage.js.map +1 -0
  105. package/dist/utils/getVercelAIMessages.d.ts +7 -0
  106. package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
  107. package/dist/utils/getVercelAIMessages.js +9 -0
  108. package/dist/utils/getVercelAIMessages.js.map +1 -0
  109. package/dist/utils/sliceMessagesUntil.d.ts +6 -0
  110. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
  111. package/dist/utils/sliceMessagesUntil.js +12 -0
  112. package/dist/utils/sliceMessagesUntil.js.map +1 -0
  113. package/package.json +100 -0
  114. package/src/__tests__/standalone.e2e.ts +59 -0
  115. package/src/adapters/aiSDKFormatAdapter.ts +41 -0
  116. package/src/adapters/vercelAttachmentAdapter.test.ts +29 -0
  117. package/src/adapters/vercelAttachmentAdapter.ts +36 -0
  118. package/src/aiSDKExtras.ts +11 -0
  119. package/src/converters/convertMessage.test.ts +1130 -0
  120. package/src/converters/convertMessage.ts +457 -0
  121. package/src/converters/modelContentEnvelope.ts +41 -0
  122. package/src/converters/toCreateMessage.test.ts +596 -0
  123. package/src/converters/toCreateMessage.ts +99 -0
  124. package/src/converters/toolOutputConversion.ts +26 -0
  125. package/src/hooks.ts +23 -0
  126. package/src/index.native.ts +21 -0
  127. package/src/index.ts +29 -0
  128. package/src/model-context/injectInteractableContext.test.ts +167 -0
  129. package/src/model-context/injectInteractableContext.ts +61 -0
  130. package/src/model-context/injectQuoteContext.test.ts +33 -0
  131. package/src/model-context/injectQuoteContext.ts +62 -0
  132. package/src/runtime/AISDKChat.integration.test.tsx +47 -0
  133. package/src/runtime/AISDKChat.react-compiler.test.ts +49 -0
  134. package/src/runtime/AISDKChat.test.ts +193 -0
  135. package/src/runtime/AISDKChat.ts +47 -0
  136. package/src/runtime/AISDKThreads.cloud.test.ts +136 -0
  137. package/src/runtime/AISDKThreads.test.ts +548 -0
  138. package/src/runtime/AISDKThreads.ts +223 -0
  139. package/src/runtime/__tests__/controlled-transport.ts +43 -0
  140. package/src/runtime/useAISDKRuntime.approval.test.tsx +69 -0
  141. package/src/runtime/useAISDKRuntime.test.ts +1021 -0
  142. package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
  143. package/src/runtime/useAISDKRuntime.ts +524 -0
  144. package/src/runtime/useChatOnTap.test.ts +58 -0
  145. package/src/runtime/useChatRuntime.integration.test.tsx +67 -0
  146. package/src/runtime/useChatRuntime.test.ts +522 -0
  147. package/src/runtime/useChatRuntime.ts +49 -0
  148. package/src/runtime/useChatThread.ts +286 -0
  149. package/src/runtime/useExternalHistory.test.ts +1289 -0
  150. package/src/runtime/useExternalHistory.ts +435 -0
  151. package/src/runtime/useResourceCleanup.ts +32 -0
  152. package/src/runtime/useStreamingTiming.ts +68 -0
  153. package/src/tools/frontendTools.test.ts +205 -0
  154. package/src/tools/frontendTools.ts +83 -0
  155. package/src/tools/generativeTools.test.ts +785 -0
  156. package/src/tools/generativeTools.ts +470 -0
  157. package/src/tools/mcp-stdio.node.ts +1 -0
  158. package/src/tools/mcp-stdio.unsupported.ts +12 -0
  159. package/src/transport/AssistantChatTransport.test.ts +300 -0
  160. package/src/transport/AssistantChatTransport.ts +204 -0
  161. package/src/transport/resumable.test.ts +262 -0
  162. package/src/transport/resumable.ts +161 -0
  163. package/src/usage.test.ts +186 -0
  164. package/src/usage.ts +174 -0
  165. package/src/utils/getVercelAIMessages.ts +11 -0
  166. package/src/utils/sliceMessagesUntil.test.ts +55 -0
  167. package/src/utils/sliceMessagesUntil.ts +20 -0
@@ -0,0 +1,1130 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getPartialJsonObjectFieldState,
4
+ type ReadonlyJSONObject,
5
+ } from "assistant-stream/utils";
6
+ import {
7
+ AISDKMessageConverter,
8
+ type AISDKMessageConverterMetadata,
9
+ } from "./convertMessage";
10
+
11
+ describe("AISDKMessageConverter", () => {
12
+ it("flags the streaming assistant message as optimistic", () => {
13
+ const metadata: AISDKMessageConverterMetadata = {
14
+ optimisticMessageId: "a1",
15
+ };
16
+ const converted = AISDKMessageConverter.toThreadMessages(
17
+ [
18
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
19
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
20
+ ] as any,
21
+ true,
22
+ metadata,
23
+ );
24
+
25
+ expect(converted[0]?.metadata.isOptimistic).toBeFalsy();
26
+ expect(converted[1]?.metadata.isOptimistic).toBe(true);
27
+ });
28
+
29
+ it("does not flag messages when no optimistic id is provided", () => {
30
+ const converted = AISDKMessageConverter.toThreadMessages([
31
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "yo" }] },
32
+ ] as any);
33
+
34
+ expect(converted[0]?.metadata.isOptimistic).toBeFalsy();
35
+ });
36
+
37
+ it("converts user files into attachments and keeps text content", () => {
38
+ const converted = AISDKMessageConverter.toThreadMessages([
39
+ {
40
+ id: "u1",
41
+ role: "user",
42
+ parts: [
43
+ { type: "text", text: "hello" },
44
+ {
45
+ type: "file",
46
+ mediaType: "image/png",
47
+ url: "https://cdn/img.png",
48
+ filename: "img.png",
49
+ },
50
+ {
51
+ type: "file",
52
+ mediaType: "application/pdf",
53
+ url: "https://cdn/file.pdf",
54
+ filename: "file.pdf",
55
+ },
56
+ ],
57
+ } as any,
58
+ ]);
59
+
60
+ expect(converted).toHaveLength(1);
61
+ expect(converted[0]?.role).toBe("user");
62
+ expect(converted[0]?.content).toHaveLength(1);
63
+ expect(converted[0]?.content[0]).toMatchObject({
64
+ type: "text",
65
+ text: "hello",
66
+ });
67
+ expect(converted[0]?.attachments).toHaveLength(2);
68
+ expect(converted[0]?.attachments?.[0]?.type).toBe("image");
69
+ expect(converted[0]?.attachments?.[1]?.type).toBe("file");
70
+ });
71
+
72
+ it("degrades a user file part missing mediaType instead of throwing", () => {
73
+ const convert = () =>
74
+ AISDKMessageConverter.toThreadMessages([
75
+ {
76
+ id: "u1",
77
+ role: "user",
78
+ parts: [
79
+ {
80
+ type: "file",
81
+ url: "https://cdn/file.bin",
82
+ filename: "file.bin",
83
+ },
84
+ ],
85
+ } as any,
86
+ ]);
87
+
88
+ expect(convert).not.toThrow();
89
+ const attachment = convert()[0]?.attachments?.[0];
90
+ expect(attachment?.type).toBe("file");
91
+ expect(attachment?.contentType).toBe("unknown/unknown");
92
+ expect(attachment?.content[0]).toMatchObject({
93
+ type: "file",
94
+ mimeType: "unknown/unknown",
95
+ });
96
+ });
97
+
98
+ it("converts source-document parts into document sources", () => {
99
+ const converted = AISDKMessageConverter.toThreadMessages([
100
+ {
101
+ id: "a1",
102
+ role: "assistant",
103
+ parts: [
104
+ {
105
+ type: "source-document",
106
+ sourceId: "doc_123",
107
+ title: "proposal.pdf",
108
+ mediaType: "application/pdf",
109
+ filename: "proposal.pdf",
110
+ providerMetadata: {
111
+ openai: {
112
+ type: "file_citation",
113
+ fileId: "file_123",
114
+ index: 0,
115
+ },
116
+ },
117
+ },
118
+ ],
119
+ } as any,
120
+ ]);
121
+
122
+ expect(converted).toHaveLength(1);
123
+ expect(converted[0]?.role).toBe("assistant");
124
+
125
+ const sourcePart = converted[0]?.content.find(
126
+ (part): part is any => part.type === "source",
127
+ );
128
+
129
+ expect(sourcePart).toMatchObject({
130
+ type: "source",
131
+ sourceType: "document",
132
+ id: "doc_123",
133
+ title: "proposal.pdf",
134
+ mediaType: "application/pdf",
135
+ filename: "proposal.pdf",
136
+ providerMetadata: {
137
+ openai: {
138
+ type: "file_citation",
139
+ fileId: "file_123",
140
+ index: 0,
141
+ },
142
+ },
143
+ });
144
+ });
145
+
146
+ it("converts source-url parts without synthesizing missing optional fields", () => {
147
+ const converted = AISDKMessageConverter.toThreadMessages([
148
+ {
149
+ id: "a1",
150
+ role: "assistant",
151
+ parts: [
152
+ {
153
+ type: "source-url",
154
+ sourceId: "url_123",
155
+ url: "https://example.com/report",
156
+ providerMetadata: {
157
+ openai: {
158
+ type: "url_citation",
159
+ index: 1,
160
+ },
161
+ },
162
+ },
163
+ ],
164
+ } as any,
165
+ ]);
166
+
167
+ const sourcePart = converted[0]?.content.find(
168
+ (part): part is any => part.type === "source",
169
+ );
170
+
171
+ expect(sourcePart).toMatchObject({
172
+ type: "source",
173
+ sourceType: "url",
174
+ id: "url_123",
175
+ url: "https://example.com/report",
176
+ providerMetadata: {
177
+ openai: {
178
+ type: "url_citation",
179
+ index: 1,
180
+ },
181
+ },
182
+ });
183
+ expect(sourcePart).not.toHaveProperty("title");
184
+ });
185
+
186
+ it("converts assistant image file parts into file content", () => {
187
+ const converted = AISDKMessageConverter.toThreadMessages([
188
+ {
189
+ id: "a1",
190
+ role: "assistant",
191
+ parts: [
192
+ { type: "text", text: "Here is the image" },
193
+ {
194
+ type: "file",
195
+ mediaType: "image/png",
196
+ url: "https://cdn/generated.png",
197
+ filename: "generated.png",
198
+ },
199
+ ],
200
+ } as any,
201
+ ]);
202
+
203
+ expect(converted).toHaveLength(1);
204
+ expect(converted[0]?.role).toBe("assistant");
205
+ expect(converted[0]?.content).toHaveLength(2);
206
+ expect(converted[0]?.content[0]).toMatchObject({
207
+ type: "text",
208
+ text: "Here is the image",
209
+ });
210
+ expect(converted[0]?.content[1]).toMatchObject({
211
+ type: "file",
212
+ data: "https://cdn/generated.png",
213
+ mimeType: "image/png",
214
+ filename: "generated.png",
215
+ });
216
+ });
217
+
218
+ it("converts assistant non-image file parts into file content", () => {
219
+ const converted = AISDKMessageConverter.toThreadMessages([
220
+ {
221
+ id: "a1",
222
+ role: "assistant",
223
+ parts: [
224
+ { type: "text", text: "Here is the PDF" },
225
+ {
226
+ type: "file",
227
+ mediaType: "application/pdf",
228
+ url: "data:application/pdf;base64,abc123",
229
+ filename: "report.pdf",
230
+ },
231
+ ],
232
+ } as any,
233
+ ]);
234
+
235
+ expect(converted).toHaveLength(1);
236
+ expect(converted[0]?.role).toBe("assistant");
237
+ expect(converted[0]?.content).toHaveLength(2);
238
+ expect(converted[0]?.content[1]).toMatchObject({
239
+ type: "file",
240
+ data: "data:application/pdf;base64,abc123",
241
+ mimeType: "application/pdf",
242
+ filename: "report.pdf",
243
+ });
244
+ });
245
+
246
+ it("deduplicates tool calls by toolCallId and surfaces approval / interrupt state", () => {
247
+ const converted = AISDKMessageConverter.toThreadMessages(
248
+ [
249
+ {
250
+ id: "a1",
251
+ role: "assistant",
252
+ parts: [
253
+ {
254
+ type: "tool-weather",
255
+ toolCallId: "tc-1",
256
+ state: "output-available",
257
+ input: { city: "NYC" },
258
+ output: { temp: 72 },
259
+ },
260
+ {
261
+ type: "tool-weather",
262
+ toolCallId: "tc-1",
263
+ state: "output-available",
264
+ input: { city: "NYC" },
265
+ output: { temp: 73 },
266
+ },
267
+ {
268
+ type: "tool-approve",
269
+ toolCallId: "tc-2",
270
+ state: "approval-requested",
271
+ input: { action: "deploy" },
272
+ approval: { id: "appr-1" },
273
+ },
274
+ {
275
+ type: "tool-human",
276
+ toolCallId: "tc-3",
277
+ state: "input-available",
278
+ input: { task: "confirm" },
279
+ },
280
+ {
281
+ type: "tool-approve",
282
+ toolCallId: "tc-4",
283
+ state: "approval-responded",
284
+ input: { action: "rollback" },
285
+ approval: { id: "appr-2", approved: true, reason: "looks ok" },
286
+ },
287
+ {
288
+ type: "tool-approve",
289
+ toolCallId: "tc-5",
290
+ state: "approval-requested",
291
+ input: { action: "auto" },
292
+ approval: { id: "appr-3", isAutomatic: true },
293
+ },
294
+ {
295
+ type: "tool-approve",
296
+ toolCallId: "tc-6",
297
+ state: "output-denied",
298
+ input: { action: "wipe" },
299
+ approval: {
300
+ id: "appr-4",
301
+ approved: false,
302
+ reason: "user denied",
303
+ },
304
+ },
305
+ {
306
+ type: "tool-approve",
307
+ toolCallId: "tc-7",
308
+ state: "output-available",
309
+ input: { action: "ship" },
310
+ output: { ok: true },
311
+ approval: { id: "appr-5", approved: true, isAutomatic: true },
312
+ },
313
+ ],
314
+ } as any,
315
+ ],
316
+ false,
317
+ {
318
+ toolStatuses: {
319
+ "tc-3": {
320
+ type: "interrupt",
321
+ payload: { type: "human", payload: { kind: "human" } },
322
+ },
323
+ },
324
+ },
325
+ );
326
+
327
+ const toolCalls = converted[0]?.content.filter(
328
+ (part): part is any => part.type === "tool-call",
329
+ );
330
+ expect(toolCalls).toHaveLength(7);
331
+
332
+ expect(toolCalls?.filter((p) => p.toolCallId === "tc-1")).toHaveLength(1);
333
+
334
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-2")?.approval).toEqual({
335
+ id: "appr-1",
336
+ });
337
+ expect(
338
+ toolCalls?.find((p) => p.toolCallId === "tc-2")?.interrupt,
339
+ ).toBeUndefined();
340
+
341
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-3")?.interrupt).toEqual({
342
+ type: "human",
343
+ payload: { kind: "human" },
344
+ });
345
+ expect(
346
+ toolCalls?.find((p) => p.toolCallId === "tc-3")?.approval,
347
+ ).toBeUndefined();
348
+
349
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-4")?.approval).toEqual({
350
+ id: "appr-2",
351
+ approved: true,
352
+ reason: "looks ok",
353
+ });
354
+
355
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-5")?.approval).toEqual({
356
+ id: "appr-3",
357
+ isAutomatic: true,
358
+ });
359
+
360
+ const denied = toolCalls?.find((p) => p.toolCallId === "tc-6");
361
+ expect(denied?.approval).toEqual({
362
+ id: "appr-4",
363
+ approved: false,
364
+ reason: "user denied",
365
+ });
366
+ expect(denied?.isError).toBe(true);
367
+
368
+ expect(toolCalls?.find((p) => p.toolCallId === "tc-7")?.approval).toEqual({
369
+ id: "appr-5",
370
+ approved: true,
371
+ isAutomatic: true,
372
+ });
373
+ });
374
+
375
+ it("strips closing delimiters from streaming tool argsText", () => {
376
+ const converted = AISDKMessageConverter.toThreadMessages([
377
+ {
378
+ id: "a1",
379
+ role: "assistant",
380
+ parts: [
381
+ {
382
+ type: "tool-weather",
383
+ toolCallId: "tc-1",
384
+ state: "input-streaming",
385
+ input: { city: "NYC" },
386
+ },
387
+ ],
388
+ } as any,
389
+ ]);
390
+
391
+ const toolCall = converted[0]?.content.find(
392
+ (part): part is any => part.type === "tool-call",
393
+ );
394
+ expect(toolCall?.argsText).toBe('{"city":"NYC');
395
+ });
396
+
397
+ it("attaches partial-JSON meta marking the trailing streaming field", () => {
398
+ const converted = AISDKMessageConverter.toThreadMessages([
399
+ {
400
+ id: "a1",
401
+ role: "assistant",
402
+ parts: [
403
+ {
404
+ type: "tool-weather",
405
+ toolCallId: "tc-1",
406
+ state: "input-streaming",
407
+ input: { city: "NYC", units: "F" },
408
+ },
409
+ ],
410
+ } as any,
411
+ ]);
412
+
413
+ const toolCall = converted[0]?.content.find(
414
+ (part): part is any => part.type === "tool-call",
415
+ );
416
+ expect(toolCall?.args).toMatchObject({ city: "NYC", units: "F" });
417
+ expect(getPartialJsonObjectFieldState(toolCall!.args, ["city"])).toBe(
418
+ "complete",
419
+ );
420
+ expect(getPartialJsonObjectFieldState(toolCall!.args, ["units"])).toBe(
421
+ "partial",
422
+ );
423
+ });
424
+
425
+ it("keeps observed key order from streaming snapshots for final tool args", () => {
426
+ const metadata: AISDKMessageConverterMetadata = {
427
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
428
+ };
429
+
430
+ const streaming = AISDKMessageConverter.toThreadMessages(
431
+ [
432
+ {
433
+ id: "a1",
434
+ role: "assistant",
435
+ parts: [
436
+ {
437
+ type: "tool-stocks",
438
+ toolCallId: "tc-order-1",
439
+ state: "input-streaming",
440
+ input: {
441
+ type: "high_stock_model",
442
+ limit: 5,
443
+ filters: {
444
+ region: "us",
445
+ sector: "tech",
446
+ },
447
+ },
448
+ },
449
+ ],
450
+ } as any,
451
+ ],
452
+ false,
453
+ metadata,
454
+ );
455
+
456
+ const streamingToolCall = streaming[0]?.content.find(
457
+ (part): part is any => part.type === "tool-call",
458
+ );
459
+ expect(streamingToolCall?.argsText).toBe(
460
+ '{"type":"high_stock_model","limit":5,"filters":{"region":"us","sector":"tech',
461
+ );
462
+
463
+ const final = AISDKMessageConverter.toThreadMessages(
464
+ [
465
+ {
466
+ id: "a1",
467
+ role: "assistant",
468
+ parts: [
469
+ {
470
+ type: "tool-stocks",
471
+ toolCallId: "tc-order-1",
472
+ state: "input-available",
473
+ input: {
474
+ filters: {
475
+ sector: "tech",
476
+ region: "us",
477
+ },
478
+ limit: 5,
479
+ type: "high_stock_model",
480
+ },
481
+ },
482
+ ],
483
+ } as any,
484
+ ],
485
+ false,
486
+ metadata,
487
+ );
488
+
489
+ const finalToolCall = final[0]?.content.find(
490
+ (part): part is any => part.type === "tool-call",
491
+ );
492
+ expect(finalToolCall?.argsText).toBe(
493
+ '{"type":"high_stock_model","limit":5,"filters":{"region":"us","sector":"tech"}}',
494
+ );
495
+ });
496
+
497
+ it("merges duplicate toolCallId across assistant snapshots", () => {
498
+ const metadata: AISDKMessageConverterMetadata = {
499
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
500
+ };
501
+
502
+ const converted = AISDKMessageConverter.toThreadMessages(
503
+ [
504
+ {
505
+ id: "a1",
506
+ role: "assistant",
507
+ parts: [
508
+ {
509
+ type: "tool-stocks",
510
+ toolCallId: "tc-order-1",
511
+ state: "input-streaming",
512
+ input: {
513
+ type: "high_stock_model",
514
+ limit: 5,
515
+ },
516
+ },
517
+ ],
518
+ } as any,
519
+ {
520
+ id: "a2",
521
+ role: "assistant",
522
+ parts: [
523
+ {
524
+ type: "tool-stocks",
525
+ toolCallId: "tc-order-1",
526
+ state: "input-available",
527
+ input: {
528
+ limit: 5,
529
+ type: "high_stock_model",
530
+ },
531
+ },
532
+ ],
533
+ } as any,
534
+ ],
535
+ false,
536
+ metadata,
537
+ );
538
+
539
+ const toolCalls = converted[0]?.content.filter(
540
+ (part): part is any => part.type === "tool-call",
541
+ );
542
+ expect(toolCalls).toHaveLength(1);
543
+ expect(toolCalls?.[0]?.toolCallId).toBe("tc-order-1");
544
+ expect(JSON.parse(toolCalls?.[0]?.argsText ?? "{}")).toEqual({
545
+ type: "high_stock_model",
546
+ limit: 5,
547
+ });
548
+ });
549
+
550
+ it("preserves last good input when AI SDK briefly emits null input", () => {
551
+ const metadata: AISDKMessageConverterMetadata = {
552
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
553
+ toolLastInputCache: new Map<string, ReadonlyJSONObject>(),
554
+ };
555
+
556
+ const convertWithInput = (input: unknown) =>
557
+ AISDKMessageConverter.toThreadMessages(
558
+ [
559
+ {
560
+ id: "a1",
561
+ role: "assistant",
562
+ parts: [
563
+ {
564
+ type: "tool-weather",
565
+ toolCallId: "tc-1",
566
+ state: "input-streaming",
567
+ input,
568
+ },
569
+ ],
570
+ } as any,
571
+ ],
572
+ false,
573
+ metadata,
574
+ )[0]?.content.find((part): part is any => part.type === "tool-call");
575
+
576
+ const first = convertWithInput({ city: "NYC" });
577
+ expect(first?.argsText).toBe('{"city":"NYC');
578
+ expect(first?.args).toMatchObject({ city: "NYC" });
579
+
580
+ const dropped = convertWithInput(null);
581
+ expect(dropped?.argsText).toBe('{"city":"NYC');
582
+ expect(dropped?.args).toMatchObject({ city: "NYC" });
583
+
584
+ const undef = convertWithInput(undefined);
585
+ expect(undef?.argsText).toBe('{"city":"NYC');
586
+ expect(undef?.args).toMatchObject({ city: "NYC" });
587
+
588
+ const grown = convertWithInput({ city: "NYC", units: "F" });
589
+ expect(grown?.argsText).toBe('{"city":"NYC","units":"F');
590
+ expect(grown?.args).toMatchObject({ city: "NYC", units: "F" });
591
+ });
592
+
593
+ it("preserves last good input across terminal state transitions", () => {
594
+ const metadata: AISDKMessageConverterMetadata = {
595
+ toolArgsKeyOrderCache: new Map<string, Map<string, string[]>>(),
596
+ toolLastInputCache: new Map<string, ReadonlyJSONObject>(),
597
+ };
598
+
599
+ AISDKMessageConverter.toThreadMessages(
600
+ [
601
+ {
602
+ id: "a1",
603
+ role: "assistant",
604
+ parts: [
605
+ {
606
+ type: "tool-weather",
607
+ toolCallId: "tc-1",
608
+ state: "input-available",
609
+ input: { city: "NYC" },
610
+ },
611
+ ],
612
+ } as any,
613
+ ],
614
+ false,
615
+ metadata,
616
+ );
617
+
618
+ const terminal = AISDKMessageConverter.toThreadMessages(
619
+ [
620
+ {
621
+ id: "a1",
622
+ role: "assistant",
623
+ parts: [
624
+ {
625
+ type: "tool-weather",
626
+ toolCallId: "tc-1",
627
+ state: "output-available",
628
+ input: null,
629
+ output: { temp: 70 },
630
+ },
631
+ ],
632
+ } as any,
633
+ ],
634
+ false,
635
+ metadata,
636
+ );
637
+
638
+ const call = terminal[0]?.content.find(
639
+ (part): part is any => part.type === "tool-call",
640
+ );
641
+ expect(call?.args).toEqual({ city: "NYC" });
642
+ expect(call?.result).toEqual({ temp: 70 });
643
+ });
644
+
645
+ it("unwraps the modelContent envelope produced by frontend tool execution", () => {
646
+ const converted = AISDKMessageConverter.toThreadMessages([
647
+ {
648
+ id: "a1",
649
+ role: "assistant",
650
+ parts: [
651
+ {
652
+ type: "tool-readPdf",
653
+ toolCallId: "tc-pdf",
654
+ state: "output-available",
655
+ input: {},
656
+ output: {
657
+ __aui_modelContent: [
658
+ { type: "text", text: "PDF contents:" },
659
+ {
660
+ type: "file",
661
+ data: "JVBERi0xLjQK",
662
+ mediaType: "application/pdf",
663
+ },
664
+ ],
665
+ value: { mediaType: "application/pdf", base64: "JVBERi0xLjQK" },
666
+ },
667
+ },
668
+ ],
669
+ } as any,
670
+ ]);
671
+
672
+ const call = converted[0]?.content.find(
673
+ (part): part is any => part.type === "tool-call",
674
+ );
675
+ expect(call?.result).toEqual({
676
+ mediaType: "application/pdf",
677
+ base64: "JVBERi0xLjQK",
678
+ });
679
+ expect(call?.modelContent).toEqual([
680
+ { type: "text", text: "PDF contents:" },
681
+ {
682
+ type: "file",
683
+ data: "JVBERi0xLjQK",
684
+ mediaType: "application/pdf",
685
+ },
686
+ ]);
687
+ });
688
+
689
+ it("leaves a plain output untouched when no envelope is present", () => {
690
+ const converted = AISDKMessageConverter.toThreadMessages([
691
+ {
692
+ id: "a1",
693
+ role: "assistant",
694
+ parts: [
695
+ {
696
+ type: "tool-weather",
697
+ toolCallId: "tc-1",
698
+ state: "output-available",
699
+ input: { city: "NYC" },
700
+ output: { temp: 72 },
701
+ },
702
+ ],
703
+ } as any,
704
+ ]);
705
+
706
+ const call = converted[0]?.content.find(
707
+ (part): part is any => part.type === "tool-call",
708
+ );
709
+ expect(call?.result).toEqual({ temp: 72 });
710
+ expect(call?.modelContent).toBeUndefined();
711
+ });
712
+
713
+ it("forwards callProviderMetadata.mcp.app onto ToolCallMessagePart.mcp.app", () => {
714
+ const converted = AISDKMessageConverter.toThreadMessages([
715
+ {
716
+ id: "a1",
717
+ role: "assistant",
718
+ parts: [
719
+ {
720
+ type: "tool-search",
721
+ toolCallId: "tc-1",
722
+ state: "output-available",
723
+ input: { query: "hi" },
724
+ output: { results: [] },
725
+ callProviderMetadata: {
726
+ mcp: {
727
+ app: {
728
+ resourceUri: "ui://example/search",
729
+ mimeType: "text/html;profile=mcp-app",
730
+ visibility: ["app", "model", "bogus"],
731
+ },
732
+ },
733
+ },
734
+ },
735
+ ],
736
+ } as any,
737
+ ]);
738
+
739
+ const call = converted[0]?.content.find(
740
+ (part): part is any => part.type === "tool-call",
741
+ );
742
+ expect(call?.mcp?.app).toEqual({
743
+ resourceUri: "ui://example/search",
744
+ mimeType: "text/html;profile=mcp-app",
745
+ visibility: ["app", "model"],
746
+ });
747
+ });
748
+
749
+ it("omits an empty callProviderMetadata.mcp.app.serverId", () => {
750
+ const converted = AISDKMessageConverter.toThreadMessages([
751
+ {
752
+ id: "a1",
753
+ role: "assistant",
754
+ parts: [
755
+ {
756
+ type: "tool-search",
757
+ toolCallId: "tc-1",
758
+ state: "output-available",
759
+ input: { query: "hi" },
760
+ output: { results: [] },
761
+ callProviderMetadata: {
762
+ mcp: {
763
+ app: {
764
+ resourceUri: "ui://example/search",
765
+ serverId: "",
766
+ },
767
+ },
768
+ },
769
+ },
770
+ ],
771
+ } as any,
772
+ ]);
773
+
774
+ const call = converted[0]?.content.find(
775
+ (part): part is any => part.type === "tool-call",
776
+ );
777
+ expect(call?.mcp?.app).toEqual({
778
+ resourceUri: "ui://example/search",
779
+ });
780
+ });
781
+
782
+ it("preserves providerMetadata on text and reasoning parts", () => {
783
+ const converted = AISDKMessageConverter.toThreadMessages([
784
+ {
785
+ id: "a1",
786
+ role: "assistant",
787
+ parts: [
788
+ {
789
+ type: "text",
790
+ text: "hello",
791
+ providerMetadata: { acme: { agentName: "researcher" } },
792
+ },
793
+ {
794
+ type: "reasoning",
795
+ text: "thinking",
796
+ providerMetadata: { acme: { agentName: "researcher" } },
797
+ },
798
+ { type: "text", text: "plain" },
799
+ ],
800
+ } as any,
801
+ ]);
802
+
803
+ expect(converted[0]?.content[0]).toMatchObject({
804
+ type: "text",
805
+ text: "hello",
806
+ providerMetadata: { acme: { agentName: "researcher" } },
807
+ });
808
+ expect(converted[0]?.content[1]).toMatchObject({
809
+ type: "reasoning",
810
+ text: "thinking",
811
+ providerMetadata: { acme: { agentName: "researcher" } },
812
+ });
813
+ expect(converted[0]?.content[2]).not.toHaveProperty("providerMetadata");
814
+ });
815
+
816
+ it("maps TextUIPart.state onto the per-part status", () => {
817
+ const converted = AISDKMessageConverter.toThreadMessages([
818
+ {
819
+ id: "a1",
820
+ role: "assistant",
821
+ parts: [
822
+ { type: "text", text: "streaming", state: "streaming" },
823
+ { type: "text", text: "done", state: "done" },
824
+ { type: "text", text: "unset" },
825
+ ],
826
+ } as any,
827
+ ]);
828
+
829
+ expect(converted[0]?.content[0]).toMatchObject({
830
+ type: "text",
831
+ text: "streaming",
832
+ status: { type: "running" },
833
+ });
834
+ expect(converted[0]?.content[1]).toMatchObject({
835
+ type: "text",
836
+ text: "done",
837
+ status: { type: "complete" },
838
+ });
839
+ expect(converted[0]?.content[2]).not.toHaveProperty("status");
840
+ });
841
+
842
+ it("maps ReasoningUIPart.state onto the per-part status", () => {
843
+ const converted = AISDKMessageConverter.toThreadMessages([
844
+ {
845
+ id: "a1",
846
+ role: "assistant",
847
+ parts: [
848
+ { type: "reasoning", text: "thinking", state: "streaming" },
849
+ { type: "reasoning", text: "settled", state: "done" },
850
+ { type: "reasoning", text: "unset" },
851
+ ],
852
+ } as any,
853
+ ]);
854
+
855
+ expect(converted[0]?.content[0]).toMatchObject({
856
+ type: "reasoning",
857
+ text: "thinking",
858
+ status: { type: "running" },
859
+ });
860
+ expect(converted[0]?.content[1]).toMatchObject({
861
+ type: "reasoning",
862
+ text: "settled",
863
+ status: { type: "complete" },
864
+ });
865
+ expect(converted[0]?.content[2]).not.toHaveProperty("status");
866
+ });
867
+
868
+ it("forwards callProviderMetadata onto ToolCallMessagePart.providerMetadata", () => {
869
+ const converted = AISDKMessageConverter.toThreadMessages([
870
+ {
871
+ id: "a1",
872
+ role: "assistant",
873
+ parts: [
874
+ {
875
+ type: "tool-search",
876
+ toolCallId: "tc-1",
877
+ state: "output-available",
878
+ input: { query: "hi" },
879
+ output: { results: [] },
880
+ callProviderMetadata: { acme: { agentName: "researcher" } },
881
+ },
882
+ {
883
+ type: "tool-search",
884
+ toolCallId: "tc-2",
885
+ state: "output-available",
886
+ input: { query: "yo" },
887
+ output: { results: [] },
888
+ },
889
+ ],
890
+ } as any,
891
+ ]);
892
+
893
+ const calls = converted[0]?.content.filter(
894
+ (part): part is any => part.type === "tool-call",
895
+ );
896
+ expect(calls?.[0]?.providerMetadata).toEqual({
897
+ acme: { agentName: "researcher" },
898
+ });
899
+ expect(calls?.[1]).not.toHaveProperty("providerMetadata");
900
+ });
901
+
902
+ it("extracts MCP app metadata from output._meta['ui/resourceUri']", () => {
903
+ const converted = AISDKMessageConverter.toThreadMessages([
904
+ {
905
+ id: "a1",
906
+ role: "assistant",
907
+ parts: [
908
+ {
909
+ type: "tool-hello_ui",
910
+ toolCallId: "tc-1",
911
+ state: "output-available",
912
+ input: {},
913
+ output: {
914
+ _meta: { "ui/resourceUri": "ui://app/hello_ui.html" },
915
+ content: [{ type: "text", text: "" }],
916
+ },
917
+ },
918
+ ],
919
+ } as any,
920
+ ]);
921
+
922
+ const call = converted[0]?.content.find(
923
+ (part): part is any => part.type === "tool-call",
924
+ );
925
+ expect(call?.mcp?.app).toEqual({
926
+ resourceUri: "ui://app/hello_ui.html",
927
+ });
928
+ });
929
+
930
+ it("adopts only spec'd ui fields from output._meta.ui", () => {
931
+ const converted = AISDKMessageConverter.toThreadMessages([
932
+ {
933
+ id: "a1",
934
+ role: "assistant",
935
+ parts: [
936
+ {
937
+ type: "tool-hello_ui",
938
+ toolCallId: "tc-1",
939
+ state: "output-available",
940
+ input: {},
941
+ output: {
942
+ _meta: {
943
+ ui: {
944
+ resourceUri: "ui://app/hello_ui.html",
945
+ serverId: "srv",
946
+ visibility: ["model"],
947
+ },
948
+ },
949
+ content: [{ type: "text", text: "" }],
950
+ },
951
+ },
952
+ ],
953
+ } as any,
954
+ ]);
955
+
956
+ const call = converted[0]?.content.find(
957
+ (part): part is any => part.type === "tool-call",
958
+ );
959
+ expect(call?.mcp?.app).toEqual({
960
+ resourceUri: "ui://app/hello_ui.html",
961
+ visibility: ["model"],
962
+ });
963
+ });
964
+
965
+ it("prefers output._meta.ui.resourceUri over output._meta['ui/resourceUri']", () => {
966
+ const converted = AISDKMessageConverter.toThreadMessages([
967
+ {
968
+ id: "a1",
969
+ role: "assistant",
970
+ parts: [
971
+ {
972
+ type: "tool-hello_ui",
973
+ toolCallId: "tc-1",
974
+ state: "output-available",
975
+ input: {},
976
+ output: {
977
+ _meta: {
978
+ ui: { resourceUri: "ui://app/nested.html" },
979
+ "ui/resourceUri": "ui://app/flat.html",
980
+ },
981
+ content: [{ type: "text", text: "" }],
982
+ },
983
+ },
984
+ ],
985
+ } as any,
986
+ ]);
987
+
988
+ const call = converted[0]?.content.find(
989
+ (part): part is any => part.type === "tool-call",
990
+ );
991
+ expect(call?.mcp?.app).toEqual({
992
+ resourceUri: "ui://app/nested.html",
993
+ });
994
+ });
995
+
996
+ it("falls back to output._meta['ui/resourceUri'] when the nested resourceUri is not a ui:// app uri", () => {
997
+ const converted = AISDKMessageConverter.toThreadMessages([
998
+ {
999
+ id: "a1",
1000
+ role: "assistant",
1001
+ parts: [
1002
+ {
1003
+ type: "tool-hello_ui",
1004
+ toolCallId: "tc-1",
1005
+ state: "output-available",
1006
+ input: {},
1007
+ output: {
1008
+ _meta: {
1009
+ ui: { resourceUri: "https://example.com/not-an-app" },
1010
+ "ui/resourceUri": "ui://app/flat.html",
1011
+ },
1012
+ content: [{ type: "text", text: "" }],
1013
+ },
1014
+ },
1015
+ ],
1016
+ } as any,
1017
+ ]);
1018
+
1019
+ const call = converted[0]?.content.find(
1020
+ (part): part is any => part.type === "tool-call",
1021
+ );
1022
+ expect(call?.mcp?.app).toEqual({
1023
+ resourceUri: "ui://app/flat.html",
1024
+ });
1025
+ });
1026
+
1027
+ it("memoizes MCP app metadata across conversions by serverId and resourceUri", () => {
1028
+ const metadata: AISDKMessageConverterMetadata = {
1029
+ mcpAppMetadataCache: new Map(),
1030
+ };
1031
+
1032
+ const buildMessage = (id: string, serverId: string) => ({
1033
+ id,
1034
+ role: "assistant" as const,
1035
+ parts: [
1036
+ {
1037
+ type: "tool-search",
1038
+ toolCallId: `${id}-call`,
1039
+ state: "output-available",
1040
+ input: { q: "hi" },
1041
+ output: {},
1042
+ callProviderMetadata: {
1043
+ mcp: {
1044
+ app: { resourceUri: "ui://example/search", serverId },
1045
+ },
1046
+ },
1047
+ } as any,
1048
+ ],
1049
+ });
1050
+
1051
+ const first = AISDKMessageConverter.toThreadMessages(
1052
+ [buildMessage("a1", "search-server")],
1053
+ false,
1054
+ metadata,
1055
+ );
1056
+ const second = AISDKMessageConverter.toThreadMessages(
1057
+ [buildMessage("a2", "search-server")],
1058
+ false,
1059
+ metadata,
1060
+ );
1061
+ const third = AISDKMessageConverter.toThreadMessages(
1062
+ [buildMessage("a3", "other-server")],
1063
+ false,
1064
+ metadata,
1065
+ );
1066
+
1067
+ const firstApp = first[0]?.content.find(
1068
+ (p): p is any => p.type === "tool-call",
1069
+ )?.mcp?.app;
1070
+ const secondApp = second[0]?.content.find(
1071
+ (p): p is any => p.type === "tool-call",
1072
+ )?.mcp?.app;
1073
+ const thirdApp = third[0]?.content.find(
1074
+ (p): p is any => p.type === "tool-call",
1075
+ )?.mcp?.app;
1076
+ expect(firstApp).toBeDefined();
1077
+ expect(firstApp).toBe(secondApp);
1078
+ expect(thirdApp).not.toBe(firstApp);
1079
+ expect(thirdApp).toEqual({
1080
+ resourceUri: "ui://example/search",
1081
+ serverId: "other-server",
1082
+ });
1083
+ });
1084
+
1085
+ it("converts a reasoning-file part into a file part", () => {
1086
+ const converted = AISDKMessageConverter.toThreadMessages([
1087
+ {
1088
+ id: "a1",
1089
+ role: "assistant",
1090
+ parts: [
1091
+ {
1092
+ type: "reasoning-file",
1093
+ mediaType: "image/png",
1094
+ url: "data:image/png;base64,abc",
1095
+ },
1096
+ ],
1097
+ } as any,
1098
+ ]);
1099
+
1100
+ expect(converted[0]?.content).toHaveLength(1);
1101
+ expect(converted[0]?.content[0]).toMatchObject({
1102
+ type: "file",
1103
+ data: "data:image/png;base64,abc",
1104
+ mimeType: "image/png",
1105
+ });
1106
+ });
1107
+
1108
+ it("converts a custom part into a data part named by its kind", () => {
1109
+ const converted = AISDKMessageConverter.toThreadMessages([
1110
+ {
1111
+ id: "a1",
1112
+ role: "assistant",
1113
+ parts: [
1114
+ {
1115
+ type: "custom",
1116
+ kind: "acme.widget",
1117
+ providerMetadata: { acme: { foo: "bar" } },
1118
+ },
1119
+ ],
1120
+ } as any,
1121
+ ]);
1122
+
1123
+ expect(converted[0]?.content).toHaveLength(1);
1124
+ expect(converted[0]?.content[0]).toMatchObject({
1125
+ type: "data",
1126
+ name: "acme.widget",
1127
+ data: { acme: { foo: "bar" } },
1128
+ });
1129
+ });
1130
+ });