@assistant-ui/react 0.15.17 → 0.15.19

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 (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -0,0 +1,553 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { z } from "zod";
3
+ import { ToolResponse, type Tool } from "assistant-stream";
4
+ import {
5
+ defaultWebMcpFilter,
6
+ toMcpContent,
7
+ toWebMcpTool,
8
+ } from "./convertTools";
9
+
10
+ const jsonSchema = {
11
+ type: "object",
12
+ properties: { city: { type: "string" } },
13
+ required: ["city"],
14
+ } as const;
15
+
16
+ const frontendTool = (
17
+ overrides: Partial<Tool<any, any>> = {},
18
+ ): Tool<any, any> =>
19
+ ({
20
+ type: "frontend",
21
+ description: "Get the weather for a city.",
22
+ parameters: jsonSchema,
23
+ execute: async ({ city }: { city: string }) => `Sunny in ${city}`,
24
+ ...overrides,
25
+ }) as Tool<any, any>;
26
+
27
+ const descriptorFor = (
28
+ overrides: Partial<Tool<any, any>> = {},
29
+ lifecycleSignal?: AbortSignal,
30
+ ) => toWebMcpTool("t", () => frontendTool(overrides), lifecycleSignal);
31
+
32
+ const text = (value: string) => ({ type: "text", text: value });
33
+
34
+ describe("defaultWebMcpFilter", () => {
35
+ it.for([
36
+ ["exposes an enabled frontend tool", {}, true],
37
+ ["hides a backend tool", { type: "backend" }, false],
38
+ ["hides a frontend tool with no execute", { execute: undefined }, false],
39
+ ["hides a disabled frontend tool", { disabled: true }, false],
40
+ ["exposes a tool authored without a type", { type: undefined }, true],
41
+ [
42
+ "hides a type-less tool with no execute",
43
+ { type: undefined, execute: undefined },
44
+ false,
45
+ ],
46
+ ] as const)("%s", ([, overrides, expected]) => {
47
+ expect(defaultWebMcpFilter("t", frontendTool(overrides as any))).toBe(
48
+ expected,
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("toWebMcpTool descriptor", () => {
54
+ it("projects name, description, and the input schema", () => {
55
+ const descriptor = toWebMcpTool("get_weather", () => frontendTool());
56
+ expect(descriptor.name).toBe("get_weather");
57
+ expect(descriptor.description).toBe("Get the weather for a city.");
58
+ expect(descriptor.inputSchema).toEqual(jsonSchema);
59
+
60
+ expect(
61
+ descriptorFor({ parameters: z.object({ city: z.string() }) }).inputSchema,
62
+ ).toMatchObject(jsonSchema);
63
+
64
+ const bare = descriptorFor({
65
+ description: undefined,
66
+ parameters: undefined,
67
+ });
68
+ expect(bare.description).toBe("");
69
+ expect(bare.inputSchema).toEqual({ type: "object", properties: {} });
70
+ });
71
+
72
+ it("throws at construction for a schema that cannot convert", () => {
73
+ const badSchema = {
74
+ "~standard": { version: 1, validate: () => ({ issues: undefined }) },
75
+ };
76
+ expect(() => descriptorFor({ parameters: badSchema as any })).toThrow();
77
+ });
78
+ });
79
+
80
+ describe("toWebMcpTool execute", () => {
81
+ it("passes the arguments through, defaulting missing arguments to {}", async () => {
82
+ const execute = vi.fn(async () => "Sunny in Paris");
83
+ const descriptor = descriptorFor({ execute, parameters: undefined });
84
+
85
+ const result = await descriptor.execute({ city: "Paris" });
86
+ expect(execute).toHaveBeenCalledWith(
87
+ { city: "Paris" },
88
+ expect.objectContaining({ toolCallId: expect.any(String) }),
89
+ );
90
+ expect(result).toEqual({ content: [text("Sunny in Paris")] });
91
+
92
+ await descriptor.execute(undefined);
93
+ expect(execute).toHaveBeenLastCalledWith({}, expect.anything());
94
+ });
95
+
96
+ it.for([
97
+ ["serializes a non-string result", { ok: true }, '{"ok":true}'],
98
+ [
99
+ "falls back to String() for an unserializable result",
100
+ Symbol("opaque"),
101
+ "Symbol(opaque)",
102
+ ],
103
+ ] as const)("%s", async ([, value, expected]) => {
104
+ const result = await descriptorFor({
105
+ execute: async () => value,
106
+ }).execute({});
107
+ expect(result).toEqual({ content: [text(expected)] });
108
+ });
109
+
110
+ it("reports a thrown value as an error result", async () => {
111
+ const throwing = (value: unknown) =>
112
+ descriptorFor({
113
+ execute: async () => {
114
+ throw value;
115
+ },
116
+ });
117
+ await expect(throwing(new Error("boom")).execute({})).resolves.toEqual({
118
+ isError: true,
119
+ content: [text("boom")],
120
+ });
121
+ await expect(throwing("raw string boom").execute({})).resolves.toEqual({
122
+ isError: true,
123
+ content: [text("raw string boom")],
124
+ });
125
+ await expect(throwing({ code: 500 }).execute({})).resolves.toEqual({
126
+ isError: true,
127
+ content: [text("[object Object]")],
128
+ });
129
+
130
+ const circular: Record<string, unknown> = { a: 1 };
131
+ circular["self"] = circular;
132
+ const unserializable = await descriptorFor({
133
+ execute: async () => circular,
134
+ }).execute({});
135
+ expect(unserializable.isError).toBe(true);
136
+ });
137
+
138
+ it("reports an error when a published tool has no client-side execute", async () => {
139
+ await expect(
140
+ descriptorFor({ execute: undefined }).execute({}),
141
+ ).resolves.toEqual({
142
+ isError: true,
143
+ content: [text('Tool "t" has no client-side implementation.')],
144
+ });
145
+ });
146
+
147
+ it("projects values JSON cannot serialize but that have a string form", async () => {
148
+ await expect(
149
+ descriptorFor({ execute: async () => 9007199254740993n }).execute({}),
150
+ ).resolves.toEqual({ content: [text("9007199254740993")] });
151
+ await expect(
152
+ descriptorFor({ execute: async () => Symbol("ticket") }).execute({}),
153
+ ).resolves.toEqual({ content: [text("Symbol(ticket)")] });
154
+ });
155
+
156
+ it("rejects human input requests", async () => {
157
+ const result = await descriptorFor({
158
+ execute: async (_args: unknown, context: any) => await context.human(),
159
+ }).execute({});
160
+ expect(result).toEqual({
161
+ isError: true,
162
+ content: [text("human input not supported in WebMCP context")],
163
+ });
164
+ });
165
+ });
166
+
167
+ describe("toWebMcpTool schema validation", () => {
168
+ const zodTool = (overrides: Partial<Tool<any, any>> = {}) =>
169
+ descriptorFor({
170
+ parameters: z.object({ city: z.string() }),
171
+ ...overrides,
172
+ });
173
+
174
+ it("runs execute unchanged when the arguments validate", async () => {
175
+ const execute = vi.fn(async () => "ok");
176
+ const result = await zodTool({ execute }).execute({ city: "Paris" });
177
+ expect(execute).toHaveBeenCalledWith({ city: "Paris" }, expect.anything());
178
+ expect(result).toEqual({ content: [text("ok")] });
179
+ });
180
+
181
+ it("returns a validation error when the arguments do not validate", async () => {
182
+ const execute = vi.fn(async () => "ok");
183
+ const result = await zodTool({ execute }).execute({ city: 42 });
184
+ expect(execute).not.toHaveBeenCalled();
185
+ expect(result.isError).toBe(true);
186
+ expect(result.content[0]).toMatchObject({
187
+ text: expect.stringContaining("Function parameter validation failed."),
188
+ });
189
+ });
190
+
191
+ it("hands invalid arguments to experimental_onSchemaValidationError", async () => {
192
+ const execute = vi.fn(async () => "ok");
193
+ const result = await zodTool({
194
+ execute,
195
+ experimental_onSchemaValidationError: async () => "recovered",
196
+ }).execute({ city: 42 });
197
+ expect(execute).not.toHaveBeenCalled();
198
+ expect(result).toEqual({ content: [text("recovered")] });
199
+ });
200
+
201
+ it("awaits a validator that returns a non-Promise thenable", async () => {
202
+ const execute = vi.fn(async () => "ok");
203
+ const schema = z.object({ city: z.string() });
204
+ (schema as any)["~standard"] = {
205
+ ...schema["~standard"],
206
+ validate: () => ({
207
+ then: (resolve: (value: { issues: unknown[] }) => void) => {
208
+ resolve({ issues: [{ message: "cross-realm" }] });
209
+ },
210
+ }),
211
+ };
212
+
213
+ const result = await descriptorFor({ execute, parameters: schema }).execute(
214
+ { city: 42 },
215
+ );
216
+ expect(execute).not.toHaveBeenCalled();
217
+ expect(result.isError).toBe(true);
218
+ expect(result.content[0]).toMatchObject({
219
+ text: expect.stringContaining("cross-realm"),
220
+ });
221
+ });
222
+
223
+ it("awaits an async Standard Schema validation", async () => {
224
+ const execute = vi.fn(async () => "ok");
225
+ const schema = z.object({ city: z.string() });
226
+ const sync = schema["~standard"].validate;
227
+ (schema as any)["~standard"] = {
228
+ ...schema["~standard"],
229
+ validate: async (value: unknown) => sync(value),
230
+ };
231
+
232
+ const result = await descriptorFor({ execute, parameters: schema }).execute(
233
+ { city: 42 },
234
+ );
235
+ expect(execute).not.toHaveBeenCalled();
236
+ expect(result.isError).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe("toWebMcpTool cancellation", () => {
241
+ it("refuses to run once its lifecycle signal is aborted", async () => {
242
+ const execute = vi.fn(async () => "never");
243
+ const controller = new AbortController();
244
+ controller.abort();
245
+ const result = await descriptorFor({ execute }, controller.signal).execute(
246
+ {},
247
+ );
248
+ expect(execute).not.toHaveBeenCalled();
249
+ expect(result).toEqual({
250
+ isError: true,
251
+ content: [text('Tool "t" is no longer registered')],
252
+ });
253
+ });
254
+
255
+ it("refuses to run when the caller signal is already aborted", async () => {
256
+ const execute = vi.fn(async () => "never");
257
+ const controller = new AbortController();
258
+ controller.abort();
259
+ const result = await descriptorFor({ execute }).execute(
260
+ {},
261
+ { signal: controller.signal },
262
+ );
263
+ expect(execute).not.toHaveBeenCalled();
264
+ expect(result).toEqual({
265
+ isError: true,
266
+ content: [text("Tool execution was cancelled.")],
267
+ });
268
+ });
269
+
270
+ it.for(["caller", "lifecycle"] as const)(
271
+ "settles while async validation is pending when the %s signal aborts",
272
+ async (abortedSignal) => {
273
+ const lifecycle = new AbortController();
274
+ const caller = new AbortController();
275
+ let finishValidation!: (result: { issues?: readonly unknown[] }) => void;
276
+ const schema = z.object({ city: z.string() });
277
+ (schema as any)["~standard"] = {
278
+ ...schema["~standard"],
279
+ validate: () =>
280
+ new Promise<{ issues?: readonly unknown[] }>((resolve) => {
281
+ finishValidation = resolve;
282
+ }),
283
+ };
284
+ const execute = vi.fn(async () => "never");
285
+ const pending = descriptorFor(
286
+ { execute, parameters: schema },
287
+ lifecycle.signal,
288
+ ).execute({ city: "Paris" }, { signal: caller.signal });
289
+
290
+ (abortedSignal === "caller" ? caller : lifecycle).abort();
291
+
292
+ await expect(pending).resolves.toEqual({
293
+ isError: true,
294
+ content: [text("Tool execution was cancelled.")],
295
+ });
296
+ expect(execute).not.toHaveBeenCalled();
297
+ finishValidation({});
298
+ },
299
+ );
300
+
301
+ it("consumes a validator rejection after cancellation", async () => {
302
+ const caller = new AbortController();
303
+ let failValidation!: (error: unknown) => void;
304
+ const schema = z.object({ city: z.string() });
305
+ (schema as any)["~standard"] = {
306
+ ...schema["~standard"],
307
+ validate: () =>
308
+ new Promise((_resolve, reject) => {
309
+ failValidation = reject;
310
+ }),
311
+ };
312
+ const execute = vi.fn(async () => "never");
313
+ const pending = descriptorFor({ execute, parameters: schema }).execute(
314
+ { city: "Paris" },
315
+ { signal: caller.signal },
316
+ );
317
+
318
+ caller.abort();
319
+
320
+ await expect(pending).resolves.toEqual({
321
+ isError: true,
322
+ content: [text("Tool execution was cancelled.")],
323
+ });
324
+ failValidation(new Error("late validation failure"));
325
+ await new Promise((resolve) => setTimeout(resolve, 0));
326
+ expect(execute).not.toHaveBeenCalled();
327
+ });
328
+
329
+ it("prefers cancellation when validation aborts before rejecting", async () => {
330
+ const caller = new AbortController();
331
+ const schema = z.object({ city: z.string() });
332
+ (schema as any)["~standard"] = {
333
+ ...schema["~standard"],
334
+ validate: () => {
335
+ caller.abort();
336
+ return Promise.reject(new Error("validation failed"));
337
+ },
338
+ };
339
+ const execute = vi.fn(async () => "never");
340
+
341
+ const result = await descriptorFor({ execute, parameters: schema }).execute(
342
+ { city: "Paris" },
343
+ { signal: caller.signal },
344
+ );
345
+
346
+ expect(result).toEqual({
347
+ isError: true,
348
+ content: [text("Tool execution was cancelled.")],
349
+ });
350
+ expect(execute).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it("does not execute when cancellation follows validation", async () => {
354
+ const caller = new AbortController();
355
+ const schema = z.object({ city: z.string() });
356
+ (schema as any)["~standard"] = {
357
+ ...schema["~standard"],
358
+ validate: () => ({
359
+ then: (resolve: (value: { issues?: readonly unknown[] }) => void) => {
360
+ resolve({});
361
+ caller.abort();
362
+ },
363
+ }),
364
+ };
365
+ const execute = vi.fn(async () => "never");
366
+
367
+ const result = await descriptorFor({ execute, parameters: schema }).execute(
368
+ { city: "Paris" },
369
+ { signal: caller.signal },
370
+ );
371
+
372
+ expect(result).toEqual({
373
+ isError: true,
374
+ content: [text("Tool execution was cancelled.")],
375
+ });
376
+ expect(execute).not.toHaveBeenCalled();
377
+ });
378
+
379
+ it.for(["caller", "lifecycle"] as const)(
380
+ "merges signals without AbortSignal.any when the %s signal aborts",
381
+ async (abortedSignal) => {
382
+ const lifecycle = new AbortController();
383
+ const caller = new AbortController();
384
+ const abortSignalConstructor = AbortSignal as typeof AbortSignal & {
385
+ any?: (signals: Iterable<AbortSignal>) => AbortSignal;
386
+ };
387
+ const originalAbortSignalAny = abortSignalConstructor.any;
388
+ Object.defineProperty(abortSignalConstructor, "any", {
389
+ configurable: true,
390
+ value: () => {
391
+ throw new Error("AbortSignal.any is not available");
392
+ },
393
+ });
394
+ try {
395
+ const descriptor = descriptorFor(
396
+ {
397
+ execute: async (_args: unknown, context: any) =>
398
+ new Promise((_resolve, reject) => {
399
+ context.abortSignal.addEventListener("abort", () =>
400
+ reject(new Error("aborted")),
401
+ );
402
+ }),
403
+ },
404
+ lifecycle.signal,
405
+ );
406
+
407
+ const pending = descriptor.execute({}, { signal: caller.signal });
408
+ (abortedSignal === "caller" ? caller : lifecycle).abort();
409
+ await expect(pending).resolves.toEqual({
410
+ isError: true,
411
+ content: [text("aborted")],
412
+ });
413
+ } finally {
414
+ Object.defineProperty(abortSignalConstructor, "any", {
415
+ configurable: true,
416
+ value: originalAbortSignalAny,
417
+ });
418
+ }
419
+ },
420
+ );
421
+
422
+ it("removes merged signal listeners after execution", async () => {
423
+ const lifecycle = new AbortController();
424
+ const caller = new AbortController();
425
+ const callerRemove = vi.spyOn(caller.signal, "removeEventListener");
426
+ const lifecycleRemove = vi.spyOn(lifecycle.signal, "removeEventListener");
427
+
428
+ const result = await descriptorFor(
429
+ { execute: async () => "ok" },
430
+ lifecycle.signal,
431
+ ).execute({}, { signal: caller.signal });
432
+
433
+ expect(result).toEqual({ content: [text("ok")] });
434
+ expect(callerRemove).toHaveBeenCalledTimes(1);
435
+ expect(lifecycleRemove).toHaveBeenCalledTimes(1);
436
+ });
437
+
438
+ it("merges a caller signal that is not a native AbortSignal", async () => {
439
+ const listeners: (() => void)[] = [];
440
+ const foreignSignal = {
441
+ aborted: false,
442
+ reason: new Error("host cancelled"),
443
+ addEventListener: (_type: string, listener: () => void) => {
444
+ listeners.push(listener);
445
+ },
446
+ removeEventListener: () => {},
447
+ } as unknown as AbortSignal;
448
+
449
+ const pending = descriptorFor(
450
+ {
451
+ execute: async (_args: unknown, context: any) =>
452
+ new Promise((_resolve, reject) => {
453
+ context.abortSignal.addEventListener("abort", () =>
454
+ reject(new Error("aborted")),
455
+ );
456
+ }),
457
+ },
458
+ new AbortController().signal,
459
+ ).execute({}, { signal: foreignSignal });
460
+
461
+ for (const listener of listeners) listener();
462
+
463
+ await expect(pending).resolves.toEqual({
464
+ isError: true,
465
+ content: [text("aborted")],
466
+ });
467
+ });
468
+ });
469
+
470
+ describe("toMcpContent", () => {
471
+ const options = { tool: frontendTool(), toolCallId: "1", args: {} };
472
+
473
+ it.for([
474
+ ["text parts", [{ type: "text", text: "hello" }], [text("hello")]],
475
+ [
476
+ "image file parts",
477
+ [{ type: "file", data: "AAA", mediaType: "image/png" }],
478
+ [{ type: "image", data: "AAA", mimeType: "image/png" }],
479
+ ],
480
+ [
481
+ "image file parts with no data",
482
+ [{ type: "file", mediaType: "image/png" }],
483
+ [{ type: "image", data: "", mimeType: "image/png" }],
484
+ ],
485
+ [
486
+ "non-image file parts",
487
+ [{ type: "file", data: "raw", mediaType: "text/plain" }],
488
+ [text("raw")],
489
+ ],
490
+ [
491
+ "unknown parts",
492
+ [{ type: "reasoning", text: "why" }],
493
+ [text('{"type":"reasoning","text":"why"}')],
494
+ ],
495
+ ] as const)("maps %s", async ([, modelContent, expected]) => {
496
+ const response = await toMcpContent(
497
+ new ToolResponse({ result: "r", modelContent: modelContent as any }),
498
+ options,
499
+ );
500
+ expect(response).toEqual({ content: expected });
501
+ });
502
+
503
+ it("marks an error response, with or without an explicit modelContent", async () => {
504
+ await expect(
505
+ toMcpContent(
506
+ new ToolResponse({
507
+ result: "r",
508
+ isError: true,
509
+ modelContent: [{ type: "text", text: "failed" }],
510
+ }),
511
+ options,
512
+ ),
513
+ ).resolves.toEqual({ isError: true, content: [text("failed")] });
514
+
515
+ await expect(
516
+ toMcpContent(
517
+ new ToolResponse({ result: "went wrong", isError: true }),
518
+ options,
519
+ ),
520
+ ).resolves.toEqual({ isError: true, content: [text("went wrong")] });
521
+ });
522
+
523
+ it("projects a successful result through toModelOutput", async () => {
524
+ const toModelOutput = vi.fn(async () => [
525
+ { type: "text", text: "projected" },
526
+ ]);
527
+ const response = await toMcpContent("raw", {
528
+ ...options,
529
+ tool: frontendTool({ toModelOutput } as any),
530
+ });
531
+ expect(toModelOutput).toHaveBeenCalledWith({
532
+ toolCallId: "1",
533
+ input: {},
534
+ output: "raw",
535
+ });
536
+ expect(response).toEqual({ content: [text("projected")] });
537
+ });
538
+
539
+ it("falls back to the default projection when toModelOutput throws", async () => {
540
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
541
+ const response = await toMcpContent("raw", {
542
+ ...options,
543
+ tool: frontendTool({
544
+ toModelOutput: () => {
545
+ throw new Error("bad projection");
546
+ },
547
+ } as any),
548
+ });
549
+ expect(response).toEqual({ content: [text("raw")] });
550
+ expect(warn).toHaveBeenCalled();
551
+ warn.mockRestore();
552
+ });
553
+ });