@assistant-ui/react-ai-sdk 1.3.30 → 1.3.32

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 (97) hide show
  1. package/dist/assistant-stream/dist/core/tool/schema-utils.d.ts +15 -0
  2. package/dist/assistant-stream/dist/core/tool/schema-utils.d.ts.map +1 -0
  3. package/dist/{packages/assistant-stream → assistant-stream}/dist/core/tool/schema-utils.js +13 -4
  4. package/dist/assistant-stream/dist/core/tool/schema-utils.js.map +1 -0
  5. package/dist/{packages/assistant-stream → assistant-stream}/dist/core/tool/tool-types.d.ts +27 -1
  6. package/dist/assistant-stream/dist/core/tool/tool-types.d.ts.map +1 -0
  7. package/dist/assistant-stream/dist/index.d.ts +2 -0
  8. package/dist/{node_modules → assistant-stream/dist/node_modules}/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +4 -5
  9. package/dist/assistant-stream/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -0
  10. package/dist/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +1 -0
  11. package/dist/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.js.map +1 -0
  12. package/dist/assistant-stream/dist/utils/json/json-value.d.ts.map +1 -0
  13. package/dist/frontendTools.d.ts +33 -6
  14. package/dist/frontendTools.d.ts.map +1 -1
  15. package/dist/frontendTools.js +6 -29
  16. package/dist/frontendTools.js.map +1 -1
  17. package/dist/generativeTools.d.ts +60 -0
  18. package/dist/generativeTools.d.ts.map +1 -0
  19. package/dist/generativeTools.js +153 -0
  20. package/dist/generativeTools.js.map +1 -0
  21. package/dist/index.d.ts +3 -2
  22. package/dist/index.js +3 -2
  23. package/dist/modelContentEnvelope.d.ts +4 -4
  24. package/dist/modelContentEnvelope.d.ts.map +1 -1
  25. package/dist/modelContentEnvelope.js.map +1 -1
  26. package/dist/toolOutputConversion.d.ts +34 -0
  27. package/dist/toolOutputConversion.d.ts.map +1 -0
  28. package/dist/toolOutputConversion.js +31 -0
  29. package/dist/toolOutputConversion.js.map +1 -0
  30. package/dist/ui/resumable.d.ts +1 -1
  31. package/dist/ui/resumable.js +1 -1
  32. package/dist/ui/use-chat/AssistantChatTransport.d.ts.map +1 -1
  33. package/dist/ui/use-chat/AssistantChatTransport.js +2 -2
  34. package/dist/ui/use-chat/useAISDKRuntime.d.ts +3 -16
  35. package/dist/ui/use-chat/useAISDKRuntime.d.ts.map +1 -1
  36. package/dist/ui/use-chat/useAISDKRuntime.js +10 -4
  37. package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
  38. package/dist/ui/use-chat/useChatRuntime.d.ts +2 -3
  39. package/dist/ui/use-chat/useChatRuntime.d.ts.map +1 -1
  40. package/dist/ui/use-chat/useChatRuntime.js +4 -3
  41. package/dist/ui/use-chat/useChatRuntime.js.map +1 -1
  42. package/dist/ui/use-chat/useExternalHistory.d.ts.map +1 -1
  43. package/dist/ui/use-chat/useExternalHistory.js +16 -8
  44. package/dist/ui/use-chat/useExternalHistory.js.map +1 -1
  45. package/dist/ui/utils/convertMessage.d.ts +3 -2
  46. package/dist/ui/utils/convertMessage.d.ts.map +1 -1
  47. package/dist/ui/utils/convertMessage.js +3 -1
  48. package/dist/ui/utils/convertMessage.js.map +1 -1
  49. package/dist/ui/utils/toCreateMessage.js +4 -0
  50. package/dist/ui/utils/toCreateMessage.js.map +1 -1
  51. package/dist/usage.d.ts.map +1 -1
  52. package/package.json +8 -7
  53. package/src/frontendTools.test.ts +24 -0
  54. package/src/frontendTools.ts +7 -32
  55. package/src/generativeTools.test.ts +407 -0
  56. package/src/generativeTools.ts +310 -0
  57. package/src/index.ts +7 -0
  58. package/src/modelContentEnvelope.ts +7 -5
  59. package/src/toolOutputConversion.ts +29 -0
  60. package/src/ui/use-chat/useAISDKRuntime.test.ts +85 -0
  61. package/src/ui/use-chat/useAISDKRuntime.ts +21 -17
  62. package/src/ui/use-chat/useChatRuntime.ts +22 -21
  63. package/src/ui/use-chat/useExternalHistory.test.ts +60 -1
  64. package/src/ui/use-chat/useExternalHistory.ts +17 -8
  65. package/src/ui/utils/convertMessage.test.ts +25 -0
  66. package/src/ui/utils/convertMessage.ts +6 -0
  67. package/src/ui/utils/toCreateMessage.test.ts +54 -0
  68. package/src/ui/utils/toCreateMessage.ts +5 -0
  69. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -1
  70. package/dist/packages/assistant-stream/dist/core/tool/schema-utils.d.ts +0 -1
  71. package/dist/packages/assistant-stream/dist/core/tool/schema-utils.js.map +0 -1
  72. package/dist/packages/assistant-stream/dist/core/tool/tool-types.d.ts.map +0 -1
  73. package/dist/packages/assistant-stream/dist/index.d.ts +0 -1
  74. package/dist/packages/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.d.ts.map +0 -1
  75. package/dist/packages/assistant-stream/dist/resumable/createResumableAssistantStreamResponse.js.map +0 -1
  76. package/dist/packages/assistant-stream/dist/utils/json/json-value.d.ts.map +0 -1
  77. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/AssistantStream.d.ts +0 -0
  78. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/AssistantStreamChunk.d.ts +0 -0
  79. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/accumulators/AssistantMessageStream.d.ts +0 -0
  80. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/accumulators/assistant-message-accumulator.d.ts +0 -0
  81. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/modules/assistant-stream.d.ts +0 -0
  82. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/modules/text.d.ts +0 -0
  83. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/modules/tool-call.d.ts +0 -0
  84. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/serialization/PlainText.d.ts +0 -0
  85. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/serialization/assistant-transport/AssistantTransport.d.ts +0 -0
  86. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/serialization/data-stream/DataStream.d.ts +0 -0
  87. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/serialization/ui-message-stream/UIMessageStream.d.ts +0 -0
  88. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/tool/ToolExecutionStream.d.ts +0 -0
  89. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/tool/toolResultStream.d.ts +0 -0
  90. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/utils/stream/AssistantMetaTransformStream.d.ts +0 -0
  91. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/utils/stream/AssistantTransformStream.d.ts +0 -0
  92. /package/dist/{packages/assistant-stream → assistant-stream}/dist/core/utils/types.d.ts +0 -0
  93. /package/dist/{packages/assistant-stream → assistant-stream}/dist/resumable/createResumableAssistantStreamResponse.d.ts +0 -0
  94. /package/dist/{packages/assistant-stream → assistant-stream}/dist/resumable/createResumableAssistantStreamResponse.js +0 -0
  95. /package/dist/{packages/assistant-stream → assistant-stream}/dist/resumable/index.d.ts +0 -0
  96. /package/dist/{packages/assistant-stream → assistant-stream}/dist/utils/json/json-value.d.ts +0 -0
  97. /package/dist/{packages/assistant-stream → assistant-stream}/dist/utils.d.ts +0 -0
