@assistant-ui/react 0.15.18 → 0.15.20

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 (113) hide show
  1. package/README.md +7 -1
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  5. package/dist/context/stores/ThreadViewport.js +2 -1
  6. package/dist/context/stores/ThreadViewport.js.map +1 -1
  7. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  8. package/dist/index.d.ts +5 -5
  9. package/dist/index.js +3 -3
  10. package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.d.ts +2 -2
  11. package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.js +2 -2
  12. package/dist/mcp-apps/McpAppRenderer.js +1 -1
  13. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  14. package/dist/mcp-apps/app-frame.js +18 -13
  15. package/dist/mcp-apps/app-frame.js.map +1 -1
  16. package/dist/mcp-apps/bridge.js +7 -1
  17. package/dist/mcp-apps/bridge.js.map +1 -1
  18. package/dist/model-context/frame/useAssistantFrameHost.js +20 -2
  19. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  20. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  21. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  22. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  23. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  24. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  25. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  26. package/dist/primitives/composer/ComposerInput.js +3 -8
  27. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  28. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  29. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  30. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  31. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  32. package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
  33. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  34. package/dist/primitives/message/MessageParts.js +2 -14
  35. package/dist/primitives/message/MessageParts.js.map +1 -1
  36. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  37. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  38. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  39. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  40. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  41. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  42. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  43. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  44. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  45. package/dist/sandbox-host/SandboxHost.js +23 -5
  46. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  47. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  48. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  49. package/dist/unstable/useMentionAdapter.js +2 -2
  50. package/dist/unstable/useMentionAdapter.js.map +1 -1
  51. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  52. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  53. package/dist/unstable/useMessageStallDetection.js +16 -15
  54. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  55. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
  56. package/dist/unstable/webmcp/convertTools.js +54 -3
  57. package/dist/unstable/webmcp/convertTools.js.map +1 -1
  58. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  59. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
  60. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  61. package/dist/utils/Primitive.d.ts +10 -2
  62. package/dist/utils/Primitive.d.ts.map +1 -1
  63. package/dist/utils/Primitive.js +25 -6
  64. package/dist/utils/Primitive.js.map +1 -1
  65. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  66. package/dist/utils/getSelectionMessageId.js +17 -3
  67. package/dist/utils/getSelectionMessageId.js.map +1 -1
  68. package/package.json +12 -12
  69. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  70. package/src/context/stores/ThreadViewport.test.ts +29 -0
  71. package/src/context/stores/ThreadViewport.ts +6 -3
  72. package/src/hooks/useToolCallElapsed.ts +3 -0
  73. package/src/index.ts +6 -1
  74. package/src/legacy-runtime/runtime-cores/external-store/external-message-converter.ts +2 -0
  75. package/src/mcp-apps/app-frame.test.tsx +189 -1
  76. package/src/mcp-apps/app-frame.tsx +31 -22
  77. package/src/mcp-apps/bridge.test.ts +98 -0
  78. package/src/mcp-apps/bridge.ts +7 -1
  79. package/src/model-context/frame/useAssistantFrameHost.test.tsx +42 -0
  80. package/src/model-context/frame/useAssistantFrameHost.ts +25 -2
  81. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  82. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  83. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  84. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  85. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  86. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  87. package/src/primitives/composer/ComposerInput.tsx +5 -9
  88. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  89. package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
  90. package/src/primitives/message/MessageParts.tsx +1 -21
  91. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  92. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  93. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  94. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  95. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  96. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  97. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  98. package/src/sandbox-host/SandboxHost.test.tsx +40 -0
  99. package/src/sandbox-host/SandboxHost.tsx +24 -4
  100. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  101. package/src/tests/toolCallTiming.test.tsx +55 -1
  102. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  103. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  104. package/src/unstable/useMentionAdapter.test.tsx +47 -1
  105. package/src/unstable/useMentionAdapter.ts +2 -2
  106. package/src/unstable/useMessageStallDetection.ts +26 -19
  107. package/src/unstable/webmcp/convertTools.test.ts +181 -21
  108. package/src/unstable/webmcp/convertTools.ts +87 -7
  109. package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
  110. package/src/utils/Primitive.test.tsx +46 -1
  111. package/src/utils/Primitive.tsx +37 -7
  112. package/src/utils/getSelectionMessageId.test.ts +176 -1
  113. package/src/utils/getSelectionMessageId.ts +21 -3
