@assistant-ui/ai-sdk 0.0.11 → 0.0.12

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 (119) hide show
  1. package/dist/converters/convertMessage.d.ts +8 -6
  2. package/dist/converters/convertMessage.d.ts.map +1 -1
  3. package/dist/converters/convertMessage.js +48 -22
  4. package/dist/converters/convertMessage.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/index.native.d.ts +1 -1
  9. package/dist/index.native.d.ts.map +1 -1
  10. package/dist/index.native.js +1 -1
  11. package/dist/model-context/injectInteractableContext.d.ts +3 -0
  12. package/dist/model-context/injectInteractableContext.d.ts.map +1 -1
  13. package/dist/model-context/injectInteractableContext.js +3 -0
  14. package/dist/model-context/injectInteractableContext.js.map +1 -1
  15. package/dist/model-context/injectQuoteContext.d.ts +1 -0
  16. package/dist/model-context/injectQuoteContext.d.ts.map +1 -1
  17. package/dist/model-context/injectQuoteContext.js +1 -0
  18. package/dist/model-context/injectQuoteContext.js.map +1 -1
  19. package/dist/runtime/AISDKChat.js +1 -1
  20. package/dist/runtime/AISDKThreads.d.ts +5 -0
  21. package/dist/runtime/AISDKThreads.d.ts.map +1 -1
  22. package/dist/runtime/AISDKThreads.js +37 -4
  23. package/dist/runtime/AISDKThreads.js.map +1 -1
  24. package/dist/runtime/DynamicChatTransport.d.ts +49 -0
  25. package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
  26. package/dist/runtime/DynamicChatTransport.js +147 -0
  27. package/dist/runtime/DynamicChatTransport.js.map +1 -0
  28. package/dist/runtime/getResumableAdapter.d.ts +5 -0
  29. package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
  30. package/dist/runtime/getResumableAdapter.js +12 -0
  31. package/dist/runtime/getResumableAdapter.js.map +1 -0
  32. package/dist/runtime/sdkIdentity.js +1 -1
  33. package/dist/runtime/toolHistoryCodec.d.ts +20 -0
  34. package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
  35. package/dist/runtime/toolHistoryCodec.js +107 -0
  36. package/dist/runtime/toolHistoryCodec.js.map +1 -0
  37. package/dist/runtime/useAISDKRuntime.d.ts +4 -1
  38. package/dist/runtime/useAISDKRuntime.d.ts.map +1 -1
  39. package/dist/runtime/useAISDKRuntime.js +154 -155
  40. package/dist/runtime/useAISDKRuntime.js.map +1 -1
  41. package/dist/runtime/useChatRuntime.d.ts +14 -2
  42. package/dist/runtime/useChatRuntime.d.ts.map +1 -1
  43. package/dist/runtime/useChatRuntime.js +12 -3
  44. package/dist/runtime/useChatRuntime.js.map +1 -1
  45. package/dist/runtime/useChatThread.d.ts +3 -2
  46. package/dist/runtime/useChatThread.d.ts.map +1 -1
  47. package/dist/runtime/useChatThread.js +56 -36
  48. package/dist/runtime/useChatThread.js.map +1 -1
  49. package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
  50. package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
  51. package/dist/runtime/useDynamicChatTransport.js +64 -0
  52. package/dist/runtime/useDynamicChatTransport.js.map +1 -0
  53. package/dist/runtime/useExternalHistory.d.ts.map +1 -1
  54. package/dist/runtime/useExternalHistory.js +11 -109
  55. package/dist/runtime/useExternalHistory.js.map +1 -1
  56. package/dist/runtime/useResourceCleanup.js +1 -1
  57. package/dist/runtime/useStreamingTiming.js +2 -2
  58. package/dist/runtime/useStreamingTiming.js.map +1 -1
  59. package/dist/tools/generativeTools.d.ts +2 -1
  60. package/dist/tools/generativeTools.d.ts.map +1 -1
  61. package/dist/tools/generativeTools.js +5 -5
  62. package/dist/tools/generativeTools.js.map +1 -1
  63. package/dist/usage.js +1 -1
  64. package/dist/utils/sliceMessagesUntil.d.ts.map +1 -1
  65. package/dist/utils/sliceMessagesUntil.js +1 -2
  66. package/dist/utils/sliceMessagesUntil.js.map +1 -1
  67. package/package.json +11 -10
  68. package/src/converters/convertMessage.test.ts +120 -2
  69. package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
  70. package/src/converters/convertMessage.ts +60 -19
  71. package/src/converters/toCreateMessage.test.ts +27 -0
  72. package/src/index.native.ts +1 -1
  73. package/src/index.ts +1 -1
  74. package/src/model-context/injectInteractableContext.ts +3 -0
  75. package/src/model-context/injectQuoteContext.ts +1 -0
  76. package/src/runtime/AISDKChat.integration.test.tsx +57 -2
  77. package/src/runtime/AISDKThreads.cloud.test.ts +3 -0
  78. package/src/runtime/AISDKThreads.test.ts +181 -0
  79. package/src/runtime/AISDKThreads.ts +31 -4
  80. package/src/runtime/DynamicChatTransport.test.ts +203 -0
  81. package/src/runtime/DynamicChatTransport.ts +273 -0
  82. package/src/runtime/__tests__/controlled-transport.ts +3 -0
  83. package/src/runtime/getResumableAdapter.ts +16 -0
  84. package/src/runtime/toolHistoryCodec.test.ts +161 -0
  85. package/src/runtime/toolHistoryCodec.ts +207 -0
  86. package/src/runtime/useAISDKRuntime.approval.test.tsx +12 -0
  87. package/src/runtime/useAISDKRuntime.reload.test.tsx +219 -0
  88. package/src/runtime/useAISDKRuntime.test.ts +538 -5
  89. package/src/runtime/useAISDKRuntime.ts +161 -51
  90. package/src/runtime/useChatRuntime.integration.test.tsx +319 -4
  91. package/src/runtime/useChatRuntime.local-storage.test.tsx +123 -0
  92. package/src/runtime/useChatRuntime.test.ts +107 -1
  93. package/src/runtime/useChatRuntime.ts +26 -4
  94. package/src/runtime/useChatThread.binding.test.tsx +143 -0
  95. package/src/runtime/useChatThread.ts +98 -81
  96. package/src/runtime/useDynamicChatTransport.ts +26 -0
  97. package/src/runtime/useExternalHistory.test.ts +111 -0
  98. package/src/runtime/useExternalHistory.ts +14 -210
  99. package/src/runtime/useStreamingTiming.ts +2 -2
  100. package/src/tools/generativeTools.test.ts +1 -1
  101. package/src/tools/generativeTools.ts +5 -6
  102. package/src/utils/sliceMessagesUntil.test.ts +2 -6
  103. package/src/utils/sliceMessagesUntil.ts +1 -5
  104. package/dist/converters/modelContentEnvelope.d.ts +0 -14
  105. package/dist/converters/modelContentEnvelope.d.ts.map +0 -1
  106. package/dist/converters/modelContentEnvelope.js +0 -22
  107. package/dist/converters/modelContentEnvelope.js.map +0 -1
  108. package/dist/converters/toolOutputConversion.d.ts +0 -26
  109. package/dist/converters/toolOutputConversion.d.ts.map +0 -1
  110. package/dist/converters/toolOutputConversion.js +0 -31
  111. package/dist/converters/toolOutputConversion.js.map +0 -1
  112. package/dist/tools/frontendTools.d.ts +0 -30
  113. package/dist/tools/frontendTools.d.ts.map +0 -1
  114. package/dist/tools/frontendTools.js +0 -33
  115. package/dist/tools/frontendTools.js.map +0 -1
  116. package/src/converters/modelContentEnvelope.ts +0 -41
  117. package/src/converters/toolOutputConversion.ts +0 -26
  118. package/src/tools/frontendTools.test.ts +0 -205
  119. package/src/tools/frontendTools.ts +0 -83