@@ -0,0 +1,310 @@
1
+ import { jsonSchema, type ToolSet } from "ai";
2
+ import type { MCPClient, MCPClientConfig } from "@ai-sdk/mcp";
3
+ import { createMCPClient } from "@ai-sdk/mcp";
4
+ import { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
5
+ import {
6
+ toJSONSchema,
7
+ type Tool,
8
+ type McpServerConfig,
9
+ type ToolJSONSchema,
10
+ type ToolModelOutputFunction,
11
+ } from "assistant-stream";
12
+ import type { Toolkit, ToolkitDefinition } from "@assistant-ui/core/react";
13
+ import { frontendTools } from "./frontendTools";
14
+ import { toAISDKContent, toAISDKDefaultOutput } from "./toolOutputConversion";
15
+ import {
16
+ unwrapModelContentEnvelope,
17
+ type ModelContentEnvelope,
18
+ } from "./modelContentEnvelope";
19
+
20
+ const EMPTY_SCHEMA = { type: "object" as const, properties: {} };
21
+
22
+ const humanNotSupported = (): never => {
23
+ throw new Error(
24
+ "`human()` is not available during server-side tool execution.",
25
+ );
26
+ };
27
+
28
+ // AI SDK leaves `abortSignal` optional; assistant-ui's execute requires one.
29
+ const neverAbort = new AbortController().signal;
30
+
31
+ const parametersToInputSchema = (parameters: Tool["parameters"] | undefined) =>
32
+ jsonSchema(parameters ? toJSONSchema(parameters) : EMPTY_SCHEMA);
33
+
34
+ export interface GenerativeToolsOptions {
35
+ /**
36
+ * The server build of a generative toolkit (schema + server `execute`). Typed
37
+ * as the canonical {@link Toolkit} so callers don't need to cast; the server
38
+ * build carries `execute`, recovered internally as {@link ToolkitDefinition}.
39
+ */
40
+ toolkit: Toolkit;
41
+ /**
42
+ * Tools uploaded by the frontend (the request body's `tools`). Merged in
43
+ * alongside the `toolkit`; a server `execute` from `toolkit` takes precedence
44
+ * over an uploaded entry of the same name.
45
+ */
46
+ frontendTools?: Record<string, ToolJSONSchema>;
47
+ }
48
+
49
+ export type AISDKToolkitOptions = {
50
+ toolkit: Toolkit;
51
+ };
52
+
53
+ export type AISDKToolkitToolsOptions = {
54
+ /**
55
+ * Tools uploaded by the frontend request body.
56
+ */
57
+ frontend?: Record<string, ToolJSONSchema>;
58
+ };
59
+
60
+ /**
61
+ * Builds an AI SDK `ToolSet` for server-side use with `streamText` /
62
+ * `generateText` from a generative `toolkit` and the frontend-uploaded tools.
63
+ *
64
+ * Each toolkit tool's `execute` runs on the server. Pair this with the
65
+ * `"use generative"` compiler: import the toolkit in a server route (where it
66
+ * resolves to the server build — schema + `execute`, with `render` stripped) and
67
+ * pass it here. Tools without an `execute` are still exposed to the model but
68
+ * left for the client to fulfill. `frontendTools` lets the client contribute
69
+ * tools that aren't in the static toolkit. Use {@link AISDKToolkit} when the
70
+ * toolkit contains MCP entries.
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const { tools } = await req.json();
75
+ * streamText({
76
+ * model,
77
+ * messages,
78
+ * tools: generativeTools({ toolkit: docsToolkit, frontendTools: tools }),
79
+ * });
80
+ * ```
81
+ */
82
+ export const generativeTools = (options: GenerativeToolsOptions): ToolSet => {
83
+ assertNoMcpToolkitTools(options.toolkit);
84
+ return {
85
+ ...(options.frontendTools ? frontendTools(options.frontendTools) : {}),
86
+ // `toolkit` last so its server-side `execute` wins over an uploaded entry of
87
+ // the same name. The cast recovers the declaration shape — the server build
88
+ // carries `execute`, which the canonical `Toolkit` type erases.
89
+ ...toProviderToolSet(options.toolkit),
90
+ ...toServerToolSet(options.toolkit as ToolkitDefinition),
91
+ };
92
+ };
93
+
94
+ export class AISDKToolkit {
95
+ readonly #toolkit: Toolkit;
96
+ readonly #mcpClients = new Map<string, Promise<MCPClient>>();
97
+
98
+ constructor(options: AISDKToolkitOptions) {
99
+ this.#toolkit = options.toolkit;
100
+ }
101
+
102
+ async tools(options: AISDKToolkitToolsOptions = {}): Promise<ToolSet> {
103
+ return {
104
+ ...(options.frontend ? frontendTools(options.frontend) : {}),
105
+ ...(await this.#mcpTools()),
106
+ ...toProviderToolSet(this.#toolkit),
107
+ ...toServerToolSet(this.#toolkit as ToolkitDefinition),
108
+ };
109
+ }
110
+
111
+ async close(): Promise<void> {
112
+ const clientPromises = [...this.#mcpClients.values()];
113
+ this.#mcpClients.clear();
114
+ const results = await Promise.allSettled(clientPromises);
115
+ const clients = results.flatMap((result) =>
116
+ result.status === "fulfilled" ? [result.value] : [],
117
+ );
118
+ const closeResults = await Promise.allSettled(
119
+ clients.map((client) => client.close()),
120
+ );
121
+ const errors = [
122
+ ...results.flatMap((result) =>
123
+ result.status === "rejected" ? [result.reason] : [],
124
+ ),
125
+ ...closeResults.flatMap((result) =>
126
+ result.status === "rejected" ? [result.reason] : [],
127
+ ),
128
+ ];
129
+ if (errors.length === 1) throw errors[0];
130
+ if (errors.length > 1) {
131
+ throw new AggregateError(
132
+ errors,
133
+ "Failed to close one or more MCP clients",
134
+ );
135
+ }
136
+ }
137
+
138
+ async #mcpTools(): Promise<ToolSet> {
139
+ const toolSets = await Promise.all(
140
+ Object.entries(this.#toolkit)
141
+ .filter((entry): entry is [string, McpToolkitTool] =>
142
+ isMcpToolkitTool(entry[1]),
143
+ )
144
+ .map(async ([name, tool]) => {
145
+ const client = await this.#mcpClient(name, tool.server);
146
+ return [name, await client.tools()] as const;
147
+ }),
148
+ );
149
+
150
+ const tools: ToolSet = {};
151
+ const toolSources = new Map<string, string>();
152
+ for (const [serverName, toolSet] of toolSets) {
153
+ for (const [toolName, tool] of Object.entries(toolSet)) {
154
+ const existingServerName = toolSources.get(toolName);
155
+ if (existingServerName) {
156
+ throw new Error(
157
+ `MCP tool name collision: "${toolName}" is exposed by both "${existingServerName}" and "${serverName}". Rename one of the toolkit entries or expose distinct MCP tool names.`,
158
+ );
159
+ }
160
+ toolSources.set(toolName, serverName);
161
+ tools[toolName] = tool;
162
+ }
163
+ }
164
+ return tools;
165
+ }
166
+
167
+ #mcpClient(name: string, config: McpServerConfig): Promise<MCPClient> {
168
+ const existing = this.#mcpClients.get(name);
169
+ if (existing) return existing;
170
+ let next: Promise<MCPClient>;
171
+ next = createMCPClient(toMCPClientConfig(config)).catch((error) => {
172
+ if (this.#mcpClients.get(name) === next) {
173
+ this.#mcpClients.delete(name);
174
+ }
175
+ throw error;
176
+ });
177
+ this.#mcpClients.set(name, next);
178
+ return next;
179
+ }
180
+ }
181
+
182
+ const toMCPClientConfig = (config: McpServerConfig): MCPClientConfig => {
183
+ if (config.type === "stdio") {
184
+ return {
185
+ transport: new Experimental_StdioMCPTransport({
186
+ command: config.command,
187
+ ...(config.args && { args: [...config.args] }),
188
+ ...(config.env && { env: config.env }),
189
+ ...(config.cwd && { cwd: config.cwd }),
190
+ }),
191
+ };
192
+ }
193
+
194
+ return {
195
+ transport: {
196
+ type: config.type,
197
+ url: config.url,
198
+ ...(config.headers && { headers: config.headers }),
199
+ ...(config.redirect && { redirect: config.redirect }),
200
+ },
201
+ };
202
+ };
203
+
204
+ type ToolkitTool = Toolkit[string];
205
+
206
+ type McpToolkitTool = ToolkitTool & {
207
+ type: "mcp";
208
+ server: McpServerConfig;
209
+ };
210
+
211
+ const isMcpToolkitTool = (tool: ToolkitTool): tool is McpToolkitTool =>
212
+ tool.type === "mcp" && !tool.disabled;
213
+
214
+ const assertNoMcpToolkitTools = (toolkit: Toolkit): void => {
215
+ const mcpToolName = Object.entries(toolkit).find(([, tool]) =>
216
+ isMcpToolkitTool(tool),
217
+ )?.[0];
218
+ if (!mcpToolName) return;
219
+
220
+ throw new Error(
221
+ `MCP toolkit entry "${mcpToolName}" requires AISDKToolkit. Use new AISDKToolkit({ toolkit }).tools(...) instead of generativeTools(...).`,
222
+ );
223
+ };
224
+
225
+ type AISDKToModelOutputOptions<TArgs, TResult> = Omit<
226
+ Parameters<ToolModelOutputFunction<TArgs, TResult>>[0],
227
+ "output"
228
+ > & {
229
+ output: TResult | ModelContentEnvelope<TResult>;
230
+ };
231
+
232
+ const toAISDKToModelOutput =
233
+ <TArgs, TResult>(toModelOutput?: ToolModelOutputFunction<TArgs, TResult>) =>
234
+ async (options: AISDKToModelOutputOptions<TArgs, TResult>) => {
235
+ const { result, modelContent } = unwrapModelContentEnvelope(options.output);
236
+
237
+ if (modelContent !== undefined) {
238
+ return toAISDKContent(modelContent);
239
+ }
240
+
241
+ if (!toModelOutput) {
242
+ return toAISDKDefaultOutput(result);
243
+ }
244
+
245
+ const parts = await toModelOutput({
246
+ ...options,
247
+ output: result,
248
+ });
249
+ return toAISDKContent(parts);
250
+ };
251
+
252
+ const toServerToolSet = (toolkit: ToolkitDefinition): ToolSet =>
253
+ Object.fromEntries(
254
+ Object.entries(toolkit)
255
+ .filter(
256
+ ([, t]) => t.type !== "mcp" && t.type !== "provider" && !t.disabled,
257
+ )
258
+ .map(([name, t]) => {
259
+ const execute = t.execute;
260
+ return [
261
+ name,
262
+ {
263
+ ...(t.description !== undefined && { description: t.description }),
264
+ inputSchema: parametersToInputSchema(t.parameters),
265
+ toModelOutput: toAISDKToModelOutput(t.toModelOutput),
266
+ ...(t.providerOptions && { providerOptions: t.providerOptions }),
267
+ ...(execute && {
268
+ execute: (
269
+ args: unknown,
270
+ callOptions: { toolCallId: string; abortSignal?: AbortSignal },
271
+ ) =>
272
+ execute(args as never, {
273
+ toolCallId: callOptions.toolCallId,
274
+ abortSignal: callOptions.abortSignal ?? neverAbort,
275
+ human: humanNotSupported,
276
+ }),
277
+ }),
278
+ },
279
+ ];
280
+ }),
281
+ ) as ToolSet;
282
+
283
+ const toProviderToolSet = (toolkit: Toolkit): ToolSet =>
284
+ Object.fromEntries(
285
+ Object.entries(toolkit)
286
+ .filter((entry): entry is [string, ProviderToolkitTool] =>
287
+ isProviderToolkitTool(entry[1]),
288
+ )
289
+ .map(([name, t]) => [
290
+ name,
291
+ {
292
+ type: "provider",
293
+ id: t.providerId,
294
+ args: t.args,
295
+ ...(t.parameters && {
296
+ inputSchema: parametersToInputSchema(t.parameters),
297
+ }),
298
+ ...(t.providerOptions && { providerOptions: t.providerOptions }),
299
+ ...(t.supportsDeferredResults !== undefined && {
300
+ supportsDeferredResults: t.supportsDeferredResults,
301
+ }),
302
+ },
303
+ ]),
304
+ ) as ToolSet;
305
+
306
+ type ProviderToolkitTool = Extract<Toolkit[string], { type: "provider" }>;
307
+
308
+ const isProviderToolkitTool = (
309
+ tool: Toolkit[string],
310
+ ): tool is ProviderToolkitTool => tool.type === "provider" && !tool.disabled;
package/src/index.ts CHANGED
@@ -13,6 +13,13 @@ export type {
13
13
  ResumableClientStorage,
14
14
  } from "./ui/resumable";
15
15
  export { frontendTools } from "./frontendTools";
16
+ export {
17
+ AISDKToolkit,
18
+ generativeTools,
19
+ type AISDKToolkitOptions,
20
+ type AISDKToolkitToolsOptions,
21
+ type GenerativeToolsOptions,
22
+ } from "./generativeTools";
16
23
  export { injectQuoteContext } from "./injectQuoteContext";
17
24
  export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
18
25
  export { getThreadMessageTokenUsage, useThreadTokenUsage } from "./usage";
@@ -7,9 +7,9 @@ export type ModelContentEnvelope<TResult = unknown> = {
7
7
  readonly value: TResult;
8
8
  };
9
9
 
10
- export function isModelContentEnvelope(
11
- value: unknown,
12
- ): value is ModelContentEnvelope {
10
+ export function isModelContentEnvelope<TResult = unknown>(
11
+ value: TResult | ModelContentEnvelope<TResult>,
12
+ ): value is ModelContentEnvelope<TResult> {
13
13
  return (
14
14
  value != null &&
15
15
  typeof value === "object" &&
@@ -25,8 +25,10 @@ export function wrapModelContentEnvelope<TResult>(
25
25
  return { [ENVELOPE_KEY]: modelContent, value: result };
26
26
  }
27
27
 
28
- export function unwrapModelContentEnvelope(output: unknown): {
29
- result: unknown;
28
+ export function unwrapModelContentEnvelope<TResult>(
29
+ output: TResult | ModelContentEnvelope<TResult>,
30
+ ): {
31
+ result: TResult;
30
32
  modelContent?: readonly ToolModelContentPart[];
31
33
  } {
32
34
  if (isModelContentEnvelope(output)) {
@@ -0,0 +1,29 @@
1
+ import type { JSONValue } from "ai";
2
+ import type { ToolModelContentPart } from "assistant-stream";
3
+
4
+ export const toAISDKContent = (parts: readonly ToolModelContentPart[]) => ({
5
+ type: "content" as const,
6
+ value: parts.map((part) => {
7
+ if (part.type === "text") {
8
+ return { type: "text" as const, text: part.text };
9
+ }
10
+ const isImage = part.mediaType.startsWith("image/");
11
+ return isImage
12
+ ? {
13
+ type: "image-data" as const,
14
+ data: part.data,
15
+ mediaType: part.mediaType,
16
+ }
17
+ : {
18
+ type: "file-data" as const,
19
+ data: part.data,
20
+ mediaType: part.mediaType,
21
+ ...(part.filename !== undefined && { filename: part.filename }),
22
+ };
23
+ }),
24
+ });
25
+
26
+ export const toAISDKDefaultOutput = (output: unknown) =>
27
+ typeof output === "string"
28
+ ? { type: "text" as const, value: output }
29
+ : { type: "json" as const, value: (output ?? null) as JSONValue };
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { validateUIMessages } from "ai";
5
6
 
6
7
  // Mock only the sibling module that requires AUI store context (not available
7
8
  // in isolation). Every other dependency — useExternalStoreRuntime,
@@ -140,6 +141,54 @@ describe("useAISDKRuntime", () => {
140
141
  expect(chat.messages[0].parts[1].state).toBe("output-available");
141
142
  });
142
143
 
144
+ it("strips stale approval when cancelling a tool pending approval so history stays valid (#4195)", async () => {
145
+ const chat = createChatHelpers([
146
+ {
147
+ id: "a1",
148
+ role: "assistant",
149
+ parts: [
150
+ {
151
+ type: "dynamic-tool",
152
+ toolName: "mcp_search",
153
+ toolCallId: "tc-1",
154
+ state: "approval-requested",
155
+ input: { q: "hi" },
156
+ approval: { id: "appr-1" },
157
+ },
158
+ ],
159
+ },
160
+ ]);
161
+
162
+ const { result } = renderHook(() => useAISDKRuntime(chat));
163
+
164
+ await waitFor(() => {
165
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
166
+ 0,
167
+ );
168
+ });
169
+
170
+ act(() => {
171
+ result.current.thread.append({
172
+ role: "user",
173
+ content: [{ type: "text", text: "what" }],
174
+ });
175
+ });
176
+
177
+ await waitFor(() => {
178
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
179
+ });
180
+
181
+ const part = chat.messages[0].parts[0];
182
+ expect(part.state).toBe("output-error");
183
+ // The pending-approval object must not survive into the terminal state,
184
+ // otherwise AI SDK's validateUIMessages rejects the next request.
185
+ expect(part.approval).toBeUndefined();
186
+
187
+ await expect(
188
+ validateUIMessages({ messages: chat.messages }),
189
+ ).resolves.toBeDefined();
190
+ });
191
+
143
192
  it("appends a new user message without sending when startRun is false", async () => {
144
193
  const chat = createChatHelpers([
145
194
  { id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
@@ -354,4 +403,40 @@ describe("useAISDKRuntime", () => {
354
403
  metadata: { custom: { maxTokens: 100 } },
355
404
  });
356
405
  });
406
+
407
+ it("forwards isDisabled to thread state", () => {
408
+ const chat = createChatHelpers();
409
+ const { result } = renderHook(() =>
410
+ useAISDKRuntime(chat, { isDisabled: true }),
411
+ );
412
+ expect(result.current.thread.getState().isDisabled).toBe(true);
413
+ });
414
+
415
+ it("forwards isSendDisabled to the composer canSend gate", () => {
416
+ const chat = createChatHelpers();
417
+ const { result } = renderHook(() =>
418
+ useAISDKRuntime(chat, { isSendDisabled: true }),
419
+ );
420
+ act(() => {
421
+ result.current.thread.composer.setText("hello");
422
+ });
423
+ expect(result.current.thread.composer.getState().canSend).toBe(false);
424
+ });
425
+
426
+ it("forwards unstable_capabilities to thread capabilities", () => {
427
+ const chat = createChatHelpers();
428
+ const { result } = renderHook(() =>
429
+ useAISDKRuntime(chat, { unstable_capabilities: { copy: false } }),
430
+ );
431
+ expect(result.current.thread.getState().capabilities.unstable_copy).toBe(
432
+ false,
433
+ );
434
+ });
435
+
436
+ it("forwards suggestions to thread state", () => {
437
+ const chat = createChatHelpers();
438
+ const suggestions = [{ prompt: "tell me a joke" }];
439
+ const { result } = renderHook(() => useAISDKRuntime(chat, { suggestions }));
440
+ expect(result.current.thread.getState().suggestions).toEqual(suggestions);
441
+ });
357
442
  });
@@ -10,10 +10,10 @@ import {
10
10
  import type { ToolExecutionStatus } from "@assistant-ui/core";
11
11
  import type {
12
12
  ExternalStoreAdapter,
13
+ ExternalStoreSharedOptions,
13
14
  ThreadHistoryAdapter,
14
15
  AssistantRuntime,
15
16
  ThreadMessage,
16
- ThreadSuggestion,
17
17
  MessageFormatAdapter,
18
18
  MessageFormatItem,
19
19
  MessageFormatRepository,
@@ -21,7 +21,10 @@ import type {
21
21
  RunConfig,
22
22
  McpAppMetadata,
23
23
  } from "@assistant-ui/core";
24
- import { getExternalStoreMessages } from "@assistant-ui/core";
24
+ import {
25
+ getExternalStoreMessages,
26
+ pickExternalStoreSharedOptions,
27
+ } from "@assistant-ui/core";
25
28
  import type { ReadonlyJSONObject } from "assistant-stream/utils";
26
29
  import { sliceMessagesUntil } from "../utils/sliceMessagesUntil";
27
30
  import { toCreateMessage } from "../utils/toCreateMessage";
@@ -55,7 +58,7 @@ const toUIMessage = <UI_MESSAGE extends UIMessage>(
55
58
  role: createMessage.role ?? fallbackRole,
56
59
  }) as UI_MESSAGE;
57
60
 
58
- export type AISDKRuntimeAdapter = {
61
+ export type AISDKRuntimeAdapter = ExternalStoreSharedOptions & {
59
62
  adapters?:
60
63
  | (NonNullable<ExternalStoreAdapter["adapters"]> & {
61
64
  history?: ThreadHistoryAdapter | undefined;
@@ -79,25 +82,18 @@ export type AISDKRuntimeAdapter = {
79
82
  * (for example, an SSE reconnect endpoint keyed by turn id).
80
83
  */
81
84
  onResume?: ExternalStoreAdapter["onResume"];
82
- /**
83
- * Follow up suggestions to surface on the thread. Use this to drive
84
- * dynamic suggestions from application state, tool results, or backend
85
- * responses; flows into `thread.suggestions` and is rendered by
86
- * components that read it (such as the shadcn `ThreadFollowupSuggestions`).
87
- */
88
- suggestions?: readonly ThreadSuggestion[] | undefined;
89
85
  };
90
86
 
91
87
  export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
92
88
  chatHelpers: ReturnType<typeof useChat<UI_MESSAGE>>,
93
- {
89
+ adapter: AISDKRuntimeAdapter = {},
90
+ ) => {
91
+ const {
94
92
  adapters,
95
93
  toCreateMessage: customToCreateMessage,
96
94
  cancelPendingToolCallsOnSend = true,
97
95
  onResume,
98
- suggestions,
99
- }: AISDKRuntimeAdapter = {},
100
- ) => {
96
+ } = adapter;
101
97
  const contextAdapters = useRuntimeAdapters();
102
98
  const [toolStatuses, setToolStatuses] = useState<
103
99
  Record<string, ToolExecutionStatus>
@@ -121,6 +117,12 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
121
117
 
122
118
  const messageTiming = useStreamingTiming(chatHelpers.messages, isRunning);
123
119
 
120
+ // Flag the streaming message optimistic: its id can be swapped for a server
121
+ // id mid-run, and the repository then drops the orphaned pre-swap id (#4037).
122
+ const lastMessage = chatHelpers.messages.at(-1);
123
+ const optimisticMessageId =
124
+ isRunning && lastMessage?.role === "assistant" ? lastMessage.id : undefined;
125
+
124
126
  const messages = AISDKMessageConverter.useThreadMessages({
125
127
  isRunning,
126
128
  messages: chatHelpers.messages,
@@ -131,9 +133,10 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
131
133
  toolArgsKeyOrderCache: toolArgsKeyOrderCacheRef.current,
132
134
  toolLastInputCache: toolLastInputCacheRef.current,
133
135
  mcpAppMetadataCache: mcpAppMetadataCacheRef.current,
136
+ ...(optimisticMessageId && { optimisticMessageId }),
134
137
  ...(chatHelpers.error && { error: chatHelpers.error.message }),
135
138
  }),
136
- [toolStatuses, messageTiming, chatHelpers.error],
139
+ [toolStatuses, messageTiming, optimisticMessageId, chatHelpers.error],
137
140
  ),
138
141
  });
139
142
 
@@ -177,8 +180,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
177
180
  return part;
178
181
 
179
182
  hasChanges = true;
183
+ const { approval: _approval, ...rest } = part;
180
184
  return {
181
- ...part,
185
+ ...rest,
182
186
  state: "output-error" as const,
183
187
  errorText: "User cancelled tool call by sending a new message.",
184
188
  };
@@ -344,8 +348,8 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
344
348
  options: { metadata: lastRunConfigRef.current },
345
349
  });
346
350
  },
351
+ ...pickExternalStoreSharedOptions(adapter),
347
352
  ...(onResume && { onResume }),
348
- ...(suggestions && { suggestions }),
349
353
  adapters: {
350
354
  attachments: vercelAttachmentAdapter,
351
355
  ...contextAdapters,
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { useChat, type UIMessage } from "@ai-sdk/react";
4
4
  import type { AssistantCloud } from "assistant-cloud";
5
- import type { AssistantRuntime } from "@assistant-ui/core";
5
+ import {
6
+ pickExternalStoreSharedOptions,
7
+ type AssistantRuntime,
8
+ type ExternalStoreSharedOptions,
9
+ } from "@assistant-ui/core";
6
10
  import {
7
11
  useCloudThreadListAdapter,
8
12
  useRemoteThreadListRuntime,
@@ -19,24 +23,21 @@ import type { AssistantChatResumableOptions } from "../resumable";
19
23
  import { useEffect, useMemo, useRef } from "react";
20
24
 
21
25
  export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
22
- ChatInit<UI_MESSAGE> & {
23
- cloud?: AssistantCloud | undefined;
24
- adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
25
- toCreateMessage?: CustomToCreateMessageFunction;
26
- onResume?: AISDKRuntimeAdapter["onResume"];
27
- suggestions?: AISDKRuntimeAdapter["suggestions"];
28
- };
26
+ ChatInit<UI_MESSAGE> &
27
+ ExternalStoreSharedOptions & {
28
+ cloud?: AssistantCloud | undefined;
29
+ adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
30
+ toCreateMessage?: CustomToCreateMessageFunction;
31
+ onResume?: AISDKRuntimeAdapter["onResume"];
32
+ };
29
33
 
30
34
  const useDynamicChatTransport = <UI_MESSAGE extends UIMessage = UIMessage>(
31
35
  transport: ChatTransport<UI_MESSAGE>,
32
36
  ): ChatTransport<UI_MESSAGE> => {
33
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
34
37
  const transportRef = useRef<ChatTransport<UI_MESSAGE>>(transport);
35
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
36
38
  useEffect(() => {
37
39
  transportRef.current = transport;
38
40
  });
39
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
40
41
  const dynamicTransport = useMemo(
41
42
  () =>
42
43
  new Proxy(transportRef.current, {
@@ -72,33 +73,36 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
72
73
  adapters,
73
74
  transport: transportOptions,
74
75
  toCreateMessage,
76
+ isDisabled: _isDisabled,
77
+ isSendDisabled: _isSendDisabled,
78
+ unstable_capabilities: _unstable_capabilities,
79
+ suggestions: _suggestions,
75
80
  onResume,
76
- suggestions,
77
81
  ...chatOptions
78
82
  } = options ?? {};
83
+ // peel guard: any shared key left in `chatOptions` collapses this to `never`
84
+ true satisfies keyof typeof chatOptions &
85
+ keyof ExternalStoreSharedOptions extends never
86
+ ? true
87
+ : never;
79
88
 
80
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
81
89
  const transport = useDynamicChatTransport(
82
90
  transportOptions ?? new AssistantChatTransport(),
83
91
  );
84
92
 
85
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
86
93
  const id = useAuiState((s) => s.threadListItem.id);
87
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
88
94
  const aui = useAui();
89
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
90
95
  const chat = useChat({
91
96
  ...chatOptions,
92
97
  id,
93
98
  transport,
94
99
  });
95
100
 
96
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
97
101
  const runtime = useAISDKRuntime(chat, {
98
102
  adapters,
103
+ ...pickExternalStoreSharedOptions(options ?? {}),
99
104
  ...(toCreateMessage && { toCreateMessage }),
100
105
  ...(onResume && { onResume }),
101
- ...(suggestions && { suggestions }),
102
106
  });
103
107
 
104
108
  if (transport instanceof AssistantChatTransport) {
@@ -108,9 +112,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
108
112
  );
109
113
  }
110
114
 
111
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
112
115
  const resumeFiredRef = useRef(false);
113
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
114
116
  useEffect(() => {
115
117
  if (resumeFiredRef.current) return;
116
118
  const adapter = getResumableAdapter(transport);
@@ -137,7 +139,6 @@ export const useChatRuntime = <UI_MESSAGE extends UIMessage = UIMessage>({
137
139
  const cloudAdapter = useCloudThreadListAdapter({ cloud });
138
140
  return useRemoteThreadListRuntime({
139
141
  runtimeHook: function RuntimeHook() {
140
- // biome-ignore lint/correctness/useHookAtTopLevel: intentional conditional/nested hook usage
141
142
  return useChatThreadRuntime(options);
142
143
  },
143
144
  adapter: cloudAdapter,