@assistant-ui/react 0.15.28 → 0.15.29

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 (41) hide show
  1. package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts +22 -0
  2. package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts.map +1 -0
  3. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +297 -0
  4. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js.map +1 -0
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  10. package/dist/mcp-apps/McpAppRenderer.js +5 -1
  11. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  12. package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
  13. package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
  14. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  15. package/dist/model-context/makeAssistantVisible.js +1 -1
  16. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  17. package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
  18. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  19. package/dist/primitives/composer/ComposerInput.d.ts +4 -1
  20. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerInput.js +15 -2
  22. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  23. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
  24. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
  25. package/dist/unstable/useMentionAdapter.js +1 -1
  26. package/dist/utils/Primitive.d.ts +14 -14
  27. package/package.json +4 -4
  28. package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
  29. package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
  30. package/src/index.ts +4 -0
  31. package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
  32. package/src/mcp-apps/McpAppRenderer.tsx +13 -2
  33. package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
  34. package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
  35. package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
  36. package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
  37. package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
  38. package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
  39. package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
  40. package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
  41. package/src/primitives/composer/ComposerInput.tsx +29 -3
@@ -0,0 +1,388 @@
1
+ import {
2
+ createVoiceSession,
3
+ type RealtimeVoiceAdapter,
4
+ } from "@assistant-ui/core";
5
+
6
+ /** Options for a browser WebRTC connection to OpenAI Realtime. */
7
+ export type OpenAIRealtimeVoiceAdapterOptions = {
8
+ /** Exchange the browser's SDP offer for an answer through your authenticated server. Keep the API key and session configuration on the server. */
9
+ exchangeSdp: (
10
+ sdp: string,
11
+ options: { signal: AbortSignal },
12
+ ) => Promise<string>;
13
+ };
14
+
15
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
16
+ value !== null && typeof value === "object" && !Array.isArray(value)
17
+ ? (value as Record<string, unknown>)
18
+ : undefined;
19
+
20
+ /**
21
+ * Connects browser microphone audio directly to OpenAI Realtime over WebRTC.
22
+ * Configure audio output, input transcription, and automatic turn detection on
23
+ * the server. Each connection starts a new conversation; existing thread history
24
+ * and assistant-ui tools are not sent to the voice model.
25
+ */
26
+ export class OpenAIRealtimeVoiceAdapter implements RealtimeVoiceAdapter {
27
+ private readonly options: OpenAIRealtimeVoiceAdapterOptions;
28
+
29
+ constructor(options: OpenAIRealtimeVoiceAdapterOptions) {
30
+ this.options = options;
31
+ }
32
+
33
+ connect(options: {
34
+ abortSignal?: AbortSignal;
35
+ }): RealtimeVoiceAdapter.Session {
36
+ return createVoiceSession(options, async (session) => {
37
+ if (
38
+ typeof RTCPeerConnection === "undefined" ||
39
+ typeof document === "undefined" ||
40
+ typeof navigator === "undefined" ||
41
+ !navigator.mediaDevices?.getUserMedia
42
+ ) {
43
+ throw new Error(
44
+ "OpenAI Realtime voice requires a browser with WebRTC and microphone access.",
45
+ );
46
+ }
47
+
48
+ const controller = new AbortController();
49
+ const peer = new RTCPeerConnection();
50
+ const channel = peer.createDataChannel("oai-events");
51
+ const audio = document.createElement("audio");
52
+ audio.autoplay = true;
53
+ let microphone: MediaStream | undefined;
54
+ let muted = false;
55
+ let closed = false;
56
+ let generating = false;
57
+ let speaking = false;
58
+ let transcribing = false;
59
+ let volumeTimer: ReturnType<typeof setInterval> | undefined;
60
+ let connectionTimer: ReturnType<typeof setTimeout> | undefined;
61
+ type Transcript = {
62
+ ready: boolean;
63
+ value: RealtimeVoiceAdapter.TranscriptItem | undefined;
64
+ };
65
+ let assistantTranscript: (Transcript & { id: string }) | undefined;
66
+ const transcriptQueue: Transcript[] = [];
67
+ const pendingTranscripts = new Map<string, Transcript>();
68
+ const flushTranscripts = () => {
69
+ while (transcriptQueue[0]?.ready) {
70
+ const item = transcriptQueue[0];
71
+ item.ready = false;
72
+ if (item.value?.isFinal !== false) transcriptQueue.shift();
73
+ if (item.value) session.emitTranscript(item.value);
74
+ }
75
+ };
76
+ const finishUserTranscript = (
77
+ id: unknown,
78
+ value?: RealtimeVoiceAdapter.TranscriptItem,
79
+ ) => {
80
+ const item =
81
+ typeof id === "string" ? pendingTranscripts.get(id) : undefined;
82
+ if (item) {
83
+ item.ready = true;
84
+ item.value = value;
85
+ pendingTranscripts.delete(id as string);
86
+ } else if (value) transcriptQueue.push({ ready: true, value });
87
+ flushTranscripts();
88
+ };
89
+ const pending = new Map<
90
+ string,
91
+ {
92
+ resolve: () => void;
93
+ reject: (error: Error) => void;
94
+ timer: ReturnType<typeof setTimeout>;
95
+ }
96
+ >();
97
+
98
+ const disconnect = () => {
99
+ if (closed) return;
100
+ closed = true;
101
+ controller.abort();
102
+ clearInterval(volumeTimer);
103
+ clearTimeout(connectionTimer);
104
+ for (const id of pending.keys())
105
+ settle(
106
+ id,
107
+ new Error(
108
+ "OpenAI Realtime session disconnected before accepting the message.",
109
+ ),
110
+ );
111
+ pendingTranscripts.clear();
112
+ transcriptQueue.length = 0;
113
+ channel.onopen =
114
+ channel.onmessage =
115
+ channel.onclose =
116
+ channel.onerror =
117
+ null;
118
+ peer.ontrack = peer.onconnectionstatechange = null;
119
+ for (const track of microphone?.getTracks() ?? []) track.stop();
120
+ audio.pause();
121
+ audio.srcObject = null;
122
+ channel.close();
123
+ peer.close();
124
+ };
125
+
126
+ const end = (reason: "finished" | "error", error?: unknown) => {
127
+ session.end(reason, error);
128
+ disconnect();
129
+ };
130
+
131
+ const send = (event: Record<string, unknown>) => {
132
+ if (closed || channel.readyState !== "open")
133
+ throw new Error("OpenAI Realtime session is not connected.");
134
+ channel.send(JSON.stringify(event));
135
+ };
136
+
137
+ const request = (id: string, event: Record<string, unknown>) =>
138
+ new Promise<void>((resolve, reject) => {
139
+ const timer = setTimeout(() => {
140
+ reject(
141
+ new Error("OpenAI Realtime did not acknowledge the message."),
142
+ );
143
+ }, 10_000);
144
+ pending.set(id, { resolve, reject, timer });
145
+ send({ ...event, event_id: id });
146
+ }).finally(() => settle(id));
147
+
148
+ const settle = (id: unknown, error?: Error) => {
149
+ if (typeof id !== "string") return false;
150
+ const message = pending.get(id);
151
+ if (!message) return false;
152
+ clearTimeout(message.timer);
153
+ pending.delete(id);
154
+ if (error) message.reject(error);
155
+ else message.resolve();
156
+ return true;
157
+ };
158
+
159
+ const setSpeaking = (value: boolean) => {
160
+ speaking = value;
161
+ session.emitMode(value ? "speaking" : "listening");
162
+ };
163
+
164
+ channel.onmessage = ({ data }) => {
165
+ if (closed) return;
166
+ let event: Record<string, unknown> | undefined;
167
+ try {
168
+ event = asRecord(JSON.parse(data));
169
+ } catch {
170
+ return;
171
+ }
172
+ if (!event) return;
173
+ switch (event.type) {
174
+ case "session.created":
175
+ case "session.updated": {
176
+ const audio = asRecord(asRecord(event.session)?.audio);
177
+ const input = asRecord(audio?.input);
178
+ transcribing = asRecord(input?.transcription) !== undefined;
179
+ break;
180
+ }
181
+ case "input_audio_buffer.committed":
182
+ if (
183
+ transcribing &&
184
+ typeof event.item_id === "string" &&
185
+ !pendingTranscripts.has(event.item_id)
186
+ ) {
187
+ const item = { ready: false, value: undefined };
188
+ pendingTranscripts.set(event.item_id, item);
189
+ transcriptQueue.push(item);
190
+ }
191
+ break;
192
+ case "conversation.item.input_audio_transcription.completed":
193
+ if (typeof event.transcript === "string")
194
+ finishUserTranscript(event.item_id, {
195
+ role: "user",
196
+ text: event.transcript,
197
+ isFinal: true,
198
+ });
199
+ break;
200
+ case "response.output_audio_transcript.delta":
201
+ case "response.output_audio_transcript.done": {
202
+ if (typeof event.item_id !== "string") break;
203
+ const final =
204
+ event.type === "response.output_audio_transcript.done";
205
+ const text = final ? event.transcript : event.delta;
206
+ if (typeof text !== "string") break;
207
+ if (assistantTranscript?.id !== event.item_id) {
208
+ assistantTranscript = {
209
+ id: event.item_id,
210
+ ready: false,
211
+ value: undefined,
212
+ };
213
+ transcriptQueue.push(assistantTranscript);
214
+ }
215
+ assistantTranscript.ready = true;
216
+ assistantTranscript.value = {
217
+ role: "assistant",
218
+ text: final
219
+ ? text
220
+ : (assistantTranscript.value?.text ?? "") + text,
221
+ isFinal: final,
222
+ };
223
+ flushTranscripts();
224
+ break;
225
+ }
226
+ case "response.created":
227
+ generating = true;
228
+ settle(
229
+ asRecord(asRecord(event.response)?.metadata)?.aui_request_id,
230
+ );
231
+ break;
232
+ case "response.done": {
233
+ generating = false;
234
+ const response = asRecord(event.response);
235
+ if (assistantTranscript?.value?.isFinal === false) {
236
+ assistantTranscript.value = {
237
+ ...assistantTranscript.value,
238
+ isFinal: true,
239
+ };
240
+ assistantTranscript.ready = true;
241
+ flushTranscripts();
242
+ }
243
+ if (response?.status === "failed")
244
+ console.warn(
245
+ "OpenAI Realtime response failed.",
246
+ response.status_details,
247
+ );
248
+ break;
249
+ }
250
+ case "output_audio_buffer.started":
251
+ setSpeaking(true);
252
+ break;
253
+ case "output_audio_buffer.stopped":
254
+ case "output_audio_buffer.cleared":
255
+ setSpeaking(false);
256
+ break;
257
+ case "conversation.item.added":
258
+ settle(asRecord(event.item)?.id);
259
+ break;
260
+ case "conversation.item.input_audio_transcription.failed":
261
+ finishUserTranscript(event.item_id);
262
+ console.warn("OpenAI Realtime transcription failed.", event.error);
263
+ break;
264
+ case "error": {
265
+ const error = asRecord(event.error);
266
+ if (error?.code === "response_cancel_not_active") break;
267
+ if (
268
+ !settle(
269
+ error?.event_id,
270
+ new Error(
271
+ typeof error?.message === "string"
272
+ ? error.message
273
+ : "OpenAI Realtime rejected the message.",
274
+ ),
275
+ )
276
+ )
277
+ console.warn("OpenAI Realtime error.", event.error);
278
+ break;
279
+ }
280
+ }
281
+ };
282
+ channel.onopen = () => {
283
+ if (closed) return;
284
+ clearTimeout(connectionTimer);
285
+ session.setStatus({ type: "running" });
286
+ if (closed) return;
287
+ session.emitMode("listening");
288
+ if (closed) return;
289
+ volumeTimer = setInterval(() => {
290
+ void peer
291
+ .getStats()
292
+ .then((report) => {
293
+ if (closed) return;
294
+ let volume = 0;
295
+ report.forEach((stat) => {
296
+ if (
297
+ (stat.type === "inbound-rtp" ||
298
+ (!muted && stat.type === "media-source")) &&
299
+ typeof stat.audioLevel === "number"
300
+ )
301
+ volume = Math.max(volume, stat.audioLevel);
302
+ });
303
+ session.emitVolume(Math.min(1, volume));
304
+ })
305
+ .catch(() => {});
306
+ }, 100);
307
+ };
308
+ channel.onclose = () => end("finished");
309
+ channel.onerror = () =>
310
+ end("error", new Error("OpenAI Realtime data channel failed."));
311
+ peer.onconnectionstatechange = () => {
312
+ if (peer.connectionState === "failed") {
313
+ end("error", new Error("OpenAI Realtime connection failed."));
314
+ } else if (peer.connectionState === "closed") {
315
+ end("finished");
316
+ }
317
+ };
318
+ peer.ontrack = ({ track }) => {
319
+ if (closed || track.kind !== "audio") return;
320
+ audio.srcObject = new MediaStream([track]);
321
+ void audio.play().catch((error: unknown) => end("error", error));
322
+ };
323
+
324
+ const start = async () => {
325
+ const stream = await navigator.mediaDevices.getUserMedia({
326
+ audio: true,
327
+ });
328
+ if (closed || session.isDisposed()) {
329
+ for (const track of stream.getTracks()) track.stop();
330
+ return;
331
+ }
332
+ microphone = stream;
333
+ for (const track of stream.getAudioTracks()) {
334
+ track.enabled = !muted;
335
+ peer.addTrack(track, stream);
336
+ }
337
+ connectionTimer = setTimeout(
338
+ () =>
339
+ end("error", new Error("OpenAI Realtime connection timed out.")),
340
+ 30_000,
341
+ );
342
+ const offer = await peer.createOffer();
343
+ if (closed) return;
344
+ await peer.setLocalDescription(offer);
345
+ if (closed) return;
346
+ const answer = await this.options.exchangeSdp(offer.sdp!, {
347
+ signal: controller.signal,
348
+ });
349
+ if (closed) return;
350
+ await peer.setRemoteDescription({ type: "answer", sdp: answer });
351
+ };
352
+ void start().catch((error: unknown) => end("error", error));
353
+
354
+ return {
355
+ disconnect,
356
+ mute: () => {
357
+ muted = true;
358
+ for (const track of microphone?.getAudioTracks() ?? [])
359
+ track.enabled = false;
360
+ },
361
+ unmute: () => {
362
+ muted = false;
363
+ for (const track of microphone?.getAudioTracks() ?? [])
364
+ track.enabled = true;
365
+ },
366
+ sendText: async (text) => {
367
+ if (generating) send({ type: "response.cancel" });
368
+ if (speaking) send({ type: "output_audio_buffer.clear" });
369
+ const id = crypto.randomUUID().replaceAll("-", "");
370
+ await request(id, {
371
+ type: "conversation.item.create",
372
+ item: {
373
+ id,
374
+ type: "message",
375
+ role: "user",
376
+ content: [{ type: "input_text", text }],
377
+ },
378
+ });
379
+ const responseId = crypto.randomUUID().replaceAll("-", "");
380
+ await request(responseId, {
381
+ type: "response.create",
382
+ response: { metadata: { aui_request_id: responseId } },
383
+ });
384
+ },
385
+ };
386
+ });
387
+ }
388
+ }
package/src/index.ts CHANGED
@@ -65,6 +65,10 @@ export { CloudFileAttachmentAdapter } from "./legacy-runtime/runtime-cores/adapt
65
65
  // --- adapters/voice ---