@@ -1390,7 +1390,9 @@ describe("AISDKMessageConverter", () => {
1390
1390
  const call = terminal[0]?.content.find(
1391
1391
  (part): part is any => part.type === "tool-call",
1392
1392
  );
1393
- expect(call?.args).toEqual({ city: "NYC" });
1393
+ expect(Object.fromEntries(Object.entries(call?.args ?? {}))).toEqual({
1394
+ city: "NYC",
1395
+ });
1394
1396
  expect(call?.result).toEqual({ temp: 70 });
1395
1397
  });
1396
1398
 
@@ -2094,7 +2096,10 @@ describe("AISDKMessageConverter", () => {
2094
2096
  const toolCall = converted[0]?.content.find(
2095
2097
  (part): part is any => part.type === "tool-call",
2096
2098
  );
2097
- expect(toolCall?.args).toEqual({ city: "NYC", units: "F" });
2099
+ expect(Object.fromEntries(Object.entries(toolCall?.args ?? {}))).toEqual({
2100
+ city: "NYC",
2101
+ units: "F",
2102
+ });
2098
2103
  expect(toolCall?.argsText).toBe('{"city":"NYC","units":"F"}');
2099
2104
  });
2100
2105
 
@@ -2168,6 +2173,7 @@ describe("AISDKMessageConverter", () => {
2168
2173
 
2169
2174
  expect(a.argsText).toBe('{"a":1,"b":2}');
2170
2175
  expect(b.argsText).toBe('{"b":2,"a":1}');
2176
+ expect(Object.keys(b.args)).toEqual(["a", "b"]);
2171
2177
 
2172
2178
  // Both entries must survive a reconversion: the key-order entries are gone
2173
2179
  // by now, so a cache miss would re-serialize B in raw key order.
@@ -2207,4 +2213,116 @@ describe("AISDKMessageConverter", () => {
2207
2213
  }
2208
2214
  expect(stableStringifySpy).toHaveBeenCalledTimes(1);
2209
2215
  });
2216
+
2217
+ it("skips null parts and parts without a type", () => {
2218
+ const converted = AISDKMessageConverter.toThreadMessages([
2219
+ {
2220
+ id: "u1",
2221
+ role: "user",
2222
+ parts: [null, { text: "no type" }, { type: "text", text: "hi" }],
2223
+ },
2224
+ {
2225
+ id: "a1",
2226
+ role: "assistant",
2227
+ parts: [null, { text: "no type" }, { type: "text", text: "yo" }],
2228
+ },
2229
+ ] as any);
2230
+
2231
+ expect(converted[0]?.content).toMatchObject([{ type: "text", text: "hi" }]);
2232
+ expect(converted[0]?.attachments).toEqual([]);
2233
+ expect(converted[1]?.content).toMatchObject([{ type: "text", text: "yo" }]);
2234
+ });
2235
+
2236
+ it("joins the text parts of a system message into one text part", () => {
2237
+ const converted = AISDKMessageConverter.toThreadMessages([
2238
+ {
2239
+ id: "s1",
2240
+ role: "system",
2241
+ parts: [
2242
+ {
2243
+ type: "text",
2244
+ text: "be ",
2245
+ providerMetadata: { p1: { k: "a" }, p2: { k: "b" } },
2246
+ },
2247
+ { type: "text", text: "brief", providerMetadata: { p2: { k: "c" } } },
2248
+ ],
2249
+ },
2250
+ { id: "s2", role: "system", parts: [] },
2251
+ ] as any);
2252
+
2253
+ expect(converted[0]?.content).toMatchObject([
2254
+ {
2255
+ type: "text",
2256
+ text: "be brief",
2257
+ providerMetadata: { p1: { k: "a" }, p2: { k: "c" } },
2258
+ },
2259
+ ]);
2260
+ expect(converted[1]?.content).toMatchObject([{ type: "text", text: "" }]);
2261
+ expect(converted[1]?.content[0]).not.toHaveProperty("providerMetadata");
2262
+ });
2263
+
2264
+ it("reads a user text part without text as empty text", () => {
2265
+ const converted = AISDKMessageConverter.toThreadMessages([
2266
+ { id: "u1", role: "user", parts: [{ type: "text" }] },
2267
+ ] as any);
2268
+
2269
+ expect(converted[0]?.content).toMatchObject([{ type: "text", text: "" }]);
2270
+ });
2271
+
2272
+ it("skips a file part without a url and floors a missing mediaType", () => {
2273
+ const converted = AISDKMessageConverter.toThreadMessages([
2274
+ {
2275
+ id: "u1",
2276
+ role: "user",
2277
+ parts: [{ type: "file", mediaType: "image/png", filename: "a.png" }],
2278
+ },
2279
+ {
2280
+ id: "a1",
2281
+ role: "assistant",
2282
+ parts: [
2283
+ { type: "file", mediaType: "image/png" },
2284
+ { type: "file", url: "https://cdn/file.bin" },
2285
+ { type: "reasoning-file", mediaType: "image/png" },
2286
+ { type: "reasoning-file", url: "https://cdn/thought.bin" },
2287
+ ],
2288
+ },
2289
+ ] as any);
2290
+
2291
+ expect(converted[0]?.attachments).toEqual([]);
2292
+ expect(converted[1]?.content).toMatchObject([
2293
+ {
2294
+ type: "file",
2295
+ data: "https://cdn/file.bin",
2296
+ mimeType: "unknown/unknown",
2297
+ },
2298
+ {
2299
+ type: "file",
2300
+ data: "https://cdn/thought.bin",
2301
+ mimeType: "unknown/unknown",
2302
+ },
2303
+ ]);
2304
+ });
2305
+
2306
+ it("gives a dynamic tool call without a toolName an empty name", () => {
2307
+ const converted = AISDKMessageConverter.toThreadMessages([
2308
+ {
2309
+ id: "a1",
2310
+ role: "assistant",
2311
+ parts: [
2312
+ {
2313
+ type: "dynamic-tool",
2314
+ toolCallId: "tc-1",
2315
+ state: "input-available",
2316
+ input: {},
2317
+ },
2318
+ ],
2319
+ },
2320
+ ] as any);
2321
+
2322
+ expect(converted[0]?.content[0]).toMatchObject({
2323
+ type: "tool-call",
2324
+ toolCallId: "tc-1",
2325
+ toolName: "",
2326
+ });
2327
+ });
2210
2328
  });
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getPartialJsonObjectFieldState,
4
+ getPartialJsonObjectMeta,
5
+ } from "assistant-stream/utils";
6
+ import {
7
+ AISDKMessageConverter,
8
+ type AISDKMessageConverterMetadata,
9
+ } from "./convertMessage";
10
+
11
+ describe("AI SDK tool argument completion", () => {
12
+ it.each([{}, { city: "Paris", unit: "c" }])(
13
+ "marks input-available arguments complete before tool execution ends for %j",
14
+ (input) => {
15
+ const convert = (toolState: "input-streaming" | "input-available") => {
16
+ const message = AISDKMessageConverter.toThreadMessages(
17
+ [
18
+ {
19
+ id: "a1",
20
+ role: "assistant",
21
+ parts: [
22
+ {
23
+ type: "tool-weather",
24
+ toolCallId: "tc-1",
25
+ state: toolState,
26
+ input,
27
+ },
28
+ ],
29
+ },
30
+ ],
31
+ true,
32
+ )[0]!;
33
+ return message.content.find((part) => part.type === "tool-call")!;
34
+ };
35
+
36
+ const streaming = convert("input-streaming");
37
+ expect(getPartialJsonObjectMeta(streaming.args)?.state).toBe("partial");
38
+
39
+ const available = convert("input-available");
40
+ expect(Object.fromEntries(Object.entries(available.args))).toEqual(input);
41
+ expect(JSON.parse(available.argsText)).toEqual(input);
42
+ expect(getPartialJsonObjectMeta(available.args)?.state).toBe("complete");
43
+ for (const key of Object.keys(input)) {
44
+ expect(getPartialJsonObjectFieldState(available.args, [key])).toBe(
45
+ "complete",
46
+ );
47
+ }
48
+ },
49
+ );
50
+
51
+ it("keeps the streaming frontier partial until input becomes available", () => {
52
+ const message = AISDKMessageConverter.toThreadMessages(
53
+ [
54
+ {
55
+ id: "a1",
56
+ role: "assistant",
57
+ parts: [
58
+ {
59
+ type: "tool-weather",
60
+ toolCallId: "tc-1",
61
+ state: "input-streaming",
62
+ input: { city: "Paris", unit: "c" },
63
+ },
64
+ ],
65
+ },
66
+ ],
67
+ true,
68
+ )[0]!;
69
+ const part = message.content.find((item) => item.type === "tool-call")!;
70
+ expect(getPartialJsonObjectMeta(part.args)?.state).toBe("partial");
71
+ expect(getPartialJsonObjectFieldState(part.args, ["city"])).toBe(
72
+ "complete",
73
+ );
74
+ expect(getPartialJsonObjectFieldState(part.args, ["unit"])).toBe("partial");
75
+ });
76
+
77
+ it.each([
78
+ {
79
+ state: "approval-responded",
80
+ approval: { id: "approval-1", approved: true },
81
+ },
82
+ {
83
+ state: "output-available",
84
+ output: { progress: 50 },
85
+ preliminary: true,
86
+ },
87
+ ])("marks settled $state input complete while the part runs", (tool) => {
88
+ const message = AISDKMessageConverter.toThreadMessages(
89
+ [
90
+ {
91
+ id: "a1",
92
+ role: "assistant",
93
+ parts: [
94
+ {
95
+ type: "tool-weather",
96
+ toolCallId: "tc-1",
97
+ input: { city: "Paris" },
98
+ ...tool,
99
+ },
100
+ ],
101
+ },
102
+ ] as any,
103
+ true,
104
+ )[0]!;
105
+ const part = message.content.find((item) => item.type === "tool-call")!;
106
+ expect(message.status?.type).toBe("running");
107
+ expect(getPartialJsonObjectMeta(part.args)?.state).toBe("complete");
108
+ expect(getPartialJsonObjectFieldState(part.args, ["city"])).toBe(
109
+ "complete",
110
+ );
111
+ });
112
+
113
+ it("reuses marked arguments when the settled input object is unchanged", () => {
114
+ const input = { weather: { city: "Paris" } };
115
+ const metadata: AISDKMessageConverterMetadata = {
116
+ toolArgsTextCache: new WeakMap(),
117
+ };
118
+ const convert = () =>
119
+ AISDKMessageConverter.toThreadMessages(
120
+ [
121
+ {
122
+ id: "a1",
123
+ role: "assistant",
124
+ parts: [
125
+ {
126
+ type: "tool-weather",
127
+ toolCallId: "tc-1",
128
+ state: "input-available",
129
+ input,
130
+ },
131
+ ],
132
+ },
133
+ ],
134
+ true,
135
+ metadata,
136
+ )[0]!.content.find((part) => part.type === "tool-call")!;
137
+
138
+ const first = convert();
139
+ const second = convert();
140
+ expect(first.args).toBe(second.args);
141
+ expect(first.args).not.toBe(input);
142
+ expect(first.args.weather).toBe(input.weather);
143
+ expect(first.argsText).toBe(second.argsText);
144
+ expect(getPartialJsonObjectMeta(second.args)?.state).toBe("complete");
145
+ });
146
+
147
+ it.each([
148
+ '{"__proto__":{"polluted":true}}',
149
+ '{"constructor":{"prototype":{"polluted":true}}}',
150
+ ])(
151
+ "marks settled prototype-named input complete while the tool runs: %s",
152
+ (json) => {
153
+ const input = JSON.parse(json);
154
+ const message = AISDKMessageConverter.toThreadMessages(
155
+ [
156
+ {
157
+ id: "a1",
158
+ role: "assistant",
159
+ parts: [
160
+ {
161
+ type: "tool-weather",
162
+ toolCallId: "tc-1",
163
+ state: "input-available",
164
+ input,
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ true,
170
+ )[0]!;
171
+ const part = message.content.find((item) => item.type === "tool-call")!;
172
+
173
+ expect(message.status?.type).toBe("running");
174
+ expect(part.args).not.toBe(input);
175
+ expect(Object.entries(part.args)).toEqual(Object.entries(input));
176
+ expect(Object.getPrototypeOf(part.args)).toBe(Object.prototype);
177
+ expect(Object.prototype).not.toHaveProperty("polluted");
178
+ expect(JSON.parse(part.argsText)).toEqual(input);
179
+ expect(getPartialJsonObjectMeta(part.args)?.state).toBe("complete");
180
+ expect(
181
+ getPartialJsonObjectFieldState(part.args, [Object.keys(input)[0]!]),
182
+ ).toBe("complete");
183
+ },
184
+ );
185
+ });
@@ -3,6 +3,7 @@ import {
3
3
  isReasoningFileUIPart,
4
4
  isCustomContentUIPart,
5
5
  getToolName,
6
+ type FileUIPart,
6
7
  type UIMessage,
7
8
  } from "ai";
8
9
  import {
@@ -30,11 +31,12 @@ import {
30
31
  } from "@assistant-ui/core";
31
32
  import { normalizeToolApprovalAnswers } from "./toolApprovalAnswers";
32
33
  import { stableStringifyToolArgs } from "@assistant-ui/core/internal";
34
+ import { markPartialJsonObjectComplete } from "assistant-stream/internal";
33
35
  import {
34
36
  parsePartialJsonObject,
35
37
  type ReadonlyJSONObject,
36
38
  } from "assistant-stream/utils";
37
- import { unwrapModelContentEnvelope } from "./modelContentEnvelope";
39
+ import { unwrapModelContentEnvelope } from "assistant-stream/internal";
38
40
 
39
41
  type MessageMetadata = ThreadMessageLike["metadata"];
40
42
 
@@ -66,13 +68,15 @@ export type AISDKMessageConverterMetadata =
66
68
  useExternalMessageConverter.Metadata & {
67
69
  toolArgsKeyOrderCache?: Map<string, Map<string, string[]>>;
68
70
  /**
69
- * Frozen `argsText` keyed weakly by a settled tool call's input object, then
70
- * by call, since the text carries the call's streamed key order. A known
71
- * call/input pair skips serialization; the entries become collectible once
72
- * the input is unreachable. A fresh input object re-serializes in its own
73
- * deterministic key order.
71
+ * Frozen text and completion-marked args keyed weakly by a settled tool
72
+ * call's input object, then by call, since the text carries its streamed key
73
+ * order. A known call/input pair skips serialization and parsing; entries
74
+ * become collectible once the input is unreachable.
74
75
  */
75
- toolArgsTextCache?: WeakMap<ReadonlyJSONObject, Map<string, string>>;
76
+ toolArgsTextCache?: WeakMap<
77
+ ReadonlyJSONObject,
78
+ Map<string, { argsText: string; args: ReadonlyJSONObject }>
79
+ >;
76
80
  toolLastInputCache?: Map<string, ReadonlyJSONObject>;
77
81
  mcpAppMetadataCache?: Map<string, McpAppMetadata>;
78
82
  toolArtifacts?: ReadonlyMap<string, unknown>;
@@ -457,6 +461,25 @@ const uiPartStateToStatus = (
457
461
  return undefined;
458
462
  };
459
463
 
464
+ const toSystemContent = (content: MessageContent): MessageContent => {
465
+ const text = content.filter((part) => part.type === "text");
466
+ if (text.length === 1) return text;
467
+ const providerMetadata = text.reduce<PartProviderMetadata>(
468
+ (merged, part) =>
469
+ part.providerMetadata != null
470
+ ? { ...merged, ...part.providerMetadata }
471
+ : merged,
472
+ {},
473
+ );
474
+ return [
475
+ {
476
+ type: "text",
477
+ text: text.map((part) => part.text).join(""),
478
+ ...(Object.keys(providerMetadata).length > 0 && { providerMetadata }),
479
+ },
480
+ ];
481
+ };
482
+
460
483
  function convertParts(
461
484
  message: UIMessage,
462
485
  metadata: AISDKMessageConverterMetadata,
@@ -468,6 +491,7 @@ function convertParts(
468
491
  const converted = message.parts
469
492
  .filter(
470
493
  (p) =>
494
+ typeof p?.type === "string" &&
471
495
  p.type !== "step-start" &&
472
496
  (message.role !== "user" || p.type !== "file"),
473
497
  )
@@ -476,7 +500,7 @@ function convertParts(
476
500
  const status = uiPartStateToStatus(part.state);
477
501
  return {
478
502
  type: "text",
479
- text: part.text,
503
+ text: part.text ?? "",
480
504
  ...(status != null ? { status } : undefined),
481
505
  ...(part.providerMetadata != null
482
506
  ? {
@@ -501,7 +525,7 @@ function convertParts(
501
525
  }
502
526
 
503
527
  if (isToolUIPart(part)) {
504
- const toolName = getToolName(part);
528
+ const toolName = getToolName(part) ?? "";
505
529
  const toolCallId = part.toolCallId;
506
530
  const argsKeyOrderCacheKey = `${message.id}:${toolCallId}`;
507
531
 
@@ -565,20 +589,31 @@ function convertParts(
565
589
  // re-serializing large args while the call keeps that input. Arrival
566
590
  // order only matters while args stream, so the key-order entry is
567
591
  // released.
592
+ const inputArgs = args;
568
593
  const frozen =
569
- metadata.toolArgsTextCache?.get(args) ?? new Map<string, string>();
570
- const frozenText = frozen.get(argsKeyOrderCacheKey);
571
- if (frozenText !== undefined) {
572
- argsText = frozenText;
594
+ metadata.toolArgsTextCache?.get(inputArgs) ??
595
+ new Map<string, { argsText: string; args: ReadonlyJSONObject }>();
596
+ const frozenEntry = frozen.get(argsKeyOrderCacheKey);
597
+ if (frozenEntry !== undefined) {
598
+ argsText = frozenEntry.argsText;
599
+ args = frozenEntry.args;
573
600
  } else {
574
601
  argsText = stableStringifyToolArgs(
575
602
  metadata.toolArgsKeyOrderCache,
576
603
  argsKeyOrderCacheKey,
577
604
  args,
578
605
  );
606
+ // The input is final even while execution keeps the part running.
607
+ // Other runtimes can synthesize complete JSON text from an
608
+ // accumulating snapshot, so only this converter supplies the
609
+ // completion signal it knows from the AI SDK part state.
610
+ // A complete root marker settles every field without parsing the
611
+ // serialized text again. Keep the SDK input's nested identities
612
+ // and own fields, including prototype-named JSON keys.
613
+ args = markPartialJsonObjectComplete(args);
579
614
  metadata.toolArgsTextCache?.set(
580
- args,
581
- frozen.set(argsKeyOrderCacheKey, argsText),
615
+ inputArgs,
616
+ frozen.set(argsKeyOrderCacheKey, { argsText, args }),
582
617
  );
583
618
  }
584
619
  metadata.toolArgsKeyOrderCache?.delete(argsKeyOrderCacheKey);
@@ -647,10 +682,11 @@ function convertParts(
647
682
  }
648
683
 
649
684
  if (part.type === "file") {
685
+ if (typeof part.url !== "string") return null;
650
686
  return {
651
687
  type: "file",
652
688
  data: part.url,
653
- mimeType: part.mediaType,
689
+ mimeType: part.mediaType ?? "unknown/unknown",
654
690
  ...(part.filename != null && { filename: part.filename }),
655
691
  } satisfies FileMessagePart;
656
692
  }
@@ -684,10 +720,11 @@ function convertParts(
684
720
  }
685
721
 
686
722
  if (isReasoningFileUIPart(part)) {
723
+ if (typeof part.url !== "string") return null;
687
724
  return {
688
725
  type: "file",
689
726
  data: part.url,
690
- mimeType: part.mediaType,
727
+ mimeType: part.mediaType ?? "unknown/unknown",
691
728
  } satisfies FileMessagePart;
692
729
  }
693
730
 
@@ -727,7 +764,10 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
727
764
  createdAt,
728
765
  content,
729
766
  attachments: message.parts
730
- ?.filter((p) => p.type === "file")
767
+ ?.filter(
768
+ (p): p is FileUIPart =>
769
+ p?.type === "file" && typeof p.url === "string",
770
+ )
731
771
  .map((part, idx) => {
732
772
  const mediaType = part.mediaType ?? "unknown/unknown";
733
773
  const isImage = mediaType.startsWith("image/");
@@ -766,7 +806,8 @@ export const AISDKMessageConverter = unstable_createMessageConverter(
766
806
  role: message.role,
767
807
  id: message.id,
768
808
  createdAt,
769
- content,
809
+ content:
810
+ message.role === "system" ? toSystemContent(content) : content,
770
811
  ...(message.role === "assistant" &&
771
812
  metadata.cancelledStatusMessageIds?.has(message.id)
772
813
  ? {
@@ -11,6 +11,33 @@ const baseMessage = {
11
11
  } as const;
12
12
 
13
13
  describe("toCreateMessage", () => {
14
+ it.each([
15
+ [
16
+ "data:;base64,SGVsbG8=",
17
+ "data:application/octet-stream;base64,SGVsbG8=",
18
+ "application/octet-stream",
19
+ ],
20
+ [
21
+ "data:;charset=utf-8,hello",
22
+ "data:text/plain;charset=utf-8,hello",
23
+ "text/plain",
24
+ ],
25
+ [
26
+ "data:;base64,",
27
+ "data:application/octet-stream;base64,",
28
+ "application/octet-stream",
29
+ ],
30
+ ])("uses the resolved media type for %s", (data, url, mediaType) => {
31
+ const message = {
32
+ ...baseMessage,
33
+ content: [{ type: "file", data, mimeType: "" }],
34
+ } as unknown as AppendMessage;
35
+
36
+ expect(toCreateMessage(message).parts).toEqual([
37
+ { type: "file", url, mediaType },
38
+ ]);
39
+ });
40
+
14
41
  it("converts a direct file part in message content", () => {
15
42
  const message = {
16
43
  ...baseMessage,
@@ -12,7 +12,7 @@ export type {
12
12
  AssistantChatResumableOptions,
13
13
  ResumableClientStorage,
14
14
  } from "./transport/resumable";
15
- export { frontendTools, type FrontendTools } from "./tools/frontendTools";
15
+ export { frontendTools, type FrontendTools } from "assistant-stream/ai-sdk";
16
16
  export { injectQuoteContext } from "./model-context/injectQuoteContext";
17
17
  export { unstable_injectInteractableContext } from "./model-context/injectInteractableContext";
18
18
  export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export type {
12
12
  AssistantChatResumableOptions,
13
13
  ResumableClientStorage,
14
14
  } from "./transport/resumable";
15
- export { frontendTools, type FrontendTools } from "./tools/frontendTools";
15
+ export { frontendTools, type FrontendTools } from "assistant-stream/ai-sdk";
16
16
  export { injectQuoteContext } from "./model-context/injectQuoteContext";
17
17
  export { unstable_injectInteractableContext } from "./model-context/injectInteractableContext";
18
18
  export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
@@ -20,6 +20,8 @@ import {
20
20
  * `format` must also handle entries with `partial: true`, whose `state` carries
21
21
  * only the fields that changed since the model's last known state.
22
22
  *
23
+ * @deprecated Experimental since 2026-06-23. Not scheduled for removal; the API may change in any release.
24
+ *
23
25
  * @example
24
26
  * ```ts
25
27
  * import { convertToModelMessages, streamText } from "ai";
@@ -28,6 +30,7 @@ import {
28
30
  * export async function POST(req: Request) {
29
31
  * const { messages } = await req.json();
30
32
  * const result = streamText({
33
+ * abortSignal: req.signal,
31
34
  * model: myModel,
32
35
  * messages: await convertToModelMessages(unstable_injectInteractableContext(messages)),
33
36
  * });
@@ -27,6 +27,7 @@ const getQuoteText = (metadata: unknown): string | undefined => {
27
27
  * export async function POST(req: Request) {
28
28
  * const { messages } = await req.json();
29
29
  * const result = streamText({
30
+ * abortSignal: req.signal,
30
31
  * model: myModel,
31
32
  * messages: await convertToModelMessages(injectQuoteContext(messages)),
32
33
  * });
@@ -1,10 +1,11 @@
1
+ import { flushTapSync } from "@assistant-ui/tap";
1
2
  // @vitest-environment jsdom
2
3
 
3
- import { StrictMode, type ReactNode } from "react";
4
+ import { StrictMode, useLayoutEffect, type ReactNode } from "react";
4
5
  import { act, render, waitFor } from "@testing-library/react";
5
6
  import { AuiConfig, AuiProvider, useAui } from "@assistant-ui/store";
6
7
  import type { ChatTransport, UIMessage } from "ai";
7
- import { describe, expect, it } from "vitest";
8
+ import { describe, expect, it, vi } from "vitest";
8
9
  import { AISDKChat } from "./AISDKChat";
9
10
  import {
10
11
  createCancellableTransport,
@@ -66,3 +67,57 @@ describe("AISDKChat legacy useAui host integration", () => {
66
67
  await waitFor(() => expect(getCancelCount()).toBe(1));
67
68
  });
68
69
  });
70
+
71
+ describe("replacement transports", () => {
72
+ it("routes sends through a replacement transport", async () => {
73
+ const emptyStream = () =>
74
+ new ReadableStream({ start: (controller) => controller.close() });
75
+ const sendA = vi.fn(async () => emptyStream());
76
+ const sendB = vi.fn(async () => emptyStream());
77
+ const transportA: ChatTransport<UIMessage> = {
78
+ sendMessages: sendA,
79
+ reconnectToStream: vi.fn(),
80
+ };
81
+ const transportB: ChatTransport<UIMessage> = {
82
+ sendMessages: sendB,
83
+ reconnectToStream: vi.fn(),
84
+ };
85
+ let initialClient: ReturnType<typeof useAui> | undefined;
86
+ let currentClient: ReturnType<typeof useAui> | undefined;
87
+ const CaptureClient = () => {
88
+ const aui = useAui();
89
+ initialClient ??= aui;
90
+ currentClient = aui;
91
+ return null;
92
+ };
93
+ const SendOnLayout = () => {
94
+ const aui = useAui();
95
+ useLayoutEffect(() => {
96
+ flushTapSync(() => {
97
+ aui.composer.setText("hello");
98
+ aui.composer.send();
99
+ });
100
+ }, [aui]);
101
+ return null;
102
+ };
103
+ const App = ({
104
+ transport,
105
+ send = false,
106
+ }: {
107
+ transport: ChatTransport<UIMessage>;
108
+ send?: boolean;
109
+ }) => (
110
+ <AuiProvider config={AuiConfig({ threads: AISDKChat({ transport }) })}>
111
+ <CaptureClient />
112
+ {send && <SendOnLayout />}
113
+ </AuiProvider>
114
+ );
115
+
116
+ const view = render(<App transport={transportA} />);
117
+ view.rerender(<App transport={transportB} send />);
118
+
119
+ await waitFor(() => expect(sendB).toHaveBeenCalledOnce());
120
+ expect(sendA).not.toHaveBeenCalled();
121
+ expect(currentClient).toBe(initialClient);
122
+ });
123
+ });
@@ -79,10 +79,12 @@ import { AI_SDK_SDK } from "./sdkIdentity";
79
79
  describe("AISDKThreads cloud", () => {
80
80
  it("reloads history when switching a keyed cloud thread", async () => {
81
81
  const cloud = {} as AssistantCloud;
82
+ const scopeId = "workspace-1";
82
83
  const handle = createAssistantClient(
83
84
  AuiConfig({
84
85
  threads: AISDKThreads({
85
86
  cloud,
87
+ scopeId,
86
88
  threadId: "t1",
87
89
  }),
88
90
  }),
@@ -98,6 +100,7 @@ describe("AISDKThreads cloud", () => {
98
100
  });
99
101
  expect(mocks.useCloudThreadListAdapter).toHaveBeenCalledWith({
100
102
  cloud,
103
+ scopeId,
101
104
  sdk: AI_SDK_SDK,
102
105
  });
103
106
  const afterFirst = load.mock.calls.length;