@assistant-ui/react-ai-sdk 1.3.31 → 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.
- package/dist/assistant-stream/dist/core/tool/schema-utils.js +5 -2
- package/dist/assistant-stream/dist/core/tool/schema-utils.js.map +1 -1
- package/dist/assistant-stream/dist/core/tool/tool-types.d.ts.map +1 -1
- package/dist/frontendTools.d.ts +1 -1
- package/dist/frontendTools.d.ts.map +1 -1
- package/dist/frontendTools.js +3 -27
- package/dist/frontendTools.js.map +1 -1
- package/dist/generativeTools.d.ts +19 -3
- package/dist/generativeTools.d.ts.map +1 -1
- package/dist/generativeTools.js +108 -10
- package/dist/generativeTools.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/modelContentEnvelope.d.ts +3 -3
- package/dist/modelContentEnvelope.d.ts.map +1 -1
- package/dist/modelContentEnvelope.js.map +1 -1
- package/dist/toolOutputConversion.d.ts +34 -0
- package/dist/toolOutputConversion.d.ts.map +1 -0
- package/dist/toolOutputConversion.js +31 -0
- package/dist/toolOutputConversion.js.map +1 -0
- package/dist/ui/use-chat/useAISDKRuntime.js +2 -1
- package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
- package/dist/ui/utils/toCreateMessage.js +4 -0
- package/dist/ui/utils/toCreateMessage.js.map +1 -1
- package/package.json +5 -4
- package/src/frontendTools.ts +4 -27
- package/src/generativeTools.test.ts +407 -0
- package/src/generativeTools.ts +239 -19
- package/src/index.ts +3 -0
- package/src/modelContentEnvelope.ts +7 -5
- package/src/toolOutputConversion.ts +29 -0
- package/src/ui/use-chat/useAISDKRuntime.test.ts +49 -0
- package/src/ui/use-chat/useAISDKRuntime.ts +2 -1
- package/src/ui/utils/toCreateMessage.test.ts +54 -0
- package/src/ui/utils/toCreateMessage.ts +5 -0
package/src/generativeTools.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { jsonSchema, type ToolSet } from "ai";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
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";
|
|
5
19
|
|
|
6
20
|
const EMPTY_SCHEMA = { type: "object" as const, properties: {} };
|
|
7
21
|
|
|
@@ -14,11 +28,14 @@ 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
|
+
|
|
17
34
|
export interface GenerativeToolsOptions {
|
|
18
35
|
/**
|
|
19
36
|
* The server build of a generative toolkit (schema + server `execute`). Typed
|
|
20
37
|
* as the canonical {@link Toolkit} so callers don't need to cast; the server
|
|
21
|
-
* build carries `execute`, recovered internally as {@link
|
|
38
|
+
* build carries `execute`, recovered internally as {@link ToolkitDefinition}.
|
|
22
39
|
*/
|
|
23
40
|
toolkit: Toolkit;
|
|
24
41
|
/**
|
|
@@ -29,6 +46,17 @@ export interface GenerativeToolsOptions {
|
|
|
29
46
|
frontendTools?: Record<string, ToolJSONSchema>;
|
|
30
47
|
}
|
|
31
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
|
+
|
|
32
60
|
/**
|
|
33
61
|
* Builds an AI SDK `ToolSet` for server-side use with `streamText` /
|
|
34
62
|
* `generateText` from a generative `toolkit` and the frontend-uploaded tools.
|
|
@@ -38,7 +66,8 @@ export interface GenerativeToolsOptions {
|
|
|
38
66
|
* resolves to the server build — schema + `execute`, with `render` stripped) and
|
|
39
67
|
* pass it here. Tools without an `execute` are still exposed to the model but
|
|
40
68
|
* left for the client to fulfill. `frontendTools` lets the client contribute
|
|
41
|
-
* tools that aren't in the static toolkit.
|
|
69
|
+
* tools that aren't in the static toolkit. Use {@link AISDKToolkit} when the
|
|
70
|
+
* toolkit contains MCP entries.
|
|
42
71
|
*
|
|
43
72
|
* @example
|
|
44
73
|
* ```ts
|
|
@@ -50,28 +79,190 @@ export interface GenerativeToolsOptions {
|
|
|
50
79
|
* });
|
|
51
80
|
* ```
|
|
52
81
|
*/
|
|
53
|
-
export const generativeTools = (options: GenerativeToolsOptions): ToolSet =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 =>
|
|
62
253
|
Object.fromEntries(
|
|
63
254
|
Object.entries(toolkit)
|
|
64
|
-
.filter(
|
|
255
|
+
.filter(
|
|
256
|
+
([, t]) => t.type !== "mcp" && t.type !== "provider" && !t.disabled,
|
|
257
|
+
)
|
|
65
258
|
.map(([name, t]) => {
|
|
66
259
|
const execute = t.execute;
|
|
67
260
|
return [
|
|
68
261
|
name,
|
|
69
262
|
{
|
|
70
263
|
...(t.description !== undefined && { description: t.description }),
|
|
71
|
-
inputSchema:
|
|
72
|
-
|
|
73
|
-
),
|
|
74
|
-
toModelOutput: t.toModelOutput ?? defaultToModelOutput,
|
|
264
|
+
inputSchema: parametersToInputSchema(t.parameters),
|
|
265
|
+
toModelOutput: toAISDKToModelOutput(t.toModelOutput),
|
|
75
266
|
...(t.providerOptions && { providerOptions: t.providerOptions }),
|
|
76
267
|
...(execute && {
|
|
77
268
|
execute: (
|
|
@@ -88,3 +279,32 @@ const toServerToolSet = (toolkit: ToolkitDeclaration): ToolSet =>
|
|
|
88
279
|
];
|
|
89
280
|
}),
|
|
90
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
|
@@ -14,7 +14,10 @@ export type {
|
|
|
14
14
|
} from "./ui/resumable";
|
|
15
15
|
export { frontendTools } from "./frontendTools";
|
|
16
16
|
export {
|
|
17
|
+
AISDKToolkit,
|
|
17
18
|
generativeTools,
|
|
19
|
+
type AISDKToolkitOptions,
|
|
20
|
+
type AISDKToolkitToolsOptions,
|
|
18
21
|
type GenerativeToolsOptions,
|
|
19
22
|
} from "./generativeTools";
|
|
20
23
|
export { injectQuoteContext } from "./injectQuoteContext";
|
|
@@ -7,9 +7,9 @@ export type ModelContentEnvelope<TResult = unknown> = {
|
|
|
7
7
|
readonly value: TResult;
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
export function isModelContentEnvelope(
|
|
11
|
-
value:
|
|
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(
|
|
29
|
-
|
|
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" }] },
|
|
@@ -180,8 +180,9 @@ export const useAISDKRuntime = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
180
180
|
return part;
|
|
181
181
|
|
|
182
182
|
hasChanges = true;
|
|
183
|
+
const { approval: _approval, ...rest } = part;
|
|
183
184
|
return {
|
|
184
|
-
...
|
|
185
|
+
...rest,
|
|
185
186
|
state: "output-error" as const,
|
|
186
187
|
errorText: "User cancelled tool call by sending a new message.",
|
|
187
188
|
};
|
|
@@ -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
|
+
});
|
|
@@ -41,6 +41,11 @@ export const toCreateMessage = <UI_MESSAGE extends UIMessage = UIMessage>(
|
|
|
41
41
|
mediaType: part.mimeType,
|
|
42
42
|
...(part.filename && { filename: part.filename }),
|
|
43
43
|
};
|
|
44
|
+
case "data":
|
|
45
|
+
return {
|
|
46
|
+
type: `data-${part.name}`,
|
|
47
|
+
data: part.data,
|
|
48
|
+
};
|
|
44
49
|
default:
|
|
45
50
|
throw new Error(`Unsupported part type: ${part.type}`);
|
|
46
51
|
}
|