@assistant-ui/react 0.15.27 → 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.
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts +22 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.d.ts.map +1 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +297 -0
- package/dist/adapters/OpenAIRealtimeVoiceAdapter.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/legacy-runtime/runtime-cores/utils/MessageRepository.js +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +5 -1
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js +67 -46
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js +28 -10
- package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +4 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +15 -2
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +63 -55
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
- package/dist/unstable/useMentionAdapter.js +1 -1
- package/dist/utils/Primitive.d.ts +14 -14
- package/package.json +6 -6
- package/src/adapters/OpenAIRealtimeVoiceAdapter.test.ts +748 -0
- package/src/adapters/OpenAIRealtimeVoiceAdapter.ts +388 -0
- package/src/index.ts +4 -0
- package/src/mcp-apps/McpAppRenderer.test.tsx +65 -0
- package/src/mcp-apps/McpAppRenderer.tsx +13 -2
- package/src/model-context/frame/useAssistantFrameHost.detach.test.tsx +145 -0
- package/src/model-context/frame/useAssistantFrameHost.reload.test.tsx +106 -0
- package/src/model-context/frame/useAssistantFrameHost.test.tsx +3 -1
- package/src/model-context/frame/useAssistantFrameHost.ts +72 -24
- package/src/primitives/composer/ComposerAddAttachment.test.tsx +1 -0
- package/src/primitives/composer/ComposerAddAttachment.thread-scope.test.tsx +202 -0
- package/src/primitives/composer/ComposerAddAttachment.ts +11 -1
- package/src/primitives/composer/ComposerInput.focus-on-run-start.test.tsx +130 -0
- package/src/primitives/composer/ComposerInput.tsx +29 -3
- package/src/primitives/composer/ComposerRoot.test.tsx +21 -1
- package/src/primitives/composer/ComposerRoot.tsx +8 -1
|
@@ -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
|
-
|
|
146
|
-
|
|
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
|
+
});
|