@assistant-ui/react-ai-sdk 1.3.31 → 1.3.33

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 (65) hide show
  1. package/dist/assistant-stream/dist/core/tool/schema-utils.js +5 -2
  2. package/dist/assistant-stream/dist/core/tool/schema-utils.js.map +1 -1
  3. package/dist/assistant-stream/dist/core/tool/tool-types.d.ts.map +1 -1
  4. package/dist/client.d.ts +10 -0
  5. package/dist/client.js +9 -0
  6. package/dist/frontendTools.d.ts +1 -1
  7. package/dist/frontendTools.d.ts.map +1 -1
  8. package/dist/frontendTools.js +3 -27
  9. package/dist/frontendTools.js.map +1 -1
  10. package/dist/generativeTools.d.ts +33 -3
  11. package/dist/generativeTools.d.ts.map +1 -1
  12. package/dist/generativeTools.js +117 -10
  13. package/dist/generativeTools.js.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.js +3 -3
  16. package/dist/index.native.d.ts +10 -0
  17. package/dist/index.native.js +9 -0
  18. package/dist/injectQuoteContext.js.map +1 -1
  19. package/dist/mcp-stdio.node.d.ts +2 -0
  20. package/dist/mcp-stdio.node.js +2 -0
  21. package/dist/mcp-stdio.unsupported.d.ts +7 -0
  22. package/dist/mcp-stdio.unsupported.d.ts.map +1 -0
  23. package/dist/mcp-stdio.unsupported.js +11 -0
  24. package/dist/mcp-stdio.unsupported.js.map +1 -0
  25. package/dist/modelContentEnvelope.d.ts +3 -3
  26. package/dist/modelContentEnvelope.d.ts.map +1 -1
  27. package/dist/modelContentEnvelope.js.map +1 -1
  28. package/dist/toolOutputConversion.d.ts +34 -0
  29. package/dist/toolOutputConversion.d.ts.map +1 -0
  30. package/dist/toolOutputConversion.js +31 -0
  31. package/dist/toolOutputConversion.js.map +1 -0
  32. package/dist/ui/use-chat/AssistantChatTransport.js.map +1 -1
  33. package/dist/ui/use-chat/useAISDKRuntime.d.ts +10 -0
  34. package/dist/ui/use-chat/useAISDKRuntime.d.ts.map +1 -1
  35. package/dist/ui/use-chat/useAISDKRuntime.js +6 -4
  36. package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
  37. package/dist/ui/use-chat/useChatRuntime.d.ts +1 -0
  38. package/dist/ui/use-chat/useChatRuntime.d.ts.map +1 -1
  39. package/dist/ui/use-chat/useChatRuntime.js +3 -2
  40. package/dist/ui/use-chat/useChatRuntime.js.map +1 -1
  41. package/dist/ui/use-chat/useExternalHistory.js.map +1 -1
  42. package/dist/ui/use-chat/useStreamingTiming.js +1 -1
  43. package/dist/ui/utils/convertMessage.d.ts +1 -1
  44. package/dist/ui/utils/convertMessage.d.ts.map +1 -1
  45. package/dist/ui/utils/convertMessage.js.map +1 -1
  46. package/dist/ui/utils/sliceMessagesUntil.js.map +1 -1
  47. package/dist/ui/utils/toCreateMessage.js +4 -0
  48. package/dist/ui/utils/toCreateMessage.js.map +1 -1
  49. package/dist/usage.js.map +1 -1
  50. package/package.json +27 -10
  51. package/src/client.ts +18 -0
  52. package/src/frontendTools.ts +4 -27
  53. package/src/generativeTools.test.ts +389 -0
  54. package/src/generativeTools.ts +253 -19
  55. package/src/index.native.ts +3 -0
  56. package/src/index.ts +4 -16
  57. package/src/mcp-stdio.node.ts +1 -0
  58. package/src/mcp-stdio.unsupported.ts +12 -0
  59. package/src/modelContentEnvelope.ts +7 -5
  60. package/src/toolOutputConversion.ts +29 -0
  61. package/src/ui/use-chat/useAISDKRuntime.test.ts +105 -0
  62. package/src/ui/use-chat/useAISDKRuntime.ts +14 -1
  63. package/src/ui/use-chat/useChatRuntime.ts +3 -0
  64. package/src/ui/utils/toCreateMessage.test.ts +54 -0
  65. package/src/ui/utils/toCreateMessage.ts +5 -0
