@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.
Files changed (35) 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/frontendTools.d.ts +1 -1
  5. package/dist/frontendTools.d.ts.map +1 -1
  6. package/dist/frontendTools.js +3 -27
  7. package/dist/frontendTools.js.map +1 -1
  8. package/dist/generativeTools.d.ts +19 -3
  9. package/dist/generativeTools.d.ts.map +1 -1
  10. package/dist/generativeTools.js +108 -10
  11. package/dist/generativeTools.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +2 -2
  14. package/dist/modelContentEnvelope.d.ts +3 -3
  15. package/dist/modelContentEnvelope.d.ts.map +1 -1
  16. package/dist/modelContentEnvelope.js.map +1 -1
  17. package/dist/toolOutputConversion.d.ts +34 -0
  18. package/dist/toolOutputConversion.d.ts.map +1 -0
  19. package/dist/toolOutputConversion.js +31 -0
  20. package/dist/toolOutputConversion.js.map +1 -0
  21. package/dist/ui/use-chat/useAISDKRuntime.js +2 -1
  22. package/dist/ui/use-chat/useAISDKRuntime.js.map +1 -1
  23. package/dist/ui/utils/toCreateMessage.js +4 -0
  24. package/dist/ui/utils/toCreateMessage.js.map +1 -1
  25. package/package.json +5 -4
  26. package/src/frontendTools.ts +4 -27
  27. package/src/generativeTools.test.ts +407 -0
  28. package/src/generativeTools.ts +239 -19
  29. package/src/index.ts +3 -0
  30. package/src/modelContentEnvelope.ts +7 -5
  31. package/src/toolOutputConversion.ts +29 -0
  32. package/src/ui/use-chat/useAISDKRuntime.test.ts +49 -0
  33. package/src/ui/use-chat/useAISDKRuntime.ts +2 -1
  34. package/src/ui/utils/toCreateMessage.test.ts +54 -0
  35. package/src/ui/utils/toCreateMessage.ts +5 -0
@@ -21,6 +21,10 @@ const toCreateMessage = (message) => {
21
21
  mediaType: part.mimeType,
22
22
  ...part.filename && { filename: part.filename }
23
23
  };
24
+ case "data": return {
25
+ type: `data-${part.name}`,
26
+ data: part.data
27
+ };
24
28
  default: throw new Error(`Unsupported part type: ${part.type}`);
25
29
  }
26
30
  });
