@assistant-ui/react 0.15.28 → 0.15.30

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 (71) 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/context/stores/ThreadViewport.d.ts +8 -0
  6. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  7. package/dist/context/stores/ThreadViewport.js +24 -8
  8. package/dist/context/stores/ThreadViewport.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -2
  12. package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
  13. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  14. package/dist/mcp-apps/McpAppRenderer.js +5 -1
  15. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  16. package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
  17. package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
  18. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  19. package/dist/model-context/makeAssistantVisible.js +1 -1
  20. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
  22. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.d.ts +4 -1
  24. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  25. package/dist/primitives/composer/ComposerInput.js +15 -2
  26. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  27. package/dist/primitives/composer/ComposerResume.d.ts +23 -0
  28. package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
  29. package/dist/primitives/composer/ComposerResume.js +34 -0
  30. package/dist/primitives/composer/ComposerResume.js.map +1 -0
  31. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
  33. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
  34. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  35. package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
  36. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  37. package/dist/primitives/composer.d.ts +1 -0
  38. package/dist/primitives/composer.d.ts.map +1 -1
  39. package/dist/primitives/composer.js +3 -1
  40. package/dist/primitives/composer.js.map +1 -1
  41. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  42. package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
  43. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  44. package/dist/unstable/useMentionAdapter.js +1 -1
  45. package/dist/utils/Primitive.d.ts +14 -14
  46. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  47. package/package.json +5 -5
  48. package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
  49. package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
  50. package/src/context/stores/ThreadViewport.ts +36 -8
  51. package/src/index.ts +6 -0
  52. package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
  53. package/src/mcp-apps/McpAppRenderer.tsx +13 -2
  54. package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
  55. package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
  56. package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
  57. package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
  58. package/src/model-context/makeAssistantVisible.test.tsx +25 -37
  59. package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
  60. package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
  61. package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
  62. package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
  63. package/src/primitives/composer/ComposerInput.tsx +30 -4
  64. package/src/primitives/composer/ComposerResume.ts +36 -0
  65. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
  66. package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
  67. package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
  68. package/src/primitives/composer.ts +1 -0
  69. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
  70. package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
  71. package/src/model-context/frame/AssistantFrame.test.ts +0 -400
@@ -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
+ }
@@ -3,6 +3,12 @@
3
3
  import { create } from "zustand";
4
4
  import type { Unsubscribe } from "@assistant-ui/core";
5
5
  import { notifyEventListeners } from "@assistant-ui/core/internal";
6
+ import {
7
+ createThreadViewportAutoScroll,
8
+ type ThreadViewportAutoScroll,
9
+ type ThreadViewportAutoScrollOptions,
10
+ } from "@assistant-ui/store/client";
11
+ import type { ReadonlyStore } from "../ReadonlyStore";
6
12
 
7
13
  export type SizeHandle = {
8
14
  /** Update the height */
@@ -137,6 +143,20 @@ export type ThreadViewportStoreOptions = {
137
143
  | undefined;
138
144
  };
139
145
 
146
+ type ViewportAutoScroll = {
147
+ controller: ThreadViewportAutoScroll;
148
+ getOptions: () => ThreadViewportAutoScrollOptions;
149
+ };
150
+
151
+ const viewportAutoScroll = new WeakMap<
152
+ ReadonlyStore<ThreadViewportState>,
153
+ ViewportAutoScroll
154
+ >();
155
+
156
+ export const getThreadViewportAutoScroll = (
157
+ store: ReadonlyStore<ThreadViewportState>,
158
+ ) => viewportAutoScroll.get(store)!;
159
+
140
160
  export const makeThreadViewportStore = (
141
161
  options: ThreadViewportStoreOptions = {},
142
162
  ) => {
@@ -152,14 +172,21 @@ export const makeThreadViewportStore = (
152
172
  },
153
173
  });
154
174
  });
155
- const insetRegistry = createSizeRegistry((total) => {
156
- store.setState({
157
- height: {
158
- ...store.getState().height,
159
- inset: total,
175
+ const autoScroll: ViewportAutoScroll = {
176
+ getOptions: () => ({
177
+ autoScroll: options.turnAnchor !== "top",
178
+ scrollToBottomOnInitialize: true,
179
+ scrollToBottomOnRunStart: options.turnAnchor !== "top",
180
+ scrollToBottomOnThreadSwitch: true,
181
+ }),
182
+ controller: createThreadViewportAutoScroll({
183
+ getOptions: () => autoScroll.getOptions(),
184
+ onAtBottomChange: (isAtBottom) => store.setState({ isAtBottom }),
185
+ onContentInsetChange: (inset) => {
186
+ store.setState({ height: { ...store.getState().height, inset } });
160
187
  },
161
- });
162
- });
188
+ }),
189
+ };
163
190
  const registerElementSlot = (
164
191
  key: "viewport" | "anchor",
165
192
  element: HTMLElement | null,
@@ -217,7 +244,7 @@ export const makeThreadViewportStore = (
217
244
  topAnchorTurn: null,
218
245
 
219
246
  registerViewport: viewportRegistry.register,
220
- registerContentInset: insetRegistry.register,
247
+ registerContentInset: autoScroll.controller.registerContentInset,
221
248
  registerViewportElement: (element) =>
222
249
  registerElementSlot("viewport", element),
223
250
  registerAnchorElement: (element) => registerElementSlot("anchor", element),
@@ -246,5 +273,6 @@ export const makeThreadViewportStore = (
246
273
  },
247
274
  }));
248
275
 
276
+ viewportAutoScroll.set(store, autoScroll);
249
277
  return store;
250
278
  };
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,
@@ -270,7 +274,9 @@ export {
270
274
 
271
275
  export type {
272
276
  ModelContext,
277
+ ModelContextOptions,
273
278
  ModelContextProvider,
279
+ SystemPart,
274
280
  LanguageModelConfig,
275
281
  LanguageModelV1CallSettings,
276
282
  } from "@assistant-ui/core";
@@ -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) &&