@@ -1,7 +1,21 @@
1
1
  import { jsonSchema, type ToolSet } from "ai";
2
- import { toJSONSchema, type ToolJSONSchema } from "assistant-stream";
3
- import type { Toolkit, ToolkitDeclaration } from "@assistant-ui/core/react";
4
- import { defaultToModelOutput, frontendTools } from "./frontendTools";
2
+ import type { MCPClient, MCPClientConfig } from "@ai-sdk/mcp";
3
+ import { createMCPClient } from "@ai-sdk/mcp";
4
+ import { Experimental_StdioMCPTransport } from "#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";
5
19
 
6
20
  const EMPTY_SCHEMA = { type: "object" as const, properties: {} };
7
21
 
@@ -14,11 +28,19 @@ const humanNotSupported = (): never => {
14
28
  // AI SDK leaves `abortSignal` optional; assistant-ui's execute requires one.
15
29
  const neverAbort = new AbortController().signal;
16
30
 
31
+ const parametersToInputSchema = (parameters: Tool["parameters"] | undefined) =>
32
+ jsonSchema(parameters ? toJSONSchema(parameters) : EMPTY_SCHEMA);
33
+
34
+ /**
35
+ * @deprecated Options for the deprecated {@link generativeTools}. Use
36
+ * {@link AISDKToolkit} with {@link AISDKToolkitOptions} /
37
+ * {@link AISDKToolkitToolsOptions} instead.
38
+ */
17
39
  export interface GenerativeToolsOptions {
18
40
  /**
19
41
  * The server build of a generative toolkit (schema + server `execute`). Typed
20
42
  * as the canonical {@link Toolkit} so callers don't need to cast; the server
21
- * build carries `execute`, recovered internally as {@link ToolkitDeclaration}.
43
+ * build carries `execute`, recovered internally as {@link ToolkitDefinition}.
22
44
  */
23
45
  toolkit: Toolkit;
24
46
  /**
@@ -29,6 +51,17 @@ export interface GenerativeToolsOptions {
29
51
  frontendTools?: Record<string, ToolJSONSchema>;
30
52
  }
31
53
 
54
+ export type AISDKToolkitOptions = {
55
+ toolkit: Toolkit;
56
+ };
57
+
58
+ export type AISDKToolkitToolsOptions = {
59
+ /**
60
+ * Tools uploaded by the frontend request body.
61
+ */
62
+ frontend?: Record<string, ToolJSONSchema>;
63
+ };
64
+
32
65
  /**
33
66
  * Builds an AI SDK `ToolSet` for server-side use with `streamText` /
34
67
  * `generateText` from a generative `toolkit` and the frontend-uploaded tools.
@@ -40,38 +73,210 @@ export interface GenerativeToolsOptions {
40
73
  * left for the client to fulfill. `frontendTools` lets the client contribute
41
74
  * tools that aren't in the static toolkit.
42
75
  *
76
+ * @deprecated Use {@link AISDKToolkit} instead:
77
+ * `new AISDKToolkit({ toolkit }).tools({ frontend })`. It is a strict superset
78
+ * (it also opens MCP server connections), so it replaces `generativeTools`
79
+ * everywhere. The `frontendTools` option is named `frontend` on `.tools()`, and
80
+ * `.tools()` is async. `generativeTools` will be removed in a future version.
81
+ *
43
82
  * @example
44
83
  * ```ts
84
+ * // Define once at module scope so any MCP connections pool across requests.
85
+ * const aiToolkit = new AISDKToolkit({ toolkit: docsToolkit });
86
+ *
87
+ * // In your route handler:
45
88
  * const { tools } = await req.json();
46
89
  * streamText({
47
90
  * model,
48
91
  * messages,
49
- * tools: generativeTools({ toolkit: docsToolkit, frontendTools: tools }),
92
+ * tools: await aiToolkit.tools({ frontend: tools }),
50
93
  * });
51
94
  * ```
52
95
  */
53
- export const generativeTools = (options: GenerativeToolsOptions): ToolSet => ({
54
- ...(options.frontendTools ? frontendTools(options.frontendTools) : {}),
55
- // `toolkit` last so its server-side `execute` wins over an uploaded entry of
56
- // the same name. The cast recovers the declaration shape — the server build
57
- // carries `execute`, which the canonical `Toolkit` type erases.
58
- ...toServerToolSet(options.toolkit as ToolkitDeclaration),
59
- });
60
-
61
- const toServerToolSet = (toolkit: ToolkitDeclaration): ToolSet =>
96
+ export const generativeTools = (options: GenerativeToolsOptions): ToolSet => {
97
+ assertNoMcpToolkitTools(options.toolkit);
98
+ return {
99
+ ...(options.frontendTools ? frontendTools(options.frontendTools) : {}),
100
+ // `toolkit` last so its server-side `execute` wins over an uploaded entry of
101
+ // the same name. The cast recovers the declaration shape — the server build
102
+ // carries `execute`, which the canonical `Toolkit` type erases.
103
+ ...toProviderToolSet(options.toolkit),
104
+ ...toServerToolSet(options.toolkit as ToolkitDefinition),
105
+ };
106
+ };
107
+
108
+ export class AISDKToolkit {
109
+ readonly #toolkit: Toolkit;
110
+ readonly #mcpClients = new Map<string, Promise<MCPClient>>();
111
+
112
+ constructor(options: AISDKToolkitOptions) {
113
+ this.#toolkit = options.toolkit;
114
+ }
115
+
116
+ async tools(options: AISDKToolkitToolsOptions = {}): Promise<ToolSet> {
117
+ return {
118
+ ...(options.frontend ? frontendTools(options.frontend) : {}),
119
+ ...(await this.#mcpTools()),
120
+ ...toProviderToolSet(this.#toolkit),
121
+ ...toServerToolSet(this.#toolkit as ToolkitDefinition),
122
+ };
123
+ }
124
+
125
+ async close(): Promise<void> {
126
+ const clientPromises = [...this.#mcpClients.values()];
127
+ this.#mcpClients.clear();
128
+ const results = await Promise.allSettled(clientPromises);
129
+ const clients = results.flatMap((result) =>
130
+ result.status === "fulfilled" ? [result.value] : [],
131
+ );
132
+ const closeResults = await Promise.allSettled(
133
+ clients.map((client) => client.close()),
134
+ );
135
+ const errors = [
136
+ ...results.flatMap((result) =>
137
+ result.status === "rejected" ? [result.reason] : [],
138
+ ),
139
+ ...closeResults.flatMap((result) =>
140
+ result.status === "rejected" ? [result.reason] : [],
141
+ ),
142
+ ];
143
+ if (errors.length === 1) throw errors[0];
144
+ if (errors.length > 1) {
145
+ throw new AggregateError(
146
+ errors,
147
+ "Failed to close one or more MCP clients",
148
+ );
149
+ }
150
+ }
151
+
152
+ async #mcpTools(): Promise<ToolSet> {
153
+ const toolSets = await Promise.all(
154
+ Object.entries(this.#toolkit)
155
+ .filter((entry): entry is [string, McpToolkitTool] =>
156
+ isMcpToolkitTool(entry[1]),
157
+ )
158
+ .map(async ([name, tool]) => {
159
+ const client = await this.#mcpClient(name, tool.server);
160
+ return [name, await client.tools()] as const;
161
+ }),
162
+ );
163
+
164
+ const tools: ToolSet = {};
165
+ const toolSources = new Map<string, string>();
166
+ for (const [serverName, toolSet] of toolSets) {
167
+ for (const [toolName, tool] of Object.entries(toolSet)) {
168
+ const existingServerName = toolSources.get(toolName);
169
+ if (existingServerName) {
170
+ throw new Error(
171
+ `MCP tool name collision: "${toolName}" is exposed by both "${existingServerName}" and "${serverName}". Rename one of the toolkit entries or expose distinct MCP tool names.`,
172
+ );
173
+ }
174
+ toolSources.set(toolName, serverName);
175
+ tools[toolName] = tool;
176
+ }
177
+ }
178
+ return tools;
179
+ }
180
+
181
+ #mcpClient(name: string, config: McpServerConfig): Promise<MCPClient> {
182
+ const existing = this.#mcpClients.get(name);
183
+ if (existing) return existing;
184
+ let next: Promise<MCPClient>;
185
+ next = createMCPClient(toMCPClientConfig(config)).catch((error) => {
186
+ if (this.#mcpClients.get(name) === next) {
187
+ this.#mcpClients.delete(name);
188
+ }
189
+ throw error;
190
+ });
191
+ this.#mcpClients.set(name, next);
192
+ return next;
193
+ }
194
+ }
195
+
196
+ const toMCPClientConfig = (config: McpServerConfig): MCPClientConfig => {
197
+ if (config.type === "stdio") {
198
+ return {
199
+ transport: new Experimental_StdioMCPTransport({
200
+ command: config.command,
201
+ ...(config.args && { args: [...config.args] }),
202
+ ...(config.env && { env: config.env }),
203
+ ...(config.cwd && { cwd: config.cwd }),
204
+ }),
205
+ };
206
+ }
207
+
208
+ return {
209
+ transport: {
210
+ type: config.type,
211
+ url: config.url,
212
+ ...(config.headers && { headers: config.headers }),
213
+ ...(config.redirect && { redirect: config.redirect }),
214
+ },
215
+ };
216
+ };
217
+
218
+ type ToolkitTool = Toolkit[string];
219
+
220
+ type McpToolkitTool = ToolkitTool & {
221
+ type: "mcp";
222
+ server: McpServerConfig;
223
+ };
224
+
225
+ const isMcpToolkitTool = (tool: ToolkitTool): tool is McpToolkitTool =>
226
+ tool.type === "mcp" && !tool.disabled;
227
+
228
+ const assertNoMcpToolkitTools = (toolkit: Toolkit): void => {
229
+ const mcpToolName = Object.entries(toolkit).find(([, tool]) =>
230
+ isMcpToolkitTool(tool),
231
+ )?.[0];
232
+ if (!mcpToolName) return;
233
+
234
+ throw new Error(
235
+ `MCP toolkit entry "${mcpToolName}" requires AISDKToolkit. Use new AISDKToolkit({ toolkit }).tools(...) instead of generativeTools(...).`,
236
+ );
237
+ };
238
+
239
+ type AISDKToModelOutputOptions<TArgs, TResult> = Omit<
240
+ Parameters<ToolModelOutputFunction<TArgs, TResult>>[0],
241
+ "output"
242
+ > & {
243
+ output: TResult | ModelContentEnvelope<TResult>;
244
+ };
245
+
246
+ const toAISDKToModelOutput =
247
+ <TArgs, TResult>(toModelOutput?: ToolModelOutputFunction<TArgs, TResult>) =>
248
+ async (options: AISDKToModelOutputOptions<TArgs, TResult>) => {
249
+ const { result, modelContent } = unwrapModelContentEnvelope(options.output);
250
+
251
+ if (modelContent !== undefined) {
252
+ return toAISDKContent(modelContent);
253
+ }
254
+
255
+ if (!toModelOutput) {
256
+ return toAISDKDefaultOutput(result);
257
+ }
258
+
259
+ const parts = await toModelOutput({
260
+ ...options,
261
+ output: result,
262
+ });
263
+ return toAISDKContent(parts);
264
+ };
265
+
266
+ const toServerToolSet = (toolkit: ToolkitDefinition): ToolSet =>
62
267
  Object.fromEntries(
63
268
  Object.entries(toolkit)
64
- .filter(([, t]) => !t.disabled)
269
+ .filter(
270
+ ([, t]) => t.type !== "mcp" && t.type !== "provider" && !t.disabled,
271
+ )
65
272
  .map(([name, t]) => {
66
273
  const execute = t.execute;
67
274
  return [
68
275
  name,
69
276
  {
70
277
  ...(t.description !== undefined && { description: t.description }),
71
- inputSchema: jsonSchema(
72
- t.parameters ? toJSONSchema(t.parameters) : EMPTY_SCHEMA,
73
- ),
74
- toModelOutput: t.toModelOutput ?? defaultToModelOutput,
278
+ inputSchema: parametersToInputSchema(t.parameters),
279
+ toModelOutput: toAISDKToModelOutput(t.toModelOutput),
75
280
  ...(t.providerOptions && { providerOptions: t.providerOptions }),
76
281
  ...(execute && {
77
282
  execute: (
@@ -88,3 +293,32 @@ const toServerToolSet = (toolkit: ToolkitDeclaration): ToolSet =>
88
293
  ];
89
294
  }),
90
295
  ) as ToolSet;
296
+
297
+ const toProviderToolSet = (toolkit: Toolkit): ToolSet =>
298
+ Object.fromEntries(
299
+ Object.entries(toolkit)
300
+ .filter((entry): entry is [string, ProviderToolkitTool] =>
301
+ isProviderToolkitTool(entry[1]),
302
+ )
303
+ .map(([name, t]) => [
304
+ name,
305
+ {
306
+ type: "provider",
307
+ id: t.providerId,
308
+ args: t.args,
309
+ ...(t.parameters && {
310
+ inputSchema: parametersToInputSchema(t.parameters),
311
+ }),
312
+ ...(t.providerOptions && { providerOptions: t.providerOptions }),
313
+ ...(t.supportsDeferredResults !== undefined && {
314
+ supportsDeferredResults: t.supportsDeferredResults,
315
+ }),
316
+ },
317
+ ]),
318
+ ) as ToolSet;
319
+
320
+ type ProviderToolkitTool = Extract<Toolkit[string], { type: "provider" }>;
321
+
322
+ const isProviderToolkitTool = (
323
+ tool: Toolkit[string],
324
+ ): tool is ProviderToolkitTool => tool.type === "provider" && !tool.disabled;
@@ -0,0 +1,3 @@
1
+ /// <reference types="@assistant-ui/core/react" />
2
+
3
+ export * from "./client";
package/src/index.ts CHANGED
@@ -1,22 +1,10 @@
1
1
  /// <reference types="@assistant-ui/core/react" />
2
2
 
3
- export { useAISDKRuntime } from "./ui/use-chat/useAISDKRuntime";
4
- export { useChatRuntime } from "./ui/use-chat/useChatRuntime";
5
- export type { UseChatRuntimeOptions } from "./ui/use-chat/useChatRuntime";
6
- export { AssistantChatTransport } from "./ui/use-chat/AssistantChatTransport";
7
- export {
8
- RESUMABLE_STREAM_ID_HEADER,
9
- createResumableSessionStorage,
10
- } from "./ui/resumable";
11
- export type {
12
- AssistantChatResumableOptions,
13
- ResumableClientStorage,
14
- } from "./ui/resumable";
15
- export { frontendTools } from "./frontendTools";
3
+ export * from "./client";
16
4
  export {
5
+ AISDKToolkit,
17
6
  generativeTools,
7
+ type AISDKToolkitOptions,
8
+ type AISDKToolkitToolsOptions,
18
9
  type GenerativeToolsOptions,
19
10
  } from "./generativeTools";
20
- export { injectQuoteContext } from "./injectQuoteContext";
21
- export type { ThreadTokenUsage, TokenUsageExtractableMessage } from "./usage";
22
- export { getThreadMessageTokenUsage, useThreadTokenUsage } from "./usage";
@@ -0,0 +1 @@
1
+ export { Experimental_StdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
@@ -0,0 +1,12 @@
1
+ import type { Experimental_StdioMCPTransport as NodeStdioMCPTransport } from "@ai-sdk/mcp/mcp-stdio";
2
+
3
+ class UnsupportedStdioMCPTransport {
4
+ constructor() {
5
+ throw new Error(
6
+ "stdio MCP transport requires a runtime that can spawn a subprocess, such as Node, Bun, or Deno (with --allow-run). Use an HTTP or SSE MCP server config in browser, React Native, edge, or worker runtimes.",
7
+ );
8
+ }
9
+ }
10
+
11
+ export const Experimental_StdioMCPTransport =
12
+ UnsupportedStdioMCPTransport as unknown as typeof NodeStdioMCPTransport;
@@ -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,
@@ -36,6 +37,12 @@ const createChatHelpers = (messages: any[] = []) => {
36
37
  return chatHelpers;
37
38
  };
38
39
 
40
+ const textOf = (message: any): string =>
41
+ message.content
42
+ .filter((part: any) => part.type === "text")
43
+ .map((part: any) => part.text)
44
+ .join("|");
45
+
39
46
  describe("useAISDKRuntime", () => {
40
47
  beforeEach(() => {
41
48
  vi.clearAllMocks();
@@ -140,6 +147,54 @@ describe("useAISDKRuntime", () => {
140
147
  expect(chat.messages[0].parts[1].state).toBe("output-available");
141
148
  });
142
149
 
150
+ it("strips stale approval when cancelling a tool pending approval so history stays valid (#4195)", async () => {
151
+ const chat = createChatHelpers([
152
+ {
153
+ id: "a1",
154
+ role: "assistant",
155
+ parts: [
156
+ {
157
+ type: "dynamic-tool",
158
+ toolName: "mcp_search",
159
+ toolCallId: "tc-1",
160
+ state: "approval-requested",
161
+ input: { q: "hi" },
162
+ approval: { id: "appr-1" },
163
+ },
164
+ ],
165
+ },
166
+ ]);
167
+
168
+ const { result } = renderHook(() => useAISDKRuntime(chat));
169
+
170
+ await waitFor(() => {
171
+ expect(result.current.thread.getState().messages.length).toBeGreaterThan(
172
+ 0,
173
+ );
174
+ });
175
+
176
+ act(() => {
177
+ result.current.thread.append({
178
+ role: "user",
179
+ content: [{ type: "text", text: "what" }],
180
+ });
181
+ });
182
+
183
+ await waitFor(() => {
184
+ expect(chat.sendMessage).toHaveBeenCalledTimes(1);
185
+ });
186
+
187
+ const part = chat.messages[0].parts[0];
188
+ expect(part.state).toBe("output-error");
189
+ // The pending-approval object must not survive into the terminal state,
190
+ // otherwise AI SDK's validateUIMessages rejects the next request.
191
+ expect(part.approval).toBeUndefined();
192
+
193
+ await expect(
194
+ validateUIMessages({ messages: chat.messages }),
195
+ ).resolves.toBeDefined();
196
+ });
197
+
143
198
  it("appends a new user message without sending when startRun is false", async () => {
144
199
  const chat = createChatHelpers([
145
200
  { id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
@@ -390,4 +445,54 @@ describe("useAISDKRuntime", () => {
390
445
  const { result } = renderHook(() => useAISDKRuntime(chat, { suggestions }));
391
446
  expect(result.current.thread.getState().suggestions).toEqual(suggestions);
392
447
  });
448
+
449
+ it("merges consecutive assistant messages into one turn by default", async () => {
450
+ const chat = createChatHelpers([
451
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
452
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
453
+ {
454
+ id: "a2",
455
+ role: "assistant",
456
+ parts: [{ type: "text", text: "second" }],
457
+ },
458
+ ]);
459
+
460
+ const { result } = renderHook(() => useAISDKRuntime(chat));
461
+
462
+ await waitFor(() => {
463
+ expect(result.current.thread.getState().messages.length).toBe(2);
464
+ });
465
+
466
+ const messages = result.current.thread.getState().messages;
467
+ expect(messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
468
+ expect(textOf(messages[1])).toBe("first|second");
469
+ });
470
+
471
+ it('keeps consecutive assistant messages separate when joinStrategy is "none"', async () => {
472
+ const chat = createChatHelpers([
473
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
474
+ { id: "a1", role: "assistant", parts: [{ type: "text", text: "first" }] },
475
+ {
476
+ id: "a2",
477
+ role: "assistant",
478
+ parts: [{ type: "text", text: "second" }],
479
+ },
480
+ ]);
481
+
482
+ const { result } = renderHook(() =>
483
+ useAISDKRuntime(chat, { joinStrategy: "none" }),
484
+ );
485
+
486
+ await waitFor(() => {
487
+ expect(result.current.thread.getState().messages.length).toBe(3);
488
+ });
489
+
490
+ const messages = result.current.thread.getState().messages;
491
+ expect(messages.map((m: any) => m.role)).toEqual([
492
+ "user",
493
+ "assistant",
494
+ "assistant",
495
+ ]);
496
+ expect(messages.slice(1).map(textOf)).toEqual(["first", "second"]);
497
+ });
393
498
  });
@@ -6,6 +6,7 @@ import { isToolUIPart, generateId } from "ai";
6
6
  import {
7
7
  useExternalStoreRuntime,
8
8
  useRuntimeAdapters,
9
+ type JoinStrategy,
9
10
  } from "@assistant-ui/core/react";
10
11
  import type { ToolExecutionStatus } from "@assistant-ui/core";
11
12
  import type {
@@ -82,6 +83,15 @@ export type AISDKRuntimeAdapter = ExternalStoreSharedOptions & {
82
83
  * (for example, an SSE reconnect endpoint keyed by turn id).
83
84
  */
84
85
  onResume?: ExternalStoreAdapter["onResume"];
86
+ /**
87
+ * How consecutive assistant messages are rendered.
88
+ *
89
+ * `"concat-content"` (the default) merges them into a single thread message.
90
+ * `"none"` keeps each assistant message as its own thread message, which is
91
+ * useful when a backend persists proactive or consecutive assistant messages
92
+ * as separate entries.
93
+ */
94
+ joinStrategy?: JoinStrategy | undefined;
85
95
  };
86
96
 
87
97
  export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
@@ -93,6 +103,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
93
103
  toCreateMessage: customToCreateMessage,
94
104
  cancelPendingToolCallsOnSend = true,
95
105
  onResume,
106
+ joinStrategy,
96
107
  } = adapter;
97
108
  const contextAdapters = useRuntimeAdapters();
98
109
  const [toolStatuses, setToolStatuses] = useState<
@@ -126,6 +137,7 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
126
137
  const messages = AISDKMessageConverter.useThreadMessages({
127
138
  isRunning,
128
139
  messages: chatHelpers.messages,
140
+ joinStrategy,
129
141
  metadata: useMemo(
130
142
  () => ({
131
143
  toolStatuses,
@@ -180,8 +192,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
180
192
  return part;
181
193
 
182
194
  hasChanges = true;
195
+ const { approval: _approval, ...rest } = part;
183
196
  return {
184
- ...part,
197
+ ...rest,
185
198
  state: "output-error" as const,
186
199
  errorText: "User cancelled tool call by sending a new message.",
187
200
  };
@@ -29,6 +29,7 @@ export type UseChatRuntimeOptions<UI_MESSAGE extends UIMessage = UIMessage> =
29
29
  adapters?: AISDKRuntimeAdapter["adapters"] | undefined;
30
30
  toCreateMessage?: CustomToCreateMessageFunction;
31
31
  onResume?: AISDKRuntimeAdapter["onResume"];
32
+ joinStrategy?: AISDKRuntimeAdapter["joinStrategy"];
32
33
  };
33
34
 
34
35
  const useDynamicChatTransport = <UI_MESSAGE extends UIMessage = UIMessage>(
@@ -78,6 +79,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
78
79
  unstable_capabilities: _unstable_capabilities,
79
80
  suggestions: _suggestions,
80
81
  onResume,
82
+ joinStrategy,
81
83
  ...chatOptions
82
84
  } = options ?? {};
83
85
  // peel guard: any shared key left in `chatOptions` collapses this to `never`
@@ -103,6 +105,7 @@ const useChatThreadRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
103
105
  ...pickExternalStoreSharedOptions(options ?? {}),
104
106
  ...(toCreateMessage && { toCreateMessage }),
105
107
  ...(onResume && { onResume }),
108
+ ...(joinStrategy && { joinStrategy }),
106
109
  });
107
110
 
108
111
  if (transport instanceof AssistantChatTransport) {
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AppendMessage } from "@assistant-ui/core";
3
+ import { toCreateMessage } from "./toCreateMessage";
4
+
5
+ const baseMessage = {
6
+ role: "user",
7
+ parentId: null,
8
+ sourceId: null,
9
+ runConfig: undefined,
10
+ metadata: undefined,
11
+ } as const;
12
+
13
+ describe("toCreateMessage", () => {
14
+ it("converts a data part in message content into a data-<name> part", () => {
15
+ const message = {
16
+ ...baseMessage,
17
+ content: [{ type: "data", name: "workflow", data: { field: 1 } }],
18
+ } as unknown as AppendMessage;
19
+
20
+ const result = toCreateMessage(message);
21
+
22
+ expect(result.parts).toEqual([
23
+ { type: "data-workflow", data: { field: 1 } },
24
+ ]);
25
+ });
26
+
27
+ it("converts a data part inside an attachment without throwing", () => {
28
+ const message = {
29
+ ...baseMessage,
30
+ content: [],
31
+ attachments: [
32
+ {
33
+ id: "some-id",
34
+ type: "document",
35
+ name: "some-name",
36
+ status: { type: "complete" },
37
+ content: [
38
+ {
39
+ type: "data",
40
+ name: "some-content-name",
41
+ data: { field: 1 },
42
+ },
43
+ ],
44
+ },
45
+ ],
46
+ } as unknown as AppendMessage;
47
+
48
+ const result = toCreateMessage(message);
49
+
50
+ expect(result.parts).toEqual([
51
+ { type: "data-some-content-name", data: { field: 1 } },
52
+ ]);
53
+ });
54
+ });