@@ -1 +1 @@
1
- {"version":3,"file":"toCreateMessage.js","names":[],"sources":["../../../src/ui/utils/toCreateMessage.ts"],"sourcesContent":["import type { AppendMessage } from \"@assistant-ui/core\";\nimport type {\n CreateUIMessage,\n UIDataTypes,\n UIMessage,\n UIMessagePart,\n UITools,\n} from \"ai\";\n\nexport const toCreateMessage = <UI_MESSAGE extends UIMessage = UIMessage>(\n message: AppendMessage,\n): CreateUIMessage<UI_MESSAGE> => {\n const inputParts = [\n ...message.content.filter((c) => c.type !== \"file\"),\n ...(message.attachments?.flatMap((a) =>\n a.content.map((c) => ({\n ...c,\n filename: a.name,\n })),\n ) ?? []),\n ];\n\n const parts = inputParts.map((part): UIMessagePart<UIDataTypes, UITools> => {\n switch (part.type) {\n case \"text\":\n return {\n type: \"text\",\n text: part.text,\n };\n case \"image\":\n return {\n type: \"file\",\n url: part.image,\n ...(part.filename && { filename: part.filename }),\n mediaType: \"image/png\",\n };\n case \"file\":\n return {\n type: \"file\",\n url: part.data,\n mediaType: part.mimeType,\n ...(part.filename && { filename: part.filename }),\n };\n default:\n throw new Error(`Unsupported part type: ${part.type}`);\n }\n });\n\n return {\n role: message.role,\n parts,\n metadata: message.metadata,\n } satisfies CreateUIMessage<UIMessage> as CreateUIMessage<UI_MESSAGE>;\n};\n"],"mappings":";AASA,MAAa,mBACX,YACgC;CAWhC,MAAM,QAAQ,CATZ,GAAG,QAAQ,QAAQ,QAAQ,MAAM,EAAE,SAAS,MAAM,GAClD,GAAI,QAAQ,aAAa,SAAS,MAChC,EAAE,QAAQ,KAAK,OAAO;EACpB,GAAG;EACH,UAAU,EAAE;CACd,EAAE,CACJ,KAAK,CAAC,CAGe,EAAE,KAAK,SAA8C;EAC1E,QAAQ,KAAK,MAAb;GACE,KAAK,QACH,OAAO;IACL,MAAM;IACN,MAAM,KAAK;GACb;GACF,KAAK,SACH,OAAO;IACL,MAAM;IACN,KAAK,KAAK;IACV,GAAI,KAAK,YAAY,EAAE,UAAU,KAAK,SAAS;IAC/C,WAAW;GACb;GACF,KAAK,QACH,OAAO;IACL,MAAM;IACN,KAAK,KAAK;IACV,WAAW,KAAK;IAChB,GAAI,KAAK,YAAY,EAAE,UAAU,KAAK,SAAS;GACjD;GACF,SACE,MAAM,IAAI,MAAM,0BAA0B,KAAK,MAAM;EACzD;CACF,CAAC;CAED,OAAO;EACL,MAAM,QAAQ;EACd;EACA,UAAU,QAAQ;CACpB;AACF"}
1
+ {"version":3,"file":"toCreateMessage.js","names":[],"sources":["../../../src/ui/utils/toCreateMessage.ts"],"sourcesContent":["import type { AppendMessage } from \"@assistant-ui/core\";\nimport type {\n CreateUIMessage,\n UIDataTypes,\n UIMessage,\n UIMessagePart,\n UITools,\n} from \"ai\";\n\nexport const toCreateMessage = <UI_MESSAGE extends UIMessage = UIMessage>(\n message: AppendMessage,\n): CreateUIMessage<UI_MESSAGE> => {\n const inputParts = [\n ...message.content.filter((c) => c.type !== \"file\"),\n ...(message.attachments?.flatMap((a) =>\n a.content.map((c) => ({\n ...c,\n filename: a.name,\n })),\n ) ?? []),\n ];\n\n const parts = inputParts.map((part): UIMessagePart<UIDataTypes, UITools> => {\n switch (part.type) {\n case \"text\":\n return {\n type: \"text\",\n text: part.text,\n };\n case \"image\":\n return {\n type: \"file\",\n url: part.image,\n ...(part.filename && { filename: part.filename }),\n mediaType: \"image/png\",\n };\n case \"file\":\n return {\n type: \"file\",\n url: part.data,\n mediaType: part.mimeType,\n ...(part.filename && { filename: part.filename }),\n };\n case \"data\":\n return {\n type: `data-${part.name}`,\n data: part.data,\n };\n default:\n throw new Error(`Unsupported part type: ${part.type}`);\n }\n });\n\n return {\n role: message.role,\n parts,\n metadata: message.metadata,\n } satisfies CreateUIMessage<UIMessage> as CreateUIMessage<UI_MESSAGE>;\n};\n"],"mappings":";AASA,MAAa,mBACX,YACgC;CAWhC,MAAM,QAAQ,CATZ,GAAG,QAAQ,QAAQ,QAAQ,MAAM,EAAE,SAAS,MAAM,GAClD,GAAI,QAAQ,aAAa,SAAS,MAChC,EAAE,QAAQ,KAAK,OAAO;EACpB,GAAG;EACH,UAAU,EAAE;CACd,EAAE,CACJ,KAAK,CAAC,CAGe,EAAE,KAAK,SAA8C;EAC1E,QAAQ,KAAK,MAAb;GACE,KAAK,QACH,OAAO;IACL,MAAM;IACN,MAAM,KAAK;GACb;GACF,KAAK,SACH,OAAO;IACL,MAAM;IACN,KAAK,KAAK;IACV,GAAI,KAAK,YAAY,EAAE,UAAU,KAAK,SAAS;IAC/C,WAAW;GACb;GACF,KAAK,QACH,OAAO;IACL,MAAM;IACN,KAAK,KAAK;IACV,WAAW,KAAK;IAChB,GAAI,KAAK,YAAY,EAAE,UAAU,KAAK,SAAS;GACjD;GACF,KAAK,QACH,OAAO;IACL,MAAM,QAAQ,KAAK;IACnB,MAAM,KAAK;GACb;GACF,SACE,MAAM,IAAI,MAAM,0BAA0B,KAAK,MAAM;EACzD;CACF,CAAC;CAED,OAAO;EACL,MAAM,QAAQ;EACd;EACA,UAAU,QAAQ;CACpB;AACF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-ai-sdk",
3
- "version": "1.3.31",
3
+ "version": "1.3.32",
4
4
  "description": "Vercel AI SDK adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "ai-sdk",
@@ -30,8 +30,9 @@
30
30
  ],