@@ -7,6 +7,7 @@ import type {
7
7
  SandboxHostProps,
8
8
  } from "../sandbox-host/SandboxHost";
9
9
  import type { CreateMcpAppBridgeOptions, McpAppBridge } from "./bridge";
10
+ import type * as BridgeModule from "./bridge";
10
11
  import { MCP_APP_MIME_TYPE, type McpAppHostContext } from "./types";
11
12
 
12
13
  const { sandboxHostMock, createMcpAppBridgeMock } = vi.hoisted(() => ({
@@ -28,7 +29,6 @@ import { McpAppFrame } from "./app-frame";
28
29
  describe("McpAppFrame", () => {
29
30
  afterEach(() => {
30
31
  cleanup();
31
- vi.clearAllMocks();
32
32
  });
33
33
 
34
34
  it("keeps bridge options scoped to committed renders", async () => {
@@ -100,6 +100,178 @@ describe("McpAppFrame", () => {
100
100
  sandboxBridge.dispose();
101
101
  });
102
102
 
103
+ it.each([
104
+ { initial: [], next: ["search"], allowed: true },
105
+ { initial: ["search"], next: [], allowed: false },
106
+ { initial: undefined, next: ["other"], allowed: false },
107
+ { initial: [], next: undefined, allowed: true },
108
+ ])(
109
+ "applies replacement tool allowlists: $initial -> $next",
110
+ async ({ initial, next, allowed }) => {
111
+ const { createMcpAppBridge } =
112
+ await vi.importActual<typeof BridgeModule>("./bridge");
113
+ createMcpAppBridgeMock.mockImplementationOnce(createMcpAppBridge);
114
+ const captured: { createBridge?: SandboxHostProps["createBridge"] } = {};
115
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
116
+ captured.createBridge ??= props.createBridge;
117
+ return null;
118
+ });
119
+ const callTool = vi.fn(() => ({ content: [] }));
120
+ const view = (allowedTools: readonly string[] | undefined) => (
121
+ <McpAppFrame
122
+ app={{ resourceUri: "ui://example/widget" }}
123
+ resource={{
124
+ uri: "ui://example/widget",
125
+ mimeType: MCP_APP_MIME_TYPE,
126
+ html: "",
127
+ }}
128
+ handlers={
129
+ allowedTools === undefined
130
+ ? { callTool }
131
+ : { allowedTools, callTool }
132
+ }
133
+ />
134
+ );
135
+ const rendered = render(view(initial));
136
+ if (!captured.createBridge) throw new Error("Frame did not mount");
137
+ const sendMessage = vi.fn();
138
+ const bridge = captured.createBridge(
139
+ {
140
+ iframe: document.createElement("iframe"),
141
+ origin: "https://widget.example",
142
+ sendMessage,
143
+ },
144
+ { setHeight: vi.fn() },
145
+ );
146
+ try {
147
+ rendered.rerender(view(next));
148
+ bridge.onMessage(
149
+ new MessageEvent("message", {
150
+ data: {
151
+ jsonrpc: "2.0",
152
+ id: 1,
153
+ method: "tools/call",
154
+ params: { name: "search" },
155
+ },
156
+ }),
157
+ );
158
+ await Promise.resolve();
159
+ expect(sendMessage).toHaveBeenCalledWith(
160
+ expect.objectContaining(
161
+ allowed
162
+ ? { id: 1, result: { content: [] } }
163
+ : { id: 1, error: expect.objectContaining({ code: -32602 }) },
164
+ ),
165
+ );
166
+ expect(callTool).toHaveBeenCalledTimes(allowed ? 1 : 0);
167
+ } finally {
168
+ bridge.dispose();
169
+ }
170
+ },
171
+ );
172
+
173
+ it("forwards size changes to the sandbox host and live handler", () => {
174
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
175
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
176
+ createBridge ??= props.createBridge;
177
+ return null;
178
+ });
179
+ const bridge: McpAppBridge = {
180
+ onMessage: vi.fn(),
181
+ dispose: vi.fn(),
182
+ notifyToolInput: vi.fn(),
183
+ notifyToolResult: vi.fn(),
184
+ notifyHostContextChanged: vi.fn(),
185
+ };
186
+ createMcpAppBridgeMock.mockReturnValue(bridge);
187
+ const initialOnSizeChange = vi.fn();
188
+ const replacementOnSizeChange = vi.fn();
189
+ const view = (onSizeChange: typeof initialOnSizeChange) => (
190
+ <McpAppFrame
191
+ app={{ resourceUri: "ui://example/widget" }}
192
+ resource={{
193
+ uri: "ui://example/widget",
194
+ mimeType: MCP_APP_MIME_TYPE,
195
+ html: "",
196
+ }}
197
+ handlers={{ onSizeChange }}
198
+ />
199
+ );
200
+ const rendered = render(view(initialOnSizeChange));
201
+
202
+ const setHeight = vi.fn();
203
+ const sandboxBridge = createBridge!(
204
+ {
205
+ iframe: document.createElement("iframe"),
206
+ origin: "https://widget.example",
207
+ sendMessage: vi.fn(),
208
+ },
209
+ { setHeight },
210
+ );
211
+ const options = createMcpAppBridgeMock.mock
212
+ .calls[0]![0] as CreateMcpAppBridgeOptions;
213
+
214
+ try {
215
+ const initialSize = { width: 640, height: 360 };
216
+ options.handlers?.onSizeChange?.(initialSize);
217
+ expect(setHeight).toHaveBeenCalledWith(360);
218
+ expect(initialOnSizeChange).toHaveBeenCalledWith(initialSize);
219
+
220
+ rendered.rerender(view(replacementOnSizeChange));
221
+ const replacementSize = { width: 800, height: 480 };
222
+ options.handlers?.onSizeChange?.(replacementSize);
223
+ expect(setHeight).toHaveBeenNthCalledWith(2, 480);
224
+ expect(initialOnSizeChange).toHaveBeenCalledOnce();
225
+ expect(replacementOnSizeChange).toHaveBeenCalledOnce();
226
+ expect(replacementOnSizeChange).toHaveBeenCalledWith(replacementSize);
227
+
228
+ const widthOnlySize = { width: 720 };
229
+ options.handlers?.onSizeChange?.(widthOnlySize);
230
+ expect(setHeight).toHaveBeenCalledTimes(2);
231
+ expect(replacementOnSizeChange).toHaveBeenCalledWith(widthOnlySize);
232
+ } finally {
233
+ sandboxBridge.dispose();
234
+ }
235
+ });
236
+
237
+ it("disposes the underlying bridge with the sandbox bridge", () => {
238
+ let createBridge: SandboxHostProps["createBridge"] | null = null;
239
+ sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
240
+ createBridge ??= props.createBridge;
241
+ return null;
242
+ });
243
+ const bridge: McpAppBridge = {
244
+ onMessage: vi.fn(),
245
+ dispose: vi.fn(),
246
+ notifyToolInput: vi.fn(),
247
+ notifyToolResult: vi.fn(),
248
+ notifyHostContextChanged: vi.fn(),
249
+ };
250
+ createMcpAppBridgeMock.mockReturnValue(bridge);
251
+ render(
252
+ <McpAppFrame
253
+ app={{ resourceUri: "ui://example/widget" }}
254
+ resource={{
255
+ uri: "ui://example/widget",
256
+ mimeType: MCP_APP_MIME_TYPE,
257
+ html: "",
258
+ }}
259
+ />,
260
+ );
261
+
262
+ const sandboxBridge = createBridge!(
263
+ {
264
+ iframe: document.createElement("iframe"),
265
+ origin: "https://widget.example",
266
+ sendMessage: vi.fn(),
267
+ },
268
+ { setHeight: vi.fn() },
269
+ );
270
+ sandboxBridge.dispose();
271
+
272
+ expect(bridge.dispose).toHaveBeenCalledOnce();
273
+ });
274
+
103
275
  it("only notifies the widget when host context actually changes", () => {
104
276
  let createBridge: SandboxHostProps["createBridge"] | null = null;
105
277
  sandboxHostMock.mockImplementation((props: SandboxHostProps) => {
@@ -159,6 +331,22 @@ describe("McpAppFrame", () => {
159
331
  availableDisplayModes: ["inline", "pip"],
160
332
  });
161
333
 
334
+ // A hole is not a value, in either operand order.
335
+ const sparseModes: ("inline" | "pip")[] = new Array(2);
336
+ sparseModes[1] = "pip";
337
+ rendered.rerender(
338
+ view({ displayMode: "fullscreen", availableDisplayModes: sparseModes }),
339
+ );
340
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(2);
341
+
342
+ rendered.rerender(
343
+ view({
344
+ displayMode: "fullscreen",
345
+ availableDisplayModes: ["inline", "pip"],
346
+ }),
347
+ );
348
+ expect(bridge.notifyHostContextChanged).toHaveBeenCalledTimes(3);
349
+
162
350
  sandboxBridge.dispose();
163
351
  });
164
352
  it("cancels a queued host context that returns to the delivered value", () => {
@@ -42,12 +42,12 @@ const isSameHostContext = (a: unknown, b: unknown, depth = 0): boolean => {
42
42
  if (Object.is(a, b)) return true;
43
43
  if (depth > 100) return false;
44
44
  if (Array.isArray(a) || Array.isArray(b)) {
45
- return (
46
- Array.isArray(a) &&
47
- Array.isArray(b) &&
48
- a.length === b.length &&
49
- a.every((item, index) => isSameHostContext(item, b[index], depth + 1))
50
- );
45
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
46
+ return false;
47
+ for (let i = 0; i < a.length; i++) {
48
+ if (!isSameHostContext(a[i], b[i], depth + 1)) return false;
49
+ }
50
+ return true;
51
51
  }
52
52
  if (!isPlainObject(a) || !isPlainObject(b)) return false;
53
53
  const aKeys = Object.keys(a);
@@ -96,25 +96,33 @@ type LiveSnapshot = {
96
96
  output: unknown;
97
97
  };
98
98
 
99
+ type FrameLifecycle = {
100
+ onInitialized: () => void;
101
+ onSizeChange: (params: { width?: number; height?: number }) => void;
102
+ };
103
+
99
104
  // Proxy each per-call handler through liveRef so the bridge always dispatches
100
105
  // to the latest handler reference (e.g. inline callbacks closing over state).
101
106
  // Capability presence is snapshot at mount: a handler added later requires a
102
107
  // remount (keyed on resource URI) to expose the capability to the widget.
108
+ // allowedTools is the exception: it is never advertised in the ui/initialize
109
+ // response, so it stays a live getter, which means this object must reach the
110
+ // bridge uncopied and the frame passes its lifecycle work in rather than
111
+ // wrapping the result.
103
112
  function buildLiveHandlers(
104
113
  initial: McpAppBridgeHandlers | undefined,
105
114
  liveRef: { readonly current: LiveSnapshot },
115
+ lifecycle: FrameLifecycle,
106
116
  ): McpAppBridgeHandlers {
107
117
  const live = () => liveRef.current.handlers;
108
118
  const has = <K extends keyof McpAppBridgeHandlers>(key: K) =>
109
119
  initial?.[key] !== undefined;
110
120
  const out: McpAppBridgeHandlers = {};
111
- if (has("allowedTools")) {
112
- Object.defineProperty(out, "allowedTools", {
113
- get: () => live()?.allowedTools,
114
- enumerable: true,
115
- configurable: true,
116
- });
117
- }
121
+ Object.defineProperty(out, "allowedTools", {
122
+ get: () => live()?.allowedTools,
123
+ enumerable: true,
124
+ configurable: true,
125
+ });
118
126
  const liveCall = <K extends keyof McpAppBridgeHandlers>(
119
127
  key: K,
120
128
  ): NonNullable<McpAppBridgeHandlers[K]> =>
@@ -134,8 +142,14 @@ function buildLiveHandlers(
134
142
  out.updateModelContext = liveCall("updateModelContext");
135
143
  if (has("requestDisplayMode"))
136
144
  out.requestDisplayMode = liveCall("requestDisplayMode");
137
- out.onSizeChange = (p) => live()?.onSizeChange?.(p);
138
- out.onInitialized = () => live()?.onInitialized?.();
145
+ out.onSizeChange = (p) => {
146
+ lifecycle.onSizeChange(p);
147
+ live()?.onSizeChange?.(p);
148
+ };
149
+ out.onInitialized = () => {
150
+ lifecycle.onInitialized();
151
+ live()?.onInitialized?.();
152
+ };
139
153
  out.onRequestTeardown = (p) => live()?.onRequestTeardown?.(p);
140
154
  out.onLog = (p) => live()?.onLog?.(p);
141
155
  out.onError = (e) => live()?.onError?.(e);
@@ -215,23 +229,18 @@ export function McpAppFrame({
215
229
  }
216
230
  };
217
231
 
218
- const liveHandlers = buildLiveHandlers(current.handlers, liveRef);
219
- const liveOnInitialized = liveHandlers.onInitialized;
220
- const wrappedHandlers: McpAppBridgeHandlers = {
221
- ...liveHandlers,
232
+ const wrappedHandlers = buildLiveHandlers(current.handlers, liveRef, {
222
233
  onInitialized: () => {
223
234
  if (initTimeoutId !== null) {
224
235
  clearTimeout(initTimeoutId);
225
236
  initTimeoutId = null;
226
237
  }
227
238
  flushPending();
228
- liveOnInitialized?.();
229
239
  },
230
240
  onSizeChange: (p) => {
231
241
  if (p.height != null) host.setHeight(p.height);
232
- liveHandlers.onSizeChange?.(p);
233
242
  },
234
- };
243
+ });
235
244
 
236
245
  // Safety net: if the widget never sends notifications/initialized (broken
237
246
  // or non-spec-compliant), flush the queue anyway so the host doesn't
@@ -37,6 +37,104 @@ async function flush() {
37
37
  }
38
38
 
39
39
  describe("createMcpAppBridge", () => {
40
+ it("ignores requests and notifications after disposal", async () => {
41
+ const { frame, captured } = makeFrame();
42
+ const callTool = vi.fn().mockResolvedValue({ ok: true });
43
+ const onInitialized = vi.fn();
44
+ const bridge = createMcpAppBridge({
45
+ frame,
46
+ handlers: { callTool, onInitialized },
47
+ });
48
+
49
+ bridge.dispose();
50
+ expect(() => bridge.dispose()).not.toThrow();
51
+
52
+ deliver(bridge, {
53
+ jsonrpc: "2.0",
54
+ id: 1,
55
+ method: "tools/call",
56
+ params: { name: "search" },
57
+ });
58
+ deliver(bridge, {
59
+ jsonrpc: "2.0",
60
+ method: "notifications/initialized",
61
+ });
62
+ await flush();
63
+
64
+ expect(callTool).not.toHaveBeenCalled();
65
+ expect(onInitialized).not.toHaveBeenCalled();
66
+ expect(captured).toEqual([]);
67
+ });
68
+
69
+ it("does not send a response after disposal", async () => {
70
+ const { frame, captured } = makeFrame();
71
+ let resolveCall!: (value: unknown) => void;
72
+ const callTool = vi.fn(
73
+ () =>
74
+ new Promise((resolve) => {
75
+ resolveCall = resolve;
76
+ }),
77
+ );
78
+ const bridge = createMcpAppBridge({ frame, handlers: { callTool } });
79
+
80
+ deliver(bridge, {
81
+ jsonrpc: "2.0",
82
+ id: 1,
83
+ method: "tools/call",
84
+ params: { name: "search" },
85
+ });
86
+ expect(callTool).toHaveBeenCalledOnce();
87
+
88
+ bridge.dispose();
89
+ resolveCall({ ok: true });
90
+ await flush();
91
+
92
+ expect(captured).toEqual([]);
93
+ });
94
+
95
+ it("does not report an async error after disposal", async () => {
96
+ const { frame, captured } = makeFrame();
97
+ let rejectCall!: (reason?: unknown) => void;
98
+ const callTool = vi.fn(
99
+ () =>
100
+ new Promise((_resolve, reject) => {
101
+ rejectCall = reject;
102
+ }),
103
+ );
104
+ const onError = vi.fn();
105
+ const bridge = createMcpAppBridge({
106
+ frame,
107
+ handlers: { callTool, onError },
108
+ });
109
+
110
+ deliver(bridge, {
111
+ jsonrpc: "2.0",
112
+ id: 1,
113
+ method: "tools/call",
114
+ params: { name: "search" },
115
+ });
116
+ expect(callTool).toHaveBeenCalledOnce();
117
+
118
+ bridge.dispose();
119
+ rejectCall(new Error("tool failed"));
120
+ await flush();
121
+
122
+ expect(onError).not.toHaveBeenCalled();
123
+ expect(captured).toEqual([]);
124
+ });
125
+
126
+ it("does not send host notifications after disposal", () => {
127
+ const { frame, captured } = makeFrame();
128
+ const bridge = createMcpAppBridge({ frame });
129
+
130
+ bridge.dispose();
131
+ bridge.notifyToolInput({ query: "hello" });
132
+ bridge.notifyToolResult({ answer: "world" });
133
+ bridge.notifyHostContextChanged({ theme: "dark" });
134
+
135
+ expect(captured).toEqual([]);
136
+ });
137
+
40
138
  it("responds to ui/initialize with host info, version, and capabilities", async () => {
41
139
  const { frame, captured } = makeFrame();
42
140
  const bridge = createMcpAppBridge({
@@ -90,8 +90,10 @@ export function createMcpAppBridge(
90
90
  hostInfo = DEFAULT_HOST_INFO,
91
91
  hostContext = {},
92
92
  } = opts;
93
+ let disposed = false;
93
94
 
94
95
  const post = (msg: McpAppJsonRpcMessage) => {
96
+ if (disposed) return;
95
97
  frame.sendMessage(msg);
96
98
  };
97
99
 
@@ -125,6 +127,7 @@ export function createMcpAppBridge(
125
127
  };
126
128
 
127
129
  const reportError = (error: Error) => {
130
+ if (disposed) return;
128
131
  invokeUserCallback(
129
132
  "assistant-ui",
130
133
  "MCP App onError",
@@ -435,6 +438,7 @@ export function createMcpAppBridge(
435
438
  // The host applies the cross-origin guard before delegating; this only
436
439
  // validates the JSON-RPC envelope.
437
440
  const onMessage = (event: MessageEvent) => {
441
+ if (disposed) return;
438
442
  if (!isJsonRpcMessage(event.data)) return;
439
443
 
440
444
  const msg = event.data;
@@ -447,7 +451,9 @@ export function createMcpAppBridge(
447
451
 
448
452
  return {
449
453
  onMessage,
450
- dispose: () => {},
454
+ dispose: () => {
455
+ disposed = true;
456
+ },
451
457
  notifyToolInput: (input: unknown) => {
452
458
  post({
453
459
  jsonrpc: "2.0",
@@ -0,0 +1,42 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { AssistantFrameHost } from "@assistant-ui/core";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { useAssistantFrameHost } from "./useAssistantFrameHost";
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe("useAssistantFrameHost", () => {
13
+ it("unregisters the host when disposal throws", () => {
14
+ const disposalError = new Error("tool cancellation failed");
15
+ const unregistrationError = new Error("unregistration failed");
16
+ vi.spyOn(AssistantFrameHost.prototype, "dispose").mockImplementation(() => {
17
+ throw disposalError;
18
+ });
19
+ const consoleError = vi
20
+ .spyOn(console, "error")
21
+ .mockImplementation(() => undefined);
22
+ const unsubscribe = vi.fn(() => {
23
+ throw unregistrationError;
24
+ });
25
+ const register = vi.fn(() => unsubscribe);
26
+ const iframeRef = {
27
+ current: {
28
+ contentWindow: { postMessage: vi.fn() } as unknown as Window,
29
+ } as HTMLIFrameElement,
30
+ };
31
+ const { unmount } = renderHook(() =>
32
+ useAssistantFrameHost({ iframeRef, register }),
33
+ );
34
+
35
+ expect(() => unmount()).toThrow(disposalError);
36
+ expect(unsubscribe).toHaveBeenCalledOnce();
37
+ expect(consoleError).toHaveBeenCalledWith(
38
+ "[assistant-ui] AssistantFrameHost unregistration failed.",
39
+ unregistrationError,
40
+ );
41
+ });
42
+ });
@@ -41,8 +41,31 @@ export const useAssistantFrameHost = ({
41
41
  const unsubscribe = register(frameHost);
42
42
 
43
43
  return () => {
44
- frameHost.dispose();
45
- unsubscribe();
44
+ let cleanupFailed = false;
45
+ let cleanupError: unknown;
46
+
47
+ try {
48
+ frameHost.dispose();
49
+ } catch (error) {
50
+ cleanupFailed = true;
51
+ cleanupError = error;
52
+ }
53
+
54
+ try {
55
+ unsubscribe();
56
+ } catch (error) {
57
+ if (cleanupFailed) {
58
+ console.error(
59
+ "[assistant-ui] AssistantFrameHost unregistration failed.",
60
+ error,
61
+ );
62
+ } else {
63
+ cleanupFailed = true;
64
+ cleanupError = error;
65
+ }
66
+ }
67
+
68
+ if (cleanupFailed) throw cleanupError;
46
69
  };
47
70
  }, [iframeRef, targetOrigin, register]);
48
71
  };
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ act,
5
+ cleanup,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ } from "@testing-library/react";
10
+ import { useState } from "react";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { AssistantRuntimeProvider } from "../../context";
13
+ import { useLocalRuntime } from "../../legacy-runtime/runtime-cores/local/useLocalRuntime";
14
+ import { AssistantModalPrimitiveContent } from "./AssistantModalContent";
15
+ import { AssistantModalPrimitiveRoot } from "./AssistantModalRoot";
16
+ import { AssistantModalPrimitiveTrigger } from "./AssistantModalTrigger";
17
+
18
+ const adapter = {
19
+ async *run() {
20
+ yield { content: [{ type: "text" as const, text: "Hello" }] };
21
+ },
22
+ };
23
+
24
+ const Modal = ({
25
+ controlled = true,
26
+ onOpenChange,
27
+ ...props
28
+ }: AssistantModalPrimitiveRoot.Props & { controlled?: boolean }) => {
29
+ const runtime = useLocalRuntime(adapter);
30
+ const [open, setOpen] = useState(false);
31
+ return (
32
+ <AssistantRuntimeProvider runtime={runtime}>
33
+ <button onClick={() => runtime.thread.append("Hello")}>Start run</button>
34
+ <AssistantModalPrimitiveRoot
35
+ {...(controlled ? { open } : {})}
36
+ onOpenChange={(value) => {
37
+ setOpen(value);
38
+ onOpenChange?.(value);
39
+ }}
40
+ {...props}
41
+ >
42
+ <AssistantModalPrimitiveTrigger>
43
+ Toggle chat
44
+ </AssistantModalPrimitiveTrigger>
45
+ <AssistantModalPrimitiveContent aria-label="Chat">
46
+ Response
47
+ </AssistantModalPrimitiveContent>
48
+ </AssistantModalPrimitiveRoot>
49
+ </AssistantRuntimeProvider>
50
+ );
51
+ };
52
+
53
+ afterEach(cleanup);
54
+
55
+ describe("AssistantModalPrimitiveRoot run start", () => {
56
+ it.each([true, false])(
57
+ "opens on run start and closes through the trigger (controlled: %s)",
58
+ async (controlled) => {
59
+ const onOpenChange = vi.fn();
60
+ render(<Modal controlled={controlled} onOpenChange={onOpenChange} />);
61
+ expect(screen.queryByRole("dialog")).toBeNull();
62
+
63
+ await act(async () => {
64
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
65
+ });
66
+ expect(await screen.findByRole("dialog", { name: "Chat" })).toBeDefined();
67
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
68
+
69
+ await act(async () => {
70
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
71
+ });
72
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
73
+
74
+ fireEvent.click(screen.getByRole("button", { name: "Toggle chat" }));
75
+ expect(screen.queryByRole("dialog")).toBeNull();
76
+ },
77
+ );
78
+
79
+ it.each([{ open: true }, { controlled: false, defaultOpen: true }])(
80
+ "does not request opening when already open (%o)",
81
+ async (props) => {
82
+ const onOpenChange = vi.fn();
83
+ render(<Modal {...props} onOpenChange={onOpenChange} />);
84
+ expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
85
+
86
+ await act(async () => {
87
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
88
+ });
89
+
90
+ expect(onOpenChange).not.toHaveBeenCalled();
91
+ expect(screen.getByRole("dialog", { name: "Chat" })).toBeDefined();
92
+ },
93
+ );
94
+
95
+ it("notifies the current owner without overriding its controlled value", async () => {
96
+ const previous = vi.fn();
97
+ const current = vi.fn();
98
+ const view = render(<Modal open={false} onOpenChange={previous} />);
99
+ view.rerender(<Modal open={false} onOpenChange={current} />);
100
+
101
+ await act(async () => {
102
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
103
+ });
104
+
105
+ expect(current).toHaveBeenCalledExactlyOnceWith(true);
106
+ expect(previous).not.toHaveBeenCalled();
107
+ expect(screen.queryByRole("dialog")).toBeNull();
108
+ });
109
+
110
+ it("does not request opening when auto-open is disabled", async () => {
111
+ const onOpenChange = vi.fn();
112
+ render(
113
+ <Modal unstable_openOnRunStart={false} onOpenChange={onOpenChange} />,
114
+ );
115
+
116
+ await act(async () => {
117
+ fireEvent.click(screen.getByRole("button", { name: "Start run" }));
118
+ });
119
+
120
+ expect(onOpenChange).not.toHaveBeenCalled();
121
+ expect(screen.queryByRole("dialog")).toBeNull();
122
+ });
123
+ });