@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,748 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { OpenAIRealtimeVoiceAdapter } from "./OpenAIRealtimeVoiceAdapter";
3
+
4
+ const deferred = <T>() => {
5
+ let resolve!: (value: T) => void;
6
+ let reject!: (reason: unknown) => void;
7
+ const promise = new Promise<T>((yes, no) => {
8
+ resolve = yes;
9
+ reject = no;
10
+ });
11
+ return { promise, resolve, reject };
12
+ };
13
+
14
+ class Channel {
15
+ readyState = "connecting";
16
+ onopen: (() => void) | null = null;
17
+ onclose: (() => void) | null = null;
18
+ onerror: (() => void) | null = null;
19
+ onmessage: ((event: { data: string }) => void) | null = null;
20
+ send = vi.fn<(data: string) => void>();
21
+ close = vi.fn(() => {
22
+ this.readyState = "closed";
23
+ this.onclose?.();
24
+ });
25
+ open() {
26
+ this.readyState = "open";
27
+ this.onopen?.();
28
+ }
29
+ emit(event: unknown) {
30
+ this.onmessage?.({ data: JSON.stringify(event) });
31
+ }
32
+ events() {
33
+ return this.send.mock.calls.map(([data]) => JSON.parse(data));
34
+ }
35
+ }
36
+
37
+ class Peer {
38
+ static instances: Peer[] = [];
39
+ channel = new Channel();
40
+ connectionState = "new";
41
+ onconnectionstatechange: (() => void) | null = null;
42
+ ontrack: ((event: { track: { kind: string } }) => void) | null = null;
43
+ createDataChannel = vi.fn(() => this.channel);
44
+ addTrack = vi.fn();
45
+ createOffer = vi.fn(async () => ({ type: "offer", sdp: "browser-offer" }));
46
+ setLocalDescription = vi.fn(async () => {});
47
+ setRemoteDescription = vi.fn(async () => {});
48
+ getStats = vi.fn(
49
+ async () =>
50
+ new Map([
51
+ ["input", { type: "media-source", audioLevel: 0.7 }],
52
+ ["output", { type: "inbound-rtp", audioLevel: 0.4 }],
53
+ ]),
54
+ );
55
+ close = vi.fn(() => {
56
+ this.connectionState = "closed";
57
+ this.onconnectionstatechange?.();
58
+ });
59
+ constructor() {
60
+ Peer.instances.push(this);
61
+ }
62
+ }
63
+
64
+ const track = { enabled: true, stop: vi.fn() };
65
+ const stream = { getTracks: () => [track], getAudioTracks: () => [track] };
66
+ const getUserMedia = vi.fn(async () => stream);
67
+ const audio = {
68
+ autoplay: false,
69
+ srcObject: null as unknown,
70
+ play: vi.fn(async () => {}),
71
+ pause: vi.fn(),
72
+ };
73
+ const exchangeSdp = vi.fn<
74
+ (sdp: string, options: { signal: AbortSignal }) => Promise<string>
75
+ >(async () => "server-answer");
76
+ const sessions: ReturnType<OpenAIRealtimeVoiceAdapter["connect"]>[] = [];
77
+
78
+ const start = (abortSignal?: AbortSignal) => {
79
+ const session = new OpenAIRealtimeVoiceAdapter({ exchangeSdp }).connect(
80
+ abortSignal ? { abortSignal } : {},
81
+ );
82
+ sessions.push(session);
83
+ const peer = Peer.instances.at(-1)!;
84
+ return { session, peer, channel: peer.channel };
85
+ };
86
+
87
+ const connected = async () => {
88
+ const result = start();
89
+ await vi.waitFor(() =>
90
+ expect(result.peer.setRemoteDescription).toHaveBeenCalled(),
91
+ );
92
+ result.channel.open();
93
+ result.channel.emit({
94
+ type: "session.created",
95
+ session: {
96
+ audio: { input: { transcription: { model: "gpt-4o-mini-transcribe" } } },
97
+ },
98
+ });
99
+ return result;
100
+ };
101
+
102
+ beforeEach(() => {
103
+ Peer.instances = [];
104
+ track.enabled = true;
105
+ getUserMedia.mockResolvedValue(stream);
106
+ exchangeSdp.mockResolvedValue("server-answer");
107
+ audio.play.mockResolvedValue();
108
+ vi.stubGlobal("RTCPeerConnection", Peer);
109
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
110
+ vi.stubGlobal("document", { createElement: () => audio });
111
+ vi.stubGlobal(
112
+ "MediaStream",
113
+ class {
114
+ tracks: unknown[];
115
+ constructor(tracks: unknown[]) {
116
+ this.tracks = tracks;
117
+ }
118
+ },
119
+ );
120
+ });
121
+
122
+ afterEach(() => {
123
+ for (const session of sessions.splice(0)) session.disconnect();
124
+ vi.unstubAllGlobals();
125
+ vi.useRealTimers();
126
+ });
127
+
128
+ describe("OpenAIRealtimeVoiceAdapter", () => {
129
+ it("exchanges SDP and waits for the data channel before becoming usable", async () => {
130
+ const { session, peer, channel } = start();
131
+ await vi.waitFor(() => expect(exchangeSdp).toHaveBeenCalled());
132
+ expect(exchangeSdp).toHaveBeenCalledWith("browser-offer", {
133
+ signal: expect.any(AbortSignal),
134
+ });
135
+ expect(peer.setRemoteDescription).toHaveBeenCalledWith({
136
+ type: "answer",
137
+ sdp: "server-answer",
138
+ });
139
+ expect(peer.addTrack).toHaveBeenCalledWith(track, stream);
140
+ expect(session.status).toEqual({ type: "starting" });
141
+ channel.open();
142
+ expect(session.status).toEqual({ type: "running" });
143
+ expect(session.sendText).toBeTypeOf("function");
144
+ });
145
+
146
+ it("streams cumulative assistant transcripts and records completed user audio", async () => {
147
+ const { session, channel } = await connected();
148
+ const onTranscript = vi.fn();
149
+ session.onTranscript(onTranscript);
150
+ channel.emit({
151
+ type: "conversation.item.input_audio_transcription.completed",
152
+ transcript: "Hello",
153
+ });
154
+ channel.emit({
155
+ type: "response.output_audio_transcript.delta",
156
+ item_id: "a",
157
+ delta: "Good ",
158
+ });
159
+ channel.emit({
160
+ type: "response.output_audio_transcript.delta",
161
+ item_id: "a",
162
+ delta: "morning",
163
+ });
164
+ channel.emit({
165
+ type: "response.output_audio_transcript.done",
166
+ item_id: "a",
167
+ transcript: "Good morning!",
168
+ });
169
+ channel.emit({
170
+ type: "response.output_audio_transcript.delta",
171
+ item_id: "b",
172
+ delta: "Next",
173
+ });
174
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
175
+ { role: "user", text: "Hello", isFinal: true },
176
+ { role: "assistant", text: "Good ", isFinal: false },
177
+ { role: "assistant", text: "Good morning", isFinal: false },
178
+ { role: "assistant", text: "Good morning!", isFinal: true },
179
+ { role: "assistant", text: "Next", isFinal: false },
180
+ ]);
181
+ });
182
+
183
+ it("uses audio playback events instead of generation completion for speaking state", async () => {
184
+ const { session, channel } = await connected();
185
+ const onMode = vi.fn();
186
+ session.onModeChange(onMode);
187
+ channel.emit({ type: "output_audio_buffer.started" });
188
+ channel.emit({ type: "response.done", response: { status: "completed" } });
189
+ expect(onMode.mock.calls).toEqual([["speaking"]]);
190
+ channel.emit({ type: "input_audio_buffer.speech_started" });
191
+ expect(onMode.mock.calls).toEqual([["speaking"]]);
192
+ channel.emit({ type: "output_audio_buffer.cleared" });
193
+ expect(onMode).toHaveBeenLastCalledWith("listening");
194
+ });
195
+
196
+ it("holds replies until preceding spoken turns are transcribed in conversation order", async () => {
197
+ const { session, channel } = await connected();
198
+ const onTranscript = vi.fn();
199
+ session.onTranscript(onTranscript);
200
+ for (const id of ["first", "second"]) {
201
+ channel.emit({ type: "input_audio_buffer.committed", item_id: id });
202
+ channel.emit({
203
+ type: "response.output_audio_transcript.done",
204
+ item_id: `${id}-reply`,
205
+ transcript: `Reply to ${id}`,
206
+ });
207
+ }
208
+ channel.emit({
209
+ type: "conversation.item.input_audio_transcription.completed",
210
+ item_id: "second",
211
+ transcript: "Second question",
212
+ });
213
+ expect(onTranscript).not.toHaveBeenCalled();
214
+ channel.emit({
215
+ type: "conversation.item.input_audio_transcription.completed",
216
+ item_id: "first",
217
+ transcript: "First question",
218
+ });
219
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
220
+ { role: "user", text: "First question", isFinal: true },
221
+ { role: "assistant", text: "Reply to first", isFinal: true },
222
+ { role: "user", text: "Second question", isFinal: true },
223
+ { role: "assistant", text: "Reply to second", isFinal: true },
224
+ ]);
225
+ });
226
+
227
+ it("keeps a streaming reply together when the user transcript arrives after its first delta", async () => {
228
+ const { session, channel } = await connected();
229
+ const onTranscript = vi.fn();
230
+ session.onTranscript(onTranscript);
231
+ channel.emit({ type: "input_audio_buffer.committed", item_id: "user" });
232
+ channel.emit({
233
+ type: "response.output_audio_transcript.delta",
234
+ item_id: "reply",
235
+ delta: "Good ",
236
+ });
237
+ expect(onTranscript).not.toHaveBeenCalled();
238
+ channel.emit({
239
+ type: "conversation.item.input_audio_transcription.completed",
240
+ item_id: "user",
241
+ transcript: "Hello",
242
+ });
243
+ channel.emit({
244
+ type: "response.output_audio_transcript.delta",
245
+ item_id: "reply",
246
+ delta: "morning",
247
+ });
248
+ channel.emit({
249
+ type: "response.output_audio_transcript.done",
250
+ item_id: "reply",
251
+ transcript: "Good morning",
252
+ });
253
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
254
+ { role: "user", text: "Hello", isFinal: true },
255
+ { role: "assistant", text: "Good ", isFinal: false },
256
+ { role: "assistant", text: "Good morning", isFinal: false },
257
+ { role: "assistant", text: "Good morning", isFinal: true },
258
+ ]);
259
+ });
260
+
261
+ it("releases replies after transcription failure and keeps the call usable", async () => {
262
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
263
+ try {
264
+ const { session, channel } = await connected();
265
+ const onTranscript = vi.fn();
266
+ session.onTranscript(onTranscript);
267
+ channel.emit({ type: "input_audio_buffer.committed", item_id: "user" });
268
+ channel.emit({
269
+ type: "response.output_audio_transcript.done",
270
+ item_id: "reply",
271
+ transcript: "Please repeat that",
272
+ });
273
+ channel.emit({
274
+ type: "conversation.item.input_audio_transcription.failed",
275
+ item_id: "user",
276
+ error: { message: "Could not transcribe" },
277
+ });
278
+ channel.emit({ type: "response.done", response: { status: "failed" } });
279
+ channel.emit({
280
+ type: "error",
281
+ error: {
282
+ code: "conversation_already_has_active_response",
283
+ message: "Already responding",
284
+ },
285
+ });
286
+ expect(session.status.type).toBe("running");
287
+ expect(onTranscript).toHaveBeenCalledWith({
288
+ role: "assistant",
289
+ text: "Please repeat that",
290
+ isFinal: true,
291
+ });
292
+ expect(track.stop).not.toHaveBeenCalled();
293
+ expect(warn).toHaveBeenCalled();
294
+ } finally {
295
+ warn.mockRestore();
296
+ }
297
+ });
298
+
299
+ it.each(["session.created", "session.updated"])(
300
+ "streams replies with transcription disabled by %s",
301
+ async (type) => {
302
+ const { session, channel } = await connected();
303
+ const onTranscript = vi.fn();
304
+ session.onTranscript(onTranscript);
305
+ channel.emit({
306
+ type,
307
+ session: { audio: { input: { transcription: null } } },
308
+ });
309
+ channel.emit({ type: "input_audio_buffer.committed", item_id: "user" });
310
+ channel.emit({
311
+ type: "response.output_audio_transcript.delta",
312
+ item_id: "reply",
313
+ delta: "Hello",
314
+ });
315
+ channel.emit({
316
+ type: "response.output_audio_transcript.done",
317
+ item_id: "reply",
318
+ transcript: "Hello!",
319
+ });
320
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
321
+ { role: "assistant", text: "Hello", isFinal: false },
322
+ { role: "assistant", text: "Hello!", isFinal: true },
323
+ ]);
324
+ },
325
+ );
326
+
327
+ it("releases later turns when a failed response omits the final transcript event", async () => {
328
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
329
+ try {
330
+ const { session, channel } = await connected();
331
+ const onTranscript = vi.fn();
332
+ session.onTranscript(onTranscript);
333
+ channel.emit({
334
+ type: "response.output_audio_transcript.delta",
335
+ item_id: "reply",
336
+ delta: "Partial",
337
+ });
338
+ channel.emit({ type: "input_audio_buffer.committed", item_id: "user" });
339
+ channel.emit({
340
+ type: "conversation.item.input_audio_transcription.completed",
341
+ item_id: "user",
342
+ transcript: "Next question",
343
+ });
344
+ channel.emit({ type: "response.done", response: { status: "failed" } });
345
+ channel.emit({
346
+ type: "response.output_audio_transcript.done",
347
+ item_id: "next-reply",
348
+ transcript: "Next reply",
349
+ });
350
+ channel.emit({
351
+ type: "response.done",
352
+ response: { status: "completed" },
353
+ });
354
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
355
+ { role: "assistant", text: "Partial", isFinal: false },
356
+ { role: "assistant", text: "Partial", isFinal: true },
357
+ { role: "user", text: "Next question", isFinal: true },
358
+ { role: "assistant", text: "Next reply", isFinal: true },
359
+ ]);
360
+ expect(session.status.type).toBe("running");
361
+ } finally {
362
+ warn.mockRestore();
363
+ }
364
+ });
365
+
366
+ it("cleans up a typed request when the data channel rejects the send", async () => {
367
+ vi.useFakeTimers();
368
+ const { session, channel } = await connected();
369
+ channel.send.mockImplementation(() => {
370
+ throw new Error("Channel unavailable");
371
+ });
372
+ await expect(session.sendText!("Hello")).rejects.toThrow(
373
+ "Channel unavailable",
374
+ );
375
+ expect(vi.getTimerCount()).toBe(1);
376
+ session.disconnect();
377
+ expect(vi.getTimerCount()).toBe(0);
378
+ });
379
+
380
+ it.each([true, false])(
381
+ "keeps an uninterrupted assistant message together when the next user transcript finishes early: %s",
382
+ async (early) => {
383
+ const { session, channel } = await connected();
384
+ const onTranscript = vi.fn();
385
+ session.onTranscript(onTranscript);
386
+ channel.emit({
387
+ type: "session.updated",
388
+ session: {
389
+ audio: {
390
+ input: {
391
+ transcription: { model: "gpt-4o-mini-transcribe" },
392
+ turn_detection: { type: "server_vad", interrupt_response: false },
393
+ },
394
+ },
395
+ },
396
+ });
397
+ channel.emit({
398
+ type: "response.output_audio_transcript.delta",
399
+ item_id: "reply",
400
+ delta: "Good ",
401
+ });
402
+ channel.emit({
403
+ type: "input_audio_buffer.committed",
404
+ item_id: "next-user",
405
+ });
406
+ const user = {
407
+ type: "conversation.item.input_audio_transcription.completed",
408
+ item_id: "next-user",
409
+ transcript: "Next question",
410
+ };
411
+ if (early) channel.emit(user);
412
+ channel.emit({
413
+ type: "response.output_audio_transcript.delta",
414
+ item_id: "reply",
415
+ delta: "morning",
416
+ });
417
+ channel.emit({
418
+ type: "response.output_audio_transcript.done",
419
+ item_id: "reply",
420
+ transcript: "Good morning",
421
+ });
422
+ if (!early) channel.emit(user);
423
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
424
+ { role: "assistant", text: "Good ", isFinal: false },
425
+ { role: "assistant", text: "Good morning", isFinal: false },
426
+ { role: "assistant", text: "Good morning", isFinal: true },
427
+ { role: "user", text: "Next question", isFinal: true },
428
+ ]);
429
+ },
430
+ );
431
+
432
+ it("waits for both typed item and response acceptance without echoing the transcript", async () => {
433
+ const { session, channel } = await connected();
434
+ const onTranscript = vi.fn();
435
+ const accepted = vi.fn();
436
+ session.onTranscript(onTranscript);
437
+ channel.emit({ type: "response.created" });
438
+ channel.emit({ type: "output_audio_buffer.started" });
439
+ const sent = Promise.resolve(session.sendText!("Change the topic"));
440
+ void sent.then(accepted);
441
+ const events = channel.events();
442
+ expect(events.map((event) => event.type)).toEqual([
443
+ "response.cancel",
444
+ "output_audio_buffer.clear",
445
+ "conversation.item.create",
446
+ ]);
447
+ expect(events[2].item).toMatchObject({
448
+ role: "user",
449
+ content: [{ type: "input_text", text: "Change the topic" }],
450
+ });
451
+ expect(accepted).not.toHaveBeenCalled();
452
+ channel.emit({
453
+ type: "conversation.item.added",
454
+ item: { id: "unrelated" },
455
+ });
456
+ expect(accepted).not.toHaveBeenCalled();
457
+ channel.emit({
458
+ type: "conversation.item.added",
459
+ item: { id: events[2].item.id },
460
+ });
461
+ await vi.waitFor(() =>
462
+ expect(channel.events().at(-1).type).toBe("response.create"),
463
+ );
464
+ expect(accepted).not.toHaveBeenCalled();
465
+ channel.emit({
466
+ type: "response.created",
467
+ response: { metadata: { aui_request_id: "unrelated" } },
468
+ });
469
+ await Promise.resolve();
470
+ expect(accepted).not.toHaveBeenCalled();
471
+ const response = channel.events().at(-1);
472
+ expect(response.response.metadata.aui_request_id).toBe(response.event_id);
473
+ channel.emit({
474
+ type: "response.created",
475
+ response: { metadata: response.response.metadata },
476
+ });
477
+ await sent;
478
+ expect(onTranscript).not.toHaveBeenCalled();
479
+ });
480
+
481
+ it("rejects a typed send whose response request fails after the item was accepted", async () => {
482
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
483
+ try {
484
+ const { session, channel } = await connected();
485
+ const rejected = expect(session.sendText!("Hello")).rejects.toThrow(
486
+ "Already responding",
487
+ );
488
+ const item = channel.events().at(-1);
489
+ channel.emit({
490
+ type: "conversation.item.added",
491
+ item: { id: item.item.id },
492
+ });
493
+ await vi.waitFor(() =>
494
+ expect(channel.events().at(-1).type).toBe("response.create"),
495
+ );
496
+ channel.emit({
497
+ type: "error",
498
+ error: {
499
+ event_id: channel.events().at(-1).event_id,
500
+ code: "conversation_already_has_active_response",
501
+ message: "Already responding",
502
+ },
503
+ });
504
+ await rejected;
505
+ expect(session.status.type).toBe("running");
506
+ expect(track.stop).not.toHaveBeenCalled();
507
+ } finally {
508
+ warn.mockRestore();
509
+ }
510
+ });
511
+
512
+ it("rejects a pending text send on disconnect", async () => {
513
+ const { session } = await connected();
514
+ const sent = session.sendText!("Hello");
515
+ const rejected = expect(sent).rejects.toThrow(
516
+ "disconnected before accepting",
517
+ );
518
+ session.disconnect();
519
+ await rejected;
520
+ });
521
+
522
+ it.each(["disconnect", "timeout"])(
523
+ "rejects a typed send on %s while waiting for response acceptance",
524
+ async (failure) => {
525
+ const { session, channel } = await connected();
526
+ vi.useFakeTimers();
527
+ const rejected = expect(session.sendText!("Hello")).rejects.toThrow(
528
+ failure === "disconnect"
529
+ ? "disconnected before accepting"
530
+ : "did not acknowledge",
531
+ );
532
+ channel.emit({
533
+ type: "conversation.item.added",
534
+ item: { id: channel.events().at(-1).item.id },
535
+ });
536
+ await vi.waitFor(() =>
537
+ expect(channel.events().at(-1).type).toBe("response.create"),
538
+ );
539
+ if (failure === "disconnect") session.disconnect();
540
+ else await vi.advanceTimersByTimeAsync(10_000);
541
+ await rejected;
542
+ session.disconnect();
543
+ expect(vi.getTimerCount()).toBe(0);
544
+ },
545
+ );
546
+
547
+ it("rejects an unacknowledged text send instead of committing it to history", async () => {
548
+ const { session } = await connected();
549
+ vi.useFakeTimers();
550
+ const rejected = expect(session.sendText!("Hello")).rejects.toThrow(
551
+ "did not acknowledge",
552
+ );
553
+ await vi.advanceTimersByTimeAsync(10_000);
554
+ await rejected;
555
+ });
556
+
557
+ it("stops microphone capture, playback, timers, and WebRTC exactly once", async () => {
558
+ vi.useFakeTimers();
559
+ const { session, peer, channel } = await connected();
560
+ peer.ontrack?.({ track: { kind: "audio" } });
561
+ expect(audio.play).toHaveBeenCalledOnce();
562
+ session.disconnect();
563
+ session.disconnect();
564
+ expect(track.stop).toHaveBeenCalledOnce();
565
+ expect(audio.pause).toHaveBeenCalledOnce();
566
+ expect(audio.srcObject).toBeNull();
567
+ expect(channel.close).toHaveBeenCalledOnce();
568
+ expect(peer.close).toHaveBeenCalledOnce();
569
+ expect(vi.getTimerCount()).toBe(0);
570
+ });
571
+
572
+ it("mutes the microphone and excludes it from volume measurements", async () => {
573
+ vi.useFakeTimers();
574
+ const { session } = await connected();
575
+ const onVolume = vi.fn();
576
+ session.onVolumeChange(onVolume);
577
+ await vi.advanceTimersByTimeAsync(100);
578
+ expect(onVolume).toHaveBeenLastCalledWith(0.7);
579
+ session.mute();
580
+ expect(track.enabled).toBe(false);
581
+ await vi.advanceTimersByTimeAsync(100);
582
+ expect(onVolume).toHaveBeenLastCalledWith(0.4);
583
+ session.unmute();
584
+ expect(track.enabled).toBe(true);
585
+ });
586
+
587
+ it("applies a mute requested while microphone permission is pending", async () => {
588
+ const permission = deferred<typeof stream>();
589
+ getUserMedia.mockReturnValue(permission.promise);
590
+ const { session, peer } = start();
591
+ session.mute();
592
+ permission.resolve(stream);
593
+ await vi.waitFor(() =>
594
+ expect(peer.setRemoteDescription).toHaveBeenCalled(),
595
+ );
596
+ expect(track.enabled).toBe(false);
597
+ });
598
+
599
+ it("disposes immediately while microphone permission is pending and stops a late stream", async () => {
600
+ const permission = deferred<typeof stream>();
601
+ getUserMedia.mockReturnValue(permission.promise);
602
+ const { session, peer } = start();
603
+ session.disconnect();
604
+ await vi.waitFor(() => expect(peer.close).toHaveBeenCalledOnce());
605
+ permission.resolve(stream);
606
+ await vi.waitFor(() => expect(track.stop).toHaveBeenCalledOnce());
607
+ expect(exchangeSdp).not.toHaveBeenCalled();
608
+ });
609
+
610
+ it("aborts an in-flight SDP exchange without installing a late answer", async () => {
611
+ const answer = deferred<string>();
612
+ exchangeSdp.mockReturnValue(answer.promise);
613
+ const controller = new AbortController();
614
+ const { session, peer } = start(controller.signal);
615
+ await vi.waitFor(() => expect(exchangeSdp).toHaveBeenCalled());
616
+ const [, options] = exchangeSdp.mock.calls[0]!;
617
+ controller.abort();
618
+ expect(options.signal.aborted).toBe(true);
619
+ expect(track.stop).toHaveBeenCalledOnce();
620
+ answer.resolve("late-answer");
621
+ await Promise.resolve();
622
+ expect(peer.setRemoteDescription).not.toHaveBeenCalled();
623
+ expect(session.status).toEqual({ type: "ended", reason: "cancelled" });
624
+ });
625
+
626
+ it("cleans up on permission rejection", async () => {
627
+ const error = new Error("Permission denied");
628
+ getUserMedia.mockRejectedValue(error);
629
+ const { session, peer } = start();
630
+ await vi.waitFor(() =>
631
+ expect(session.status).toEqual({ type: "ended", reason: "error", error }),
632
+ );
633
+ expect(peer.close).toHaveBeenCalledOnce();
634
+ expect(exchangeSdp).not.toHaveBeenCalled();
635
+ });
636
+
637
+ it("cleans up on signaling rejection", async () => {
638
+ const error = new Error("Unauthorized");
639
+ exchangeSdp.mockRejectedValue(error);
640
+ const { session, peer } = start();
641
+ await vi.waitFor(() =>
642
+ expect(session.status).toEqual({ type: "ended", reason: "error", error }),
643
+ );
644
+ expect(track.stop).toHaveBeenCalledOnce();
645
+ expect(peer.close).toHaveBeenCalledOnce();
646
+ });
647
+
648
+ it("times out a connection whose channel never opens", async () => {
649
+ vi.useFakeTimers();
650
+ const { session, peer } = start();
651
+ await vi.waitFor(() =>
652
+ expect(peer.setRemoteDescription).toHaveBeenCalled(),
653
+ );
654
+ await vi.advanceTimersByTimeAsync(30_000);
655
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" });
656
+ expect(track.stop).toHaveBeenCalledOnce();
657
+ expect(peer.close).toHaveBeenCalledOnce();
658
+ });
659
+
660
+ it.each(["failed", "closed"])(
661
+ "cleans up when the peer becomes %s",
662
+ async (state) => {
663
+ const { session, peer } = await connected();
664
+ peer.connectionState = state;
665
+ peer.onconnectionstatechange?.();
666
+ expect(session.status).toMatchObject({
667
+ type: "ended",
668
+ reason: state === "failed" ? "error" : "finished",
669
+ });
670
+ expect(track.stop).toHaveBeenCalledOnce();
671
+ },
672
+ );
673
+
674
+ it("rejects the affected message on a provider error without ending the call", async () => {
675
+ const { session, channel } = await connected();
676
+ const rejected = expect(session.sendText!("Hello")).rejects.toThrow();
677
+ const id = channel.events().at(-1).event_id;
678
+ channel.emit({
679
+ type: "error",
680
+ error: { event_id: id, message: "Invalid item" },
681
+ });
682
+ await rejected;
683
+ expect(session.status.type).toBe("running");
684
+ expect(track.stop).not.toHaveBeenCalled();
685
+ });
686
+
687
+ it("ignores malformed and unrelated events", async () => {
688
+ const { session, channel } = await connected();
689
+ const onTranscript = vi.fn();
690
+ session.onTranscript(onTranscript);
691
+ channel.onmessage?.({ data: "not-json" });
692
+ for (const event of [
693
+ null,
694
+ [],
695
+ {},
696
+ { type: "session.updated" },
697
+ { type: "response.output_audio_transcript.delta" },
698
+ { type: "response.output_audio_transcript.done", item_id: "a" },
699
+ { type: "conversation.item.input_audio_transcription.completed" },
700
+ ])
701
+ channel.emit(event);
702
+ expect(onTranscript).not.toHaveBeenCalled();
703
+ expect(session.status.type).toBe("running");
704
+ });
705
+
706
+ it("reports unsupported environments through session status", async () => {
707
+ vi.stubGlobal("RTCPeerConnection", undefined);
708
+ const session = new OpenAIRealtimeVoiceAdapter({ exchangeSdp }).connect({});
709
+ await vi.waitFor(() =>
710
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" }),
711
+ );
712
+ expect(getUserMedia).not.toHaveBeenCalled();
713
+ });
714
+
715
+ it("does not restart the volume meter when a status listener disconnects", async () => {
716
+ vi.useFakeTimers();
717
+ const { session, peer, channel } = start();
718
+ await vi.waitFor(() =>
719
+ expect(peer.setRemoteDescription).toHaveBeenCalled(),
720
+ );
721
+ session.onStatusChange((status) => {
722
+ if (status.type === "running") session.disconnect();
723
+ });
724
+ channel.open();
725
+ expect(session.status).toEqual({ type: "ended", reason: "cancelled" });
726
+ expect(vi.getTimerCount()).toBe(0);
727
+ });
728
+
729
+ it("ends the session when remote audio cannot be played", async () => {
730
+ const { session, peer } = await connected();
731
+ const error = new Error("Autoplay blocked");
732
+ audio.play.mockRejectedValue(error);
733
+ peer.ontrack?.({ track: { kind: "audio" } });
734
+ await vi.waitFor(() =>
735
+ expect(session.status).toEqual({ type: "ended", reason: "error", error }),
736
+ );
737
+ expect(track.stop).toHaveBeenCalledOnce();
738
+ });
739
+
740
+ it("does not allocate media for an already-aborted connection", () => {
741
+ const session = new OpenAIRealtimeVoiceAdapter({ exchangeSdp }).connect({
742
+ abortSignal: AbortSignal.abort(),
743
+ });
744
+ expect(session.status).toEqual({ type: "ended", reason: "cancelled" });
745
+ expect(Peer.instances).toHaveLength(0);
746
+ expect(getUserMedia).not.toHaveBeenCalled();
747
+ });
748
+ });