31
31
  "sideEffects": false,
32
32
  "dependencies": {
33
+ "@ai-sdk/mcp": "^1.0.45",
33
34
  "@ai-sdk/react": "^3.0.195",
34
- "@assistant-ui/core": "^0.2.8",
35
+ "@assistant-ui/core": "^0.2.10",
35
36
  "@assistant-ui/store": "^0.2.13",
36
37
  "ai": "^6.0.193",
37
38
  "assistant-cloud": "*"
@@ -53,8 +54,8 @@
53
54
  "jsdom": "^29.1.1",
54
55
  "react": "^19.2.6",
55
56
  "vitest": "^4.1.7",
56
- "@assistant-ui/x-buildutils": "0.0.10",
57
- "assistant-stream": "0.3.18"
57
+ "assistant-stream": "0.3.20",
58
+ "@assistant-ui/x-buildutils": "0.0.11"
58
59
  },
59
60
  "publishConfig": {
60
61
  "access": "public",
@@ -1,37 +1,14 @@
1
1
  import { jsonSchema, type ToolSet } from "ai";
2
- import type { ToolJSONSchema, ToolModelContentPart } from "assistant-stream";
2
+ import type { ToolJSONSchema } from "assistant-stream";
3
3
  import { unwrapModelContentEnvelope } from "./modelContentEnvelope";
4
-
5
- const toAISDKContent = (parts: readonly ToolModelContentPart[]) => ({
6
- type: "content" as const,
7
- value: parts.map((part) => {
8
- if (part.type === "text") {
9
- return { type: "text" as const, text: part.text };
10
- }
11
- const isImage = part.mediaType.startsWith("image/");
12
- return isImage
13
- ? {
14
- type: "image-data" as const,
15
- data: part.data,
16
- mediaType: part.mediaType,
17
- }
18
- : {
19
- type: "file-data" as const,
20
- data: part.data,
21
- mediaType: part.mediaType,
22
- ...(part.filename !== undefined && { filename: part.filename }),
23
- };
24
- }),
25
- });
4
+ import { toAISDKContent, toAISDKDefaultOutput } from "./toolOutputConversion";
26
5
 
27
6
  export const defaultToModelOutput = ({ output }: { output: unknown }) => {
28
- const { modelContent } = unwrapModelContentEnvelope(output);
7
+ const { result, modelContent } = unwrapModelContentEnvelope(output);
29
8
  if (modelContent !== undefined) {
30
9
  return toAISDKContent(modelContent);
31
10
  }
32
- return typeof output === "string"
33
- ? { type: "text" as const, value: output }
34
- : { type: "json" as const, value: (output ?? null) as any };
11
+ return toAISDKDefaultOutput(result);
35
12
  };
36
13
 
37
14
  export const frontendTools = (tools: Record<string, ToolJSONSchema>): ToolSet =>
@@ -0,0 +1,407 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AISDKToolkit, generativeTools } from "./generativeTools";
3
+ import { wrapModelContentEnvelope } from "./modelContentEnvelope";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ close: vi.fn(),
7
+ tools: vi.fn(),
8
+ createMCPClient: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@ai-sdk/mcp", () => ({
12
+ createMCPClient: mocks.createMCPClient,
13
+ }));
14
+
15
+ vi.mock("@ai-sdk/mcp/mcp-stdio", () => ({
16
+ Experimental_StdioMCPTransport: vi.fn((config) => ({
17
+ type: "stdio",
18
+ config,
19
+ })),
20
+ }));
21
+
22
+ describe("generativeTools", () => {
23
+ beforeEach(() => {
24
+ mocks.close.mockReset();
25
+ mocks.tools.mockReset();
26
+ mocks.createMCPClient.mockReset();
27
+ });
28
+
29
+ it("merges frontend tools with toolkit tools", () => {
30
+ const toolSet = generativeTools({
31
+ frontendTools: {
32
+ clientTool: {
33
+ parameters: { type: "object", properties: {} },
34
+ },
35
+ },
36
+ toolkit: {
37
+ serverTool: {
38
+ type: "backend",
39
+ description: "Server tool",
40
+ parameters: { type: "object", properties: {} },
41
+ execute: async () => "ok",
42
+ } as never,
43
+ },
44
+ });
45
+
46
+ expect(toolSet.clientTool).toBeDefined();
47
+ expect(toolSet.serverTool?.description).toBe("Server tool");
48
+ expect(toolSet.serverTool?.execute).toBeTypeOf("function");
49
+ });
50
+
51
+ it("keeps a flat toolkit tool named tools", () => {
52
+ const toolSet = generativeTools({
53
+ toolkit: {
54
+ tools: {
55
+ type: "backend",
56
+ description: "Actually a tool, not config",
57
+ parameters: { type: "object", properties: {} },
58
+ execute: async () => "ok",
59
+ } as never,
60
+ },
61
+ });
62
+
63
+ expect(toolSet.tools?.description).toBe("Actually a tool, not config");
64
+ expect(toolSet.tools?.execute).toBeTypeOf("function");
65
+ });
66
+
67
+ it("rejects MCP entries because they require pooled clients", () => {
68
+ expect(() =>
69
+ generativeTools({
70
+ toolkit: {
71
+ docs: {
72
+ type: "mcp",
73
+ server: { type: "http", url: "http://localhost:3001/mcp" },
74
+ },
75
+ },
76
+ }),
77
+ ).toThrow(/requires AISDKToolkit/);
78
+ });
79
+
80
+ it("converts provider tools without an execute function", () => {
81
+ const toolSet = generativeTools({
82
+ toolkit: {
83
+ web_search: {
84
+ type: "provider",
85
+ providerId: "openai.web_search_preview",
86
+ args: { searchContextSize: "low" },
87
+ },
88
+ },
89
+ });
90
+
91
+ expect(toolSet.web_search).toMatchObject({
92
+ type: "provider",
93
+ id: "openai.web_search_preview",
94
+ args: { searchContextSize: "low" },
95
+ });
96
+ expect(toolSet.web_search).not.toHaveProperty("inputSchema");
97
+ expect(toolSet.web_search).not.toHaveProperty("execute");
98
+ });
99
+
100
+ it("forwards provider tool parameters and providerOptions when present", () => {
101
+ const toolSet = generativeTools({
102
+ toolkit: {
103
+ web_search: {
104
+ type: "provider",
105
+ providerId: "openai.web_search_preview",
106
+ args: { searchContextSize: "low" },
107
+ parameters: {
108
+ type: "object",
109
+ properties: {
110
+ query: { type: "string" },
111
+ },
112
+ required: ["query"],
113
+ },
114
+ providerOptions: {
115
+ openai: { rankingOptions: { scoreThreshold: 0.5 } },
116
+ },
117
+ },
118
+ },
119
+ });
120
+
121
+ expect(toolSet.web_search).toMatchObject({
122
+ type: "provider",
123
+ id: "openai.web_search_preview",
124
+ args: { searchContextSize: "low" },
125
+ providerOptions: {
126
+ openai: { rankingOptions: { scoreThreshold: 0.5 } },
127
+ },
128
+ });
129
+ expect(toolSet.web_search).toHaveProperty("inputSchema");
130
+ });
131
+
132
+ it("forwards explicit false supportsDeferredResults", () => {
133
+ const toolSet = generativeTools({
134
+ toolkit: {
135
+ web_search: {
136
+ type: "provider",
137
+ providerId: "openai.web_search_preview",
138
+ args: { searchContextSize: "low" },
139
+ supportsDeferredResults: false,
140
+ },
141
+ },
142
+ });
143
+
144
+ expect(toolSet.web_search).toMatchObject({
145
+ supportsDeferredResults: false,
146
+ });
147
+ });
148
+ });
149
+
150
+ describe("AISDKToolkit", () => {
151
+ beforeEach(() => {
152
+ mocks.close.mockReset();
153
+ mocks.tools.mockReset();
154
+ mocks.createMCPClient.mockReset();
155
+ });
156
+
157
+ it("loads MCP tools through pooled clients", async () => {
158
+ mocks.tools.mockResolvedValue({ echo: { inputSchema: {} } });
159
+ mocks.createMCPClient.mockResolvedValue({
160
+ tools: mocks.tools,
161
+ close: mocks.close,
162
+ });
163
+
164
+ const toolkit = new AISDKToolkit({
165
+ toolkit: {
166
+ local: {
167
+ type: "mcp",
168
+ server: { type: "http", url: "http://localhost:3001/mcp" },
169
+ },
170
+ },
171
+ });
172
+
173
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
174
+ await toolkit.tools();
175
+
176
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(1);
177
+ expect(mocks.createMCPClient).toHaveBeenCalledWith({
178
+ transport: {
179
+ type: "http",
180
+ url: "http://localhost:3001/mcp",
181
+ },
182
+ });
183
+ expect(mocks.tools).toHaveBeenCalledTimes(2);
184
+ });
185
+
186
+ it("closes pooled MCP clients", async () => {
187
+ mocks.tools.mockResolvedValue({});
188
+ mocks.createMCPClient.mockResolvedValue({
189
+ tools: mocks.tools,
190
+ close: mocks.close,
191
+ });
192
+
193
+ const toolkit = new AISDKToolkit({
194
+ toolkit: {
195
+ local: {
196
+ type: "mcp",
197
+ server: { type: "sse", url: "http://localhost:3001/sse" },
198
+ },
199
+ },
200
+ });
201
+
202
+ await toolkit.tools();
203
+ await toolkit.close();
204
+
205
+ expect(mocks.close).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it("clears pooled MCP clients even when initialization fails", async () => {
209
+ const error = new Error("connect failed");
210
+ const closeError = new Error("close failed");
211
+ const close = vi.fn().mockRejectedValue(closeError);
212
+ mocks.tools.mockResolvedValue({});
213
+ mocks.createMCPClient
214
+ .mockResolvedValueOnce({
215
+ tools: mocks.tools,
216
+ close,
217
+ })
218
+ .mockRejectedValueOnce(error);
219
+
220
+ const toolkit = new AISDKToolkit({
221
+ toolkit: {
222
+ first: {
223
+ type: "mcp",
224
+ server: { type: "http", url: "http://localhost:3001/mcp" },
225
+ },
226
+ second: {
227
+ type: "mcp",
228
+ server: { type: "http", url: "http://localhost:3002/mcp" },
229
+ },
230
+ },
231
+ });
232
+
233
+ const toolsPromise = toolkit.tools();
234
+ await expect(toolkit.close()).rejects.toMatchObject({
235
+ errors: [error, closeError],
236
+ });
237
+ await expect(toolsPromise).rejects.toThrow(error);
238
+ expect(close).toHaveBeenCalledTimes(1);
239
+
240
+ await expect(toolkit.close()).resolves.toBeUndefined();
241
+ });
242
+
243
+ it("evicts failed MCP client initialization so later calls can retry", async () => {
244
+ const error = new Error("connect failed");
245
+ mocks.createMCPClient.mockRejectedValueOnce(error).mockResolvedValueOnce({
246
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
247
+ close: mocks.close,
248
+ });
249
+
250
+ const toolkit = new AISDKToolkit({
251
+ toolkit: {
252
+ local: {
253
+ type: "mcp",
254
+ server: { type: "http", url: "http://localhost:3001/mcp" },
255
+ },
256
+ },
257
+ });
258
+
259
+ await expect(toolkit.tools()).rejects.toThrow(error);
260
+ await expect(toolkit.tools()).resolves.toHaveProperty("echo");
261
+ expect(mocks.createMCPClient).toHaveBeenCalledTimes(2);
262
+ });
263
+
264
+ it("rejects duplicate MCP tool names", async () => {
265
+ mocks.createMCPClient
266
+ .mockResolvedValueOnce({
267
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
268
+ close: mocks.close,
269
+ })
270
+ .mockResolvedValueOnce({
271
+ tools: vi.fn().mockResolvedValue({ echo: { inputSchema: {} } }),
272
+ close: mocks.close,
273
+ });
274
+
275
+ const toolkit = new AISDKToolkit({
276
+ toolkit: {
277
+ first: {
278
+ type: "mcp",
279
+ server: { type: "http", url: "http://localhost:3001/mcp" },
280
+ },
281
+ second: {
282
+ type: "mcp",
283
+ server: { type: "http", url: "http://localhost:3002/mcp" },
284
+ },
285
+ },
286
+ });
287
+
288
+ await expect(toolkit.tools()).rejects.toThrow(
289
+ /MCP tool name collision: "echo"/,
290
+ );
291
+ });
292
+
293
+ it("includes provider tools alongside MCP tools", async () => {
294
+ mocks.tools.mockResolvedValue({ echo: { inputSchema: {} } });
295
+ mocks.createMCPClient.mockResolvedValue({
296
+ tools: mocks.tools,
297
+ close: mocks.close,
298
+ });
299
+
300
+ const toolkit = new AISDKToolkit({
301
+ toolkit: {
302
+ local: {
303
+ type: "mcp",
304
+ server: { type: "http", url: "http://localhost:3001/mcp" },
305
+ },
306
+ web_search: {
307
+ type: "provider",
308
+ providerId: "openai.web_search_preview",
309
+ args: { searchContextSize: "low" },
310
+ supportsDeferredResults: false,
311
+ },
312
+ },
313
+ });
314
+
315
+ await expect(toolkit.tools()).resolves.toMatchObject({
316
+ echo: { inputSchema: {} },
317
+ web_search: {
318
+ type: "provider",
319
+ id: "openai.web_search_preview",
320
+ args: { searchContextSize: "low" },
321
+ supportsDeferredResults: false,
322
+ },
323
+ });
324
+ });
325
+ });
326
+
327
+ describe("generativeTools toModelOutput", () => {
328
+ const createWeatherTools = (toModelOutput?: any) =>
329
+ generativeTools({
330
+ toolkit: {
331
+ get_weather: {
332
+ ...(toModelOutput && { toModelOutput }),
333
+ },
334
+ } as any,
335
+ });
336
+
337
+ it("adapts assistant-ui model content parts to the AI SDK tool output shape", async () => {
338
+ const tools = createWeatherTools(({ output }: any) => [
339
+ { type: "text", text: `Weather card displayed: ${output.location}` },
340
+ ]);
341
+
342
+ const output = await tools.get_weather!.toModelOutput!({
343
+ toolCallId: "tc-weather",
344
+ input: {},
345
+ output: { location: "San Francisco" },
346
+ });
347
+
348
+ expect(output).toEqual({
349
+ type: "content",
350
+ value: [{ type: "text", text: "Weather card displayed: San Francisco" }],
351
+ });
352
+ });
353
+
354
+ it("uses stored model content envelopes without re-running the custom projector", async () => {
355
+ let called = false;
356
+ const tools = createWeatherTools(() => {
357
+ called = true;
358
+ return [{ type: "text", text: "recomputed" }];
359
+ });
360
+
361
+ const output = await tools.get_weather!.toModelOutput!({
362
+ toolCallId: "tc-weather",
363
+ input: {},
364
+ output: wrapModelContentEnvelope({ location: "San Francisco" }, [
365
+ { type: "text", text: "cached weather receipt" },
366
+ ]),
367
+ });
368
+
369
+ expect(called).toBe(false);
370
+ expect(output).toEqual({
371
+ type: "content",
372
+ value: [{ type: "text", text: "cached weather receipt" }],
373
+ });
374
+ });
375
+
376
+ it("falls back to default model output when no custom projector is defined", async () => {
377
+ const tools = createWeatherTools();
378
+
379
+ const output = await tools.get_weather!.toModelOutput!({
380
+ toolCallId: "tc-weather",
381
+ input: {},
382
+ output: { location: "San Francisco" },
383
+ });
384
+
385
+ expect(output).toEqual({
386
+ type: "json",
387
+ value: { location: "San Francisco" },
388
+ });
389
+ });
390
+
391
+ it("uses stored model content envelopes when no custom projector is defined", async () => {
392
+ const tools = createWeatherTools();
393
+
394
+ const output = await tools.get_weather!.toModelOutput!({
395
+ toolCallId: "tc-weather",
396
+ input: {},
397
+ output: wrapModelContentEnvelope({ location: "San Francisco" }, [
398
+ { type: "text", text: "cached weather receipt" },
399
+ ]),
400
+ });
401
+
402
+ expect(output).toEqual({
403
+ type: "content",
404
+ value: [{ type: "text", text: "cached weather receipt" }],
405
+ });
406
+ });
407
+ });