66
66
  export type { RealtimeVoiceAdapter } from "@assistant-ui/core";
67
67
  export { createVoiceSession } from "@assistant-ui/core";
68
+ export {
69
+ OpenAIRealtimeVoiceAdapter,
70
+ type OpenAIRealtimeVoiceAdapterOptions,
71
+ } from "./adapters/OpenAIRealtimeVoiceAdapter";
68
72
  export type {
69
73
  VoiceSessionControls,
70
74
  VoiceSessionHelpers,
@@ -725,6 +725,71 @@ describe("McpAppRenderer", () => {
725
725
  expect(appendMock).toHaveBeenCalledTimes(2);
726
726
  });
727
727
 
728
+ it("extracts sparse message content without traversing holes", async () => {
729
+ render(<Harness host={loadingHost()} />);
730
+ await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
731
+ const handlers = framePropsMock.mock.lastCall?.[0]
732
+ .handlers as McpAppBridgeHandlers;
733
+ const content: unknown[] = new Array(50_000_000);
734
+ content[0] = { type: "text", text: "sparse prompt" };
735
+
736
+ const lastIndex = String(content.length - 1);
737
+ const arrayPrototypeLength = Array.prototype.length;
738
+ const visitedHole = vi.fn();
739
+ Object.defineProperty(Array.prototype, lastIndex, {
740
+ configurable: true,
741
+ get() {
742
+ visitedHole();
743
+ return undefined;
744
+ },
745
+ });
746
+
747
+ try {
748
+ const clonedContent = structuredClone(content);
749
+ expect(
750
+ handlers.sendMessage?.({ role: "user", content: clonedContent }),
751
+ ).toEqual({ ok: true });
752
+ } finally {
753
+ delete (Array.prototype as unknown[])[Number(lastIndex)];
754
+ Array.prototype.length = arrayPrototypeLength;
755
+ }
756
+
757
+ expect(visitedHole).not.toHaveBeenCalled();
758
+ expect(appendMock).toHaveBeenCalledWith({
759
+ content: [{ type: "text", text: "sparse prompt" }],
760
+ });
761
+ });
762
+
763
+ it("ignores non-index message content properties after structured clone", async () => {
764
+ render(<Harness host={loadingHost()} />);
765
+ await waitFor(() => expect(framePropsMock).toHaveBeenCalled());
766
+ const handlers = framePropsMock.mock.lastCall?.[0]
767
+ .handlers as McpAppBridgeHandlers;
768
+
769
+ for (const property of ["filter", "constructor"] as const) {
770
+ const content: unknown[] = [{ type: "text", text: "message text" }];
771
+ Object.defineProperty(content, property, {
772
+ configurable: true,
773
+ enumerable: true,
774
+ value: { type: "text", text: "not an array element" },
775
+ });
776
+ const clonedContent = structuredClone(content);
777
+
778
+ expect(Object.hasOwn(clonedContent, property)).toBe(true);
779
+ expect(
780
+ handlers.sendMessage?.({ role: "user", content: clonedContent }),
781
+ ).toEqual({ ok: true });
782
+ }
783
+
784
+ expect(appendMock).toHaveBeenCalledTimes(2);
785
+ expect(appendMock).toHaveBeenNthCalledWith(1, {
786
+ content: [{ type: "text", text: "message text" }],
787
+ });
788
+ expect(appendMock).toHaveBeenNthCalledWith(2, {
789
+ content: [{ type: "text", text: "message text" }],
790
+ });
791
+ });
792
+
728
793
  it("uses caller UI handlers and keeps data-plane handlers on the host", async () => {
729
794
  const host: McpAppsHost = {
730
795
  loadResource: vi.fn(async ({ uri }) => ({
@@ -142,8 +142,19 @@ function extractSendMessageTexts(params: unknown): string[] | undefined {
142
142
  if (typeof obj["prompt"] === "string") return [obj["prompt"]];
143
143
  if (typeof obj["text"] === "string") return [obj["text"]];
144
144
  if (typeof obj["message"] === "string") return [obj["message"]];
145
- if (Array.isArray(obj["content"])) {
146
- return obj["content"]
145
+ const content = obj["content"];
146
+ if (Array.isArray(content)) {
147
+ return Object.keys(content)
148
+ .filter((key) => {
149
+ const index = Number(key);
150
+ return (
151
+ Number.isInteger(index) &&
152
+ index >= 0 &&
153
+ index < content.length &&
154
+ String(index) === key
155
+ );
156
+ })
157
+ .map((key) => content[Number(key)])
147
158
  .filter(
148
159
  (block): block is { type: "text"; text: string } =>
149
160
  isRecord(block) &&
@@ -0,0 +1,145 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { AssistantFrameHost, ModelContextRegistry } from "@assistant-ui/core";
5
+ import { expect, it, vi } from "vitest";
6
+ import { useAssistantFrameHost } from "./useAssistantFrameHost";
7
+
8
+ const attachFrame = () => {
9
+ const iframe = document.createElement("iframe");
10
+ document.body.append(iframe);
11
+ vi.spyOn(iframe.contentWindow!, "postMessage").mockImplementation(() => {});
12
+ return iframe;
13
+ };
14
+
15
+ const publishTool = (iframe: HTMLIFrameElement, name: string) => {
16
+ window.dispatchEvent(
17
+ new MessageEvent("message", {
18
+ source: iframe.contentWindow,
19
+ origin: window.location.origin,
20
+ data: {
21
+ channel: "assistant-ui-frame",
22
+ message: {
23
+ type: "model-context-update",
24
+ context: {
25
+ tools: {
26
+ [name]: { parameters: { type: "object", properties: {} } },
27
+ },
28
+ },
29
+ },
30
+ },
31
+ }),
32
+ );
33
+ };
34
+
35
+ it("withdraws a detached frame's tools and calls, then serves an attached frame", async () => {
36
+ const registry = new ModelContextRegistry();
37
+ const firstFrame = attachFrame();
38
+ const iframeRef = { current: firstFrame };
39
+ const register = (host: Parameters<typeof registry.addProvider>[0]) =>
40
+ registry.addProvider(host).remove;
41
+ const { unmount } = renderHook(() =>
42
+ useAssistantFrameHost({ iframeRef, register }),
43
+ );
44
+
45
+ try {
46
+ publishTool(firstFrame, "search");
47
+ const search = registry.getModelContext().tools?.search;
48
+ expect(search).toBeDefined();
49
+ const pending = search!.execute!({}, {} as any);
50
+ const outcome = pending.then(
51
+ () => undefined,
52
+ (error: unknown) => error,
53
+ );
54
+
55
+ firstFrame.remove();
56
+ await vi.waitFor(() => {
57
+ expect(registry.getModelContext().tools).toBeUndefined();
58
+ });
59
+ expect(await outcome).toMatchObject({
60
+ message: "AssistantFrameHost has been disposed",
61
+ });
62
+
63
+ const secondFrame = attachFrame();
64
+ iframeRef.current = secondFrame;
65
+ await vi.waitFor(() => {
66
+ publishTool(secondFrame, "replacement");
67
+ expect(registry.getModelContext().tools?.replacement).toBeDefined();
68
+ });
69
+ secondFrame.remove();
70
+ } finally {
71
+ unmount();
72
+ firstFrame.remove();
73
+ }
74
+ });
75
+
76
+ it("serves a replacement frame before reporting a disposal failure", () => {
77
+ const disposalError = new Error("tool cancellation failed");
78
+ const dispose = vi
79
+ .spyOn(AssistantFrameHost.prototype, "dispose")
80
+ .mockImplementationOnce(() => {
81
+ throw disposalError;
82
+ });
83
+ const mutationCallbacks: MutationCallback[] = [];
84
+ vi.stubGlobal(
85
+ "MutationObserver",
86
+ class {
87
+ constructor(callback: MutationCallback) {
88
+ mutationCallbacks.push(callback);
89
+ }
90
+ observe() {}
91
+ disconnect() {}
92
+ },
93
+ );
94
+
95
+ const registry = new ModelContextRegistry();
96
+ const firstFrame = attachFrame();
97
+ const secondFrame = attachFrame();
98
+ const iframeRef = { current: firstFrame };
99
+ const register = (host: Parameters<typeof registry.addProvider>[0]) =>
100
+ registry.addProvider(host).remove;
101
+ const { unmount } = renderHook(() =>
102
+ useAssistantFrameHost({ iframeRef, register }),
103
+ );
104
+
105
+ try {
106
+ publishTool(firstFrame, "search");
107
+ expect(registry.getModelContext().tools?.search).toBeDefined();
108
+
109
+ iframeRef.current = secondFrame;
110
+ expect(() => mutationCallbacks[0]!([], {} as MutationObserver)).toThrow(
111
+ disposalError,
112
+ );
113
+
114
+ expect(registry.getModelContext().tools?.search).toBeUndefined();
115
+ publishTool(secondFrame, "replacement");
116
+ expect(registry.getModelContext().tools?.replacement).toBeDefined();
117
+ } finally {
118
+ unmount();
119
+ firstFrame.remove();
120
+ secondFrame.remove();
121
+ dispose.mockRestore();
122
+ vi.unstubAllGlobals();
123
+ }
124
+ });
125
+
126
+ it("disposes a host whose registration throws", () => {
127
+ const dispose = vi.spyOn(AssistantFrameHost.prototype, "dispose");
128
+ const iframe = attachFrame();
129
+ const register = vi.fn((): (() => void) => {
130
+ throw new Error("register failed");
131
+ });
132
+
133
+ try {
134
+ expect(() =>
135
+ renderHook(() =>
136
+ useAssistantFrameHost({ iframeRef: { current: iframe }, register }),
137
+ ),
138
+ ).toThrow("register failed");
139
+ expect(register).toHaveBeenCalledOnce();
140
+ expect(dispose).toHaveBeenCalledOnce();
141
+ } finally {
142
+ dispose.mockRestore();
143
+ iframe.remove();
144
+ }
145
+ });