@assistant-ui/react 0.15.31 → 0.15.33

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 (228) hide show
  1. package/dist/_virtual/_rolldown/runtime.js +1 -2
  2. package/dist/adapters/GeminiLiveVoiceAdapter.d.ts +26 -0
  3. package/dist/adapters/GeminiLiveVoiceAdapter.d.ts.map +1 -0
  4. package/dist/adapters/GeminiLiveVoiceAdapter.js +346 -0
  5. package/dist/adapters/GeminiLiveVoiceAdapter.js.map +1 -0
  6. package/dist/adapters/OpenAIRealtimeVoiceAdapter.js +1 -2
  7. package/dist/adapters/gemini-live/audio.d.ts +27 -0
  8. package/dist/adapters/gemini-live/audio.d.ts.map +1 -0
  9. package/dist/adapters/gemini-live/audio.js +291 -0
  10. package/dist/adapters/gemini-live/audio.js.map +1 -0
  11. package/dist/assistant-transport.js +1 -2
  12. package/dist/cloud-renderer/CloudRendererHost.js +1 -2
  13. package/dist/context/ReadonlyStore.js +1 -2
  14. package/dist/context/providers/MessageProvider.js +1 -2
  15. package/dist/context/providers/ThreadViewportProvider.js +1 -2
  16. package/dist/context/react/ThreadViewportContext.js +1 -2
  17. package/dist/context/react/utils/createContextHook.js +1 -2
  18. package/dist/context/react/utils/createContextStoreHook.js +1 -2
  19. package/dist/context/stores/ThreadViewport.js +2 -3
  20. package/dist/devtools/DevToolsHooks.js +1 -2
  21. package/dist/hooks/useMessageQuote.js +1 -2
  22. package/dist/hooks/useMessageTiming.js +1 -2
  23. package/dist/hooks/useToolCallElapsed.js +1 -2
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -1
  27. package/dist/internal.js +2 -3
  28. package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -2
  29. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  30. package/dist/mcp-apps/McpAppRenderer.js +4 -4
  31. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  32. package/dist/mcp-apps/McpAppsRemoteHost.js +1 -2
  33. package/dist/mcp-apps/app-frame.js +2 -3
  34. package/dist/mcp-apps/bridge.js +1 -2
  35. package/dist/mcp-apps/csp.js +1 -2
  36. package/dist/mcp-apps/types.js +1 -2
  37. package/dist/mcp-apps/utils.js +1 -2
  38. package/dist/model-context/frame/useAssistantFrameHost.js +1 -2
  39. package/dist/model-context/makeAssistantVisible.js +1 -2
  40. package/dist/primitives/actionBar/ActionBarCopy.js +1 -2
  41. package/dist/primitives/actionBar/ActionBarEdit.js +1 -2
  42. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -2
  43. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -2
  44. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -2
  45. package/dist/primitives/actionBar/ActionBarInteractionContext.js +1 -2
  46. package/dist/primitives/actionBar/ActionBarReload.js +1 -2
  47. package/dist/primitives/actionBar/ActionBarRoot.js +1 -2
  48. package/dist/primitives/actionBar/ActionBarSpeak.js +1 -2
  49. package/dist/primitives/actionBar/ActionBarStopSpeaking.js +1 -2
  50. package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -2
  51. package/dist/primitives/actionBar.js +1 -2
  52. package/dist/primitives/actionBarMore/ActionBarMoreContent.js +1 -2
  53. package/dist/primitives/actionBarMore/ActionBarMoreItem.js +1 -2
  54. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +1 -2
  55. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js +1 -2
  56. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js +1 -2
  57. package/dist/primitives/actionBarMore/scope.js +1 -2
  58. package/dist/primitives/actionBarMore.js +1 -2
  59. package/dist/primitives/assistantModal/AssistantModalAnchor.js +1 -2
  60. package/dist/primitives/assistantModal/AssistantModalContent.js +1 -2
  61. package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -2
  62. package/dist/primitives/assistantModal/AssistantModalTrigger.js +1 -2
  63. package/dist/primitives/assistantModal/popoverRenderPrimitives.js +1 -2
  64. package/dist/primitives/assistantModal/scope.js +1 -2
  65. package/dist/primitives/assistantModal.js +1 -2
  66. package/dist/primitives/attachment/AttachmentName.js +1 -2
  67. package/dist/primitives/attachment/AttachmentRemove.js +1 -2
  68. package/dist/primitives/attachment/AttachmentRoot.js +1 -2
  69. package/dist/primitives/attachment/AttachmentThumb.js +1 -2
  70. package/dist/primitives/attachment.js +1 -2
  71. package/dist/primitives/branchPicker/BranchPickerCount.js +1 -2
  72. package/dist/primitives/branchPicker/BranchPickerNext.js +1 -2
  73. package/dist/primitives/branchPicker/BranchPickerNumber.js +1 -2
  74. package/dist/primitives/branchPicker/BranchPickerPrevious.js +1 -2
  75. package/dist/primitives/branchPicker/BranchPickerRoot.js +1 -2
  76. package/dist/primitives/branchPicker.js +1 -2
  77. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts +5 -4
  78. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts.map +1 -1
  79. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +57 -20
  80. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  81. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js +1 -2
  82. package/dist/primitives/chainOfThought.js +1 -2
  83. package/dist/primitives/composer/ComposerAddAttachment.js +1 -2
  84. package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -2
  85. package/dist/primitives/composer/ComposerCancel.js +1 -2
  86. package/dist/primitives/composer/ComposerCompactContext.js +1 -2
  87. package/dist/primitives/composer/ComposerDictate.js +1 -2
  88. package/dist/primitives/composer/ComposerDictationTranscript.js +1 -2
  89. package/dist/primitives/composer/ComposerInput.js +1 -2
  90. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -2
  91. package/dist/primitives/composer/ComposerQuote.js +1 -2
  92. package/dist/primitives/composer/ComposerResume.js +1 -2
  93. package/dist/primitives/composer/ComposerRoot.js +1 -2
  94. package/dist/primitives/composer/ComposerSend.js +1 -2
  95. package/dist/primitives/composer/ComposerStopDictation.js +1 -2
  96. package/dist/primitives/composer/composerInputSelector.js +1 -2
  97. package/dist/primitives/composer/trigger/TriggerPopover.js +1 -2
  98. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -2
  99. package/dist/primitives/composer/trigger/TriggerPopoverBack.js +1 -2
  100. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +1 -2
  101. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -2
  102. package/dist/primitives/composer/trigger/TriggerPopoverItems.js +1 -2
  103. package/dist/primitives/composer/trigger/TriggerPopoverResource.js +1 -2
  104. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +1 -2
  105. package/dist/primitives/composer/trigger/detectTrigger.js +1 -2
  106. package/dist/primitives/composer/trigger/index.js +1 -2
  107. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +1 -2
  108. package/dist/primitives/composer/trigger/triggerDetectionResource.js +1 -2
  109. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +1 -2
  110. package/dist/primitives/composer/trigger/triggerNavigationResource.js +1 -2
  111. package/dist/primitives/composer/trigger/triggerSelectionResource.js +1 -2
  112. package/dist/primitives/composer/useComposerCancelWithFocus.js +1 -2
  113. package/dist/primitives/composer/useComposerInputState.js +1 -2
  114. package/dist/primitives/composer.js +1 -2
  115. package/dist/primitives/dropdownMenuRenderPrimitives.js +1 -2
  116. package/dist/primitives/error/ErrorMessage.js +1 -2
  117. package/dist/primitives/error/ErrorRoot.js +1 -2
  118. package/dist/primitives/error.js +1 -2
  119. package/dist/primitives/message/MessageError.js +1 -2
  120. package/dist/primitives/message/MessageIf.js +1 -2
  121. package/dist/primitives/message/MessageParts.js +1 -2
  122. package/dist/primitives/message/MessagePartsGrouped.js +2 -3
  123. package/dist/primitives/message/MessageRoot.js +1 -2
  124. package/dist/primitives/message.js +1 -2
  125. package/dist/primitives/messagePart/MessagePartImage.js +1 -2
  126. package/dist/primitives/messagePart/MessagePartText.js +1 -2
  127. package/dist/primitives/messagePart/useMessagePartData.js +1 -2
  128. package/dist/primitives/messagePart/useMessagePartFile.js +1 -2
  129. package/dist/primitives/messagePart/useMessagePartImage.js +1 -2
  130. package/dist/primitives/messagePart/useMessagePartReasoning.js +1 -2
  131. package/dist/primitives/messagePart/useMessagePartSource.js +1 -2
  132. package/dist/primitives/messagePart/useMessagePartText.js +1 -2
  133. package/dist/primitives/messagePart.js +1 -2
  134. package/dist/primitives/queueItem/QueueItemRemove.js +1 -2
  135. package/dist/primitives/queueItem/QueueItemSteer.js +1 -2
  136. package/dist/primitives/queueItem/QueueItemText.js +1 -2
  137. package/dist/primitives/queueItem.js +1 -2
  138. package/dist/primitives/reasoning/useScrollLock.d.ts.map +1 -1
  139. package/dist/primitives/reasoning/useScrollLock.js +41 -25
  140. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  141. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -2
  142. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +1 -2
  143. package/dist/primitives/selectionToolbar.js +1 -2
  144. package/dist/primitives/suggestion/SuggestionDescription.js +1 -2
  145. package/dist/primitives/suggestion/SuggestionTitle.js +1 -2
  146. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -2
  147. package/dist/primitives/suggestion.js +1 -2
  148. package/dist/primitives/thread/ThreadEmpty.js +1 -2
  149. package/dist/primitives/thread/ThreadIf.js +1 -2
  150. package/dist/primitives/thread/ThreadLoadEarlier.js +1 -2
  151. package/dist/primitives/thread/ThreadRoot.js +1 -2
  152. package/dist/primitives/thread/ThreadRootElementContext.js +1 -2
  153. package/dist/primitives/thread/ThreadScrollToBottom.js +1 -2
  154. package/dist/primitives/thread/ThreadSuggestion.js +1 -2
  155. package/dist/primitives/thread/ThreadViewport.js +1 -2
  156. package/dist/primitives/thread/ThreadViewportFooter.js +1 -2
  157. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +1 -2
  158. package/dist/primitives/thread/topAnchor/createReserveObservers.js +1 -2
  159. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +1 -2
  160. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +1 -2
  161. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +1 -2
  162. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +1 -2
  163. package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -2
  164. package/dist/primitives/thread.js +1 -2
  165. package/dist/primitives/threadList/ThreadListLoadMore.js +1 -2
  166. package/dist/primitives/threadList/ThreadListNew.js +1 -2
  167. package/dist/primitives/threadList/ThreadListRoot.js +1 -2
  168. package/dist/primitives/threadList.js +1 -2
  169. package/dist/primitives/threadListFocusGroup.js +1 -2
  170. package/dist/primitives/threadListItem/ThreadListItemArchive.js +1 -2
  171. package/dist/primitives/threadListItem/ThreadListItemDelete.js +1 -2
  172. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  173. package/dist/primitives/threadListItem/ThreadListItemRoot.js +2 -3
  174. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  175. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +1 -2
  176. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js +1 -2
  177. package/dist/primitives/threadListItem.js +1 -2
  178. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -2
  179. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js +1 -2
  180. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js +1 -2
  181. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js +1 -2
  182. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -2
  183. package/dist/primitives/threadListItemMore/scope.js +1 -2
  184. package/dist/primitives/threadListItemMore.js +1 -2
  185. package/dist/sandbox-host/SandboxHost.js +2 -3
  186. package/dist/unstable/useComposerInput.js +1 -2
  187. package/dist/unstable/useComposerInputHistory.js +2 -3
  188. package/dist/unstable/useLiveCompletionAdapter.js +1 -2
  189. package/dist/unstable/useMentionAdapter.js +1 -2
  190. package/dist/unstable/useMessageStallDetection.js +1 -2
  191. package/dist/unstable/useModelContextSnapshot.js +1 -2
  192. package/dist/unstable/useSlashCommandAdapter.js +1 -2
  193. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +1 -2
  194. package/dist/unstable/webmcp/convertTools.js +1 -2
  195. package/dist/unstable/webmcp/useWebMcpProvider.js +1 -2
  196. package/dist/unstable/webmcp/webmcp-host.js +1 -2
  197. package/dist/utils/Primitive.js +1 -2
  198. package/dist/utils/createActionButton.js +1 -2
  199. package/dist/utils/getSelectionMessageId.js +1 -2
  200. package/dist/utils/hooks/useManagedRef.js +1 -2
  201. package/dist/utils/hooks/useMediaQuery.js +1 -2
  202. package/dist/utils/hooks/useOnScrollToBottom.js +1 -2
  203. package/dist/utils/hooks/useSizeHandle.js +1 -2
  204. package/dist/utils/isCompositionKey.js +1 -2
  205. package/dist/utils/smooth/SmoothContext.js +1 -2
  206. package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
  207. package/dist/utils/smooth/useSmooth.js +11 -4
  208. package/dist/utils/smooth/useSmooth.js.map +1 -1
  209. package/dist/utils/useIsomorphicLayoutEffect.js +0 -1
  210. package/package.json +8 -8
  211. package/src/adapters/GeminiLiveVoiceAdapter.test.ts +992 -0
  212. package/src/adapters/GeminiLiveVoiceAdapter.ts +454 -0
  213. package/src/adapters/gemini-live/audio.test.ts +880 -0
  214. package/src/adapters/gemini-live/audio.ts +333 -0
  215. package/src/index.test.ts +34 -0
  216. package/src/index.ts +4 -0
  217. package/src/mcp-apps/McpAppRenderer.composer.test.tsx +111 -0
  218. package/src/mcp-apps/McpAppRenderer.test.tsx +24 -23
  219. package/src/mcp-apps/McpAppRenderer.tsx +9 -1
  220. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.test.tsx +91 -0
  221. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.tsx +54 -0
  222. package/src/primitives/reasoning/useScrollLock.test.tsx +71 -2
  223. package/src/primitives/reasoning/useScrollLock.ts +78 -51
  224. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +4 -1
  225. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +25 -0
  226. package/src/utils/smooth/useSmooth.test.tsx +113 -4
  227. package/src/utils/smooth/useSmooth.ts +16 -2
  228. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.ts +0 -49
@@ -0,0 +1,992 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { GeminiLiveVoiceAdapter } from "./GeminiLiveVoiceAdapter";
3
+
4
+ const audio = vi.hoisted(() => {
5
+ const microphone = {
6
+ setMuted: vi.fn(),
7
+ level: vi.fn(() => 0.7),
8
+ close: vi.fn(async () => {}),
9
+ };
10
+ const player = {
11
+ enqueue: vi.fn(),
12
+ clear: vi.fn(),
13
+ isPlaying: vi.fn(() => false),
14
+ level: vi.fn(() => 0.4),
15
+ close: vi.fn(async () => {}),
16
+ };
17
+ return {
18
+ microphone,
19
+ player,
20
+ onChunk: undefined as ((base64: string) => void) | undefined,
21
+ onIdle: undefined as (() => void) | undefined,
22
+ openMicrophone: vi.fn(
23
+ async (options: {
24
+ onChunk: (base64: string) => void;
25
+ signal?: AbortSignal;
26
+ }) => {
27
+ audio.onChunk = options.onChunk;
28
+ return microphone;
29
+ },
30
+ ),
31
+ createPcmPlayer: vi.fn((options: { onIdle?: () => void }) => {
32
+ audio.onIdle = options.onIdle;
33
+ return player;
34
+ }),
35
+ };
36
+ });
37
+
38
+ vi.mock("./gemini-live/audio", async (importOriginal) => ({
39
+ ...(await importOriginal()),
40
+ openMicrophone: audio.openMicrophone,
41
+ createPcmPlayer: audio.createPcmPlayer,
42
+ }));
43
+
44
+ class Socket {
45
+ static instances: Socket[] = [];
46
+ static CONNECTING = 0;
47
+ static OPEN = 1;
48
+ static CLOSING = 2;
49
+ static CLOSED = 3;
50
+ readyState = 0;
51
+ binaryType = "blob";
52
+ onopen: (() => void) | null = null;
53
+ onclose: ((event: { code: number; reason: string }) => void) | null = null;
54
+ onerror: (() => void) | null = null;
55
+ onmessage: ((event: { data: unknown }) => void) | null = null;
56
+ send = vi.fn<(data: string) => void>();
57
+ close = vi.fn(() => {
58
+ this.readyState = 3;
59
+ this.onclose?.({ code: 1000, reason: "" });
60
+ });
61
+ url: string;
62
+ constructor(url: string) {
63
+ this.url = url;
64
+ Socket.instances.push(this);
65
+ }
66
+ open() {
67
+ this.readyState = 1;
68
+ this.onopen?.();
69
+ }
70
+ emit(message: unknown) {
71
+ this.onmessage?.({ data: JSON.stringify(message) });
72
+ }
73
+ events() {
74
+ return this.send.mock.calls.map(([data]) => JSON.parse(data));
75
+ }
76
+ }
77
+
78
+ const getToken = vi.fn<(options: { signal: AbortSignal }) => Promise<string>>(
79
+ async () => "ephemeral-token",
80
+ );
81
+ const sessions: ReturnType<GeminiLiveVoiceAdapter["connect"]>[] = [];
82
+
83
+ const start = (abortSignal?: AbortSignal) => {
84
+ const session = new GeminiLiveVoiceAdapter({
85
+ getToken,
86
+ model: "live-test-model",
87
+ }).connect(abortSignal ? { abortSignal } : {});
88
+ sessions.push(session);
89
+ return session;
90
+ };
91
+
92
+ const opened = async (abortSignal?: AbortSignal) => {
93
+ const session = start(abortSignal);
94
+ await vi.waitFor(() => expect(Socket.instances).toHaveLength(1));
95
+ const socket = Socket.instances[0]!;
96
+ socket.open();
97
+ return { session, socket };
98
+ };
99
+
100
+ const connected = async () => {
101
+ const result = await opened();
102
+ result.socket.emit({ setupComplete: {} });
103
+ await vi.waitFor(() => expect(result.session.status.type).toBe("running"));
104
+ return result;
105
+ };
106
+
107
+ const reply = (text: string) => ({
108
+ serverContent: {
109
+ modelTurn: { parts: [{ inlineData: { data: "AQID" } }] },
110
+ outputTranscription: { text },
111
+ },
112
+ });
113
+
114
+ const flush = async () => {
115
+ await new Promise((resolve) => setTimeout(resolve, 0));
116
+ };
117
+
118
+ beforeEach(() => {
119
+ Socket.instances = [];
120
+ audio.onChunk = undefined;
121
+ audio.onIdle = undefined;
122
+ audio.player.isPlaying.mockReturnValue(false);
123
+ getToken.mockResolvedValue("ephemeral-token");
124
+ vi.stubGlobal("WebSocket", Socket);
125
+ vi.stubGlobal("AudioContext", class {});
126
+ vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } });
127
+ });
128
+
129
+ afterEach(() => {
130
+ for (const session of sessions.splice(0)) session.disconnect();
131
+ vi.unstubAllGlobals();
132
+ vi.useRealTimers();
133
+ });
134
+
135
+ describe("GeminiLiveVoiceAdapter", () => {
136
+ it("fetches a token, sends setup, and waits for setupComplete before running", async () => {
137
+ const { session, socket } = await opened();
138
+ expect(getToken).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) });
139
+ expect(socket.url).toBe(
140
+ "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained?access_token=ephemeral-token",
141
+ );
142
+ expect(socket.events()).toEqual([
143
+ {
144
+ setup: {
145
+ model: "models/live-test-model",
146
+ generationConfig: { responseModalities: ["AUDIO"] },
147
+ inputAudioTranscription: {},
148
+ outputAudioTranscription: {},
149
+ },
150
+ },
151
+ ]);
152
+ expect(session.status).toEqual({ type: "starting" });
153
+ expect(audio.openMicrophone).toHaveBeenCalledWith(
154
+ expect.objectContaining({ sampleRate: 16000 }),
155
+ );
156
+ expect(audio.createPcmPlayer).toHaveBeenCalledWith(
157
+ expect.objectContaining({ sampleRate: 24000 }),
158
+ );
159
+ const onMode = vi.fn();
160
+ session.onModeChange(onMode);
161
+ socket.emit({ setupComplete: {} });
162
+ expect(session.status).toEqual({ type: "running" });
163
+ expect(onMode).toHaveBeenCalledWith("listening");
164
+ expect(session.sendText).toBeTypeOf("function");
165
+ });
166
+
167
+ it("streams microphone chunks only once setup is complete", async () => {
168
+ const { socket } = await opened();
169
+ audio.onChunk?.("AAAA");
170
+ expect(socket.events()).toHaveLength(1);
171
+ socket.emit({ setupComplete: {} });
172
+ audio.onChunk?.("AAAA");
173
+ expect(socket.events().at(-1)).toEqual({
174
+ realtimeInput: {
175
+ audio: { data: "AAAA", mimeType: "audio/pcm;rate=16000" },
176
+ },
177
+ });
178
+ });
179
+
180
+ it("plays model audio and reports speaking until playback drains", async () => {
181
+ const { session, socket } = await connected();
182
+ const onMode = vi.fn();
183
+ session.onModeChange(onMode);
184
+ socket.emit({
185
+ serverContent: {
186
+ modelTurn: {
187
+ parts: [
188
+ { inlineData: { mimeType: "audio/pcm;rate=24000", data: "AQID" } },
189
+ ],
190
+ },
191
+ },
192
+ });
193
+ audio.player.isPlaying.mockReturnValue(true);
194
+ socket.emit({
195
+ serverContent: {
196
+ modelTurn: { parts: [{ inlineData: { data: "BAUG" } }] },
197
+ },
198
+ });
199
+ expect(audio.player.enqueue.mock.calls).toEqual([["AQID"], ["BAUG"]]);
200
+ expect(onMode.mock.calls).toEqual([["speaking"]]);
201
+ socket.emit({ serverContent: { turnComplete: true } });
202
+ expect(onMode.mock.calls).toEqual([["speaking"]]);
203
+ audio.onIdle?.();
204
+ expect(onMode).toHaveBeenLastCalledWith("listening");
205
+ });
206
+
207
+ it("streams cumulative assistant transcripts once the user transcription is finished", async () => {
208
+ const { session, socket } = await connected();
209
+ const onTranscript = vi.fn();
210
+ session.onTranscript(onTranscript);
211
+ socket.emit({
212
+ serverContent: { interimInputTranscription: { text: "Hel" } },
213
+ });
214
+ socket.emit({ serverContent: { inputTranscription: { text: "Hel" } } });
215
+ socket.emit({
216
+ serverContent: { inputTranscription: { text: "lo", finished: true } },
217
+ });
218
+ socket.emit(reply("Good "));
219
+ socket.emit({
220
+ serverContent: { outputTranscription: { text: "morning" } },
221
+ });
222
+ socket.emit({ serverContent: { generationComplete: true } });
223
+ socket.emit({ serverContent: { turnComplete: true } });
224
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
225
+ { role: "user", text: "Hello", isFinal: true },
226
+ { role: "assistant", text: "Good ", isFinal: false },
227
+ { role: "assistant", text: "Good morning", isFinal: false },
228
+ { role: "assistant", text: "Good morning", isFinal: true },
229
+ ]);
230
+ });
231
+
232
+ it("keeps late input transcription in the spoken turn by holding the reply transcript", async () => {
233
+ const { session, socket } = await connected();
234
+ const onTranscript = vi.fn();
235
+ session.onTranscript(onTranscript);
236
+ socket.emit({ serverContent: { inputTranscription: { text: "What's" } } });
237
+ socket.emit({
238
+ serverContent: { inputTranscription: { text: " the weather?" } },
239
+ });
240
+ socket.emit({
241
+ serverContent: {
242
+ modelTurn: { parts: [{ inlineData: { data: "AQID" } }] },
243
+ },
244
+ });
245
+ socket.emit(reply("Sunny"));
246
+ socket.emit({
247
+ serverContent: { inputTranscription: { text: " Thanks." } },
248
+ });
249
+ socket.emit({ serverContent: { outputTranscription: { text: " today" } } });
250
+ expect(onTranscript).not.toHaveBeenCalled();
251
+ expect(audio.player.enqueue).toHaveBeenCalledWith("AQID");
252
+ socket.emit({ serverContent: { turnComplete: true } });
253
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
254
+ { role: "user", text: "What's the weather? Thanks.", isFinal: true },
255
+ { role: "assistant", text: "Sunny today", isFinal: true },
256
+ ]);
257
+ });
258
+
259
+ it("releases the held reply transcript when the user transcription finishes mid-reply", async () => {
260
+ const { session, socket } = await connected();
261
+ const onTranscript = vi.fn();
262
+ session.onTranscript(onTranscript);
263
+ socket.emit({ serverContent: { inputTranscription: { text: "Hi" } } });
264
+ socket.emit(reply("Hello"));
265
+ socket.emit({
266
+ serverContent: { inputTranscription: { text: " there", finished: true } },
267
+ });
268
+ socket.emit({ serverContent: { outputTranscription: { text: "!" } } });
269
+ socket.emit({ serverContent: { turnComplete: true } });
270
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
271
+ { role: "user", text: "Hi there", isFinal: true },
272
+ { role: "assistant", text: "Hello", isFinal: false },
273
+ { role: "assistant", text: "Hello!", isFinal: false },
274
+ { role: "assistant", text: "Hello!", isFinal: true },
275
+ ]);
276
+ });
277
+
278
+ it("carries speech that arrives after the reply streams into the next turn", async () => {
279
+ const { session, socket } = await connected();
280
+ const onTranscript = vi.fn();
281
+ session.onTranscript(onTranscript);
282
+ socket.emit({
283
+ serverContent: { inputTranscription: { text: "Hi", finished: true } },
284
+ });
285
+ socket.emit(reply("Hello"));
286
+ socket.emit({ serverContent: { inputTranscription: { text: "Again" } } });
287
+ socket.emit({ serverContent: { turnComplete: true } });
288
+ socket.emit(reply("Sure"));
289
+ socket.emit({ serverContent: { turnComplete: true } });
290
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
291
+ { role: "user", text: "Hi", isFinal: true },
292
+ { role: "assistant", text: "Hello", isFinal: false },
293
+ { role: "assistant", text: "Hello", isFinal: true },
294
+ { role: "user", text: "Again", isFinal: true },
295
+ { role: "assistant", text: "Sure", isFinal: true },
296
+ ]);
297
+ });
298
+
299
+ it("flushes input transcription when the server marks it finished or the turn ends", async () => {
300
+ const { session, socket } = await connected();
301
+ const onTranscript = vi.fn();
302
+ session.onTranscript(onTranscript);
303
+ socket.emit({ serverContent: { inputTranscription: { text: "Stop" } } });
304
+ socket.emit({
305
+ serverContent: { inputTranscription: { text: " now", finished: true } },
306
+ });
307
+ expect(onTranscript).toHaveBeenLastCalledWith({
308
+ role: "user",
309
+ text: "Stop now",
310
+ isFinal: true,
311
+ });
312
+ socket.emit({ serverContent: { inputTranscription: { text: "Bye" } } });
313
+ socket.emit({ serverContent: { turnComplete: true } });
314
+ expect(onTranscript).toHaveBeenLastCalledWith({
315
+ role: "user",
316
+ text: "Bye",
317
+ isFinal: true,
318
+ });
319
+ socket.emit({ serverContent: { inputTranscription: { text: "Wait" } } });
320
+ socket.emit({ serverContent: { interrupted: true } });
321
+ expect(onTranscript).toHaveBeenLastCalledWith({
322
+ role: "user",
323
+ text: "Wait",
324
+ isFinal: true,
325
+ });
326
+ expect(onTranscript).toHaveBeenCalledTimes(3);
327
+ });
328
+
329
+ it("clears playback and finalizes the reply when the user interrupts", async () => {
330
+ const { session, socket } = await connected();
331
+ const onTranscript = vi.fn();
332
+ session.onTranscript(onTranscript);
333
+ const onMode = vi.fn();
334
+ session.onModeChange(onMode);
335
+ audio.player.isPlaying.mockReturnValue(true);
336
+ socket.emit(reply("Let me"));
337
+ socket.emit({ serverContent: { interrupted: true } });
338
+ expect(audio.player.clear).toHaveBeenCalledOnce();
339
+ expect(onMode).toHaveBeenLastCalledWith("listening");
340
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
341
+ { role: "assistant", text: "Let me", isFinal: true },
342
+ ]);
343
+ socket.emit({ serverContent: { turnComplete: true } });
344
+ expect(onTranscript).toHaveBeenCalledOnce();
345
+ });
346
+
347
+ it("mutes the microphone and tells Gemini the audio stream paused", async () => {
348
+ const { session, socket } = await connected();
349
+ session.mute();
350
+ expect(audio.microphone.setMuted).toHaveBeenLastCalledWith(true);
351
+ expect(socket.events().at(-1)).toEqual({
352
+ realtimeInput: { audioStreamEnd: true },
353
+ });
354
+ session.unmute();
355
+ expect(audio.microphone.setMuted).toHaveBeenLastCalledWith(false);
356
+ });
357
+
358
+ it("applies a mute requested before the microphone opened", async () => {
359
+ const session = start();
360
+ session.mute();
361
+ await vi.waitFor(() => expect(Socket.instances).toHaveLength(1));
362
+ expect(audio.microphone.setMuted).toHaveBeenCalledWith(true);
363
+ });
364
+
365
+ it("reports the louder of microphone and playback, ignoring a muted microphone", async () => {
366
+ vi.useFakeTimers();
367
+ const { session } = await connected();
368
+ const onVolume = vi.fn();
369
+ session.onVolumeChange(onVolume);
370
+ await vi.advanceTimersByTimeAsync(100);
371
+ expect(onVolume).toHaveBeenLastCalledWith(0.7);
372
+ session.mute();
373
+ await vi.advanceTimersByTimeAsync(100);
374
+ expect(onVolume).toHaveBeenLastCalledWith(0.4);
375
+ });
376
+
377
+ it("resolves a typed send when the new reply starts and forwards that reply afterwards", async () => {
378
+ const { session, socket } = await connected();
379
+ const onTranscript = vi.fn();
380
+ session.onTranscript(onTranscript);
381
+ const accepted = vi.fn();
382
+ const sent = Promise.resolve(session.sendText!("Change the topic"));
383
+ void sent.then(accepted);
384
+ expect(socket.events().at(-1)).toEqual({
385
+ clientContent: {
386
+ turns: [{ role: "user", parts: [{ text: "Change the topic" }] }],
387
+ turnComplete: true,
388
+ },
389
+ });
390
+ socket.emit({ serverContent: { inputTranscription: { text: "Hello" } } });
391
+ await Promise.resolve();
392
+ expect(accepted).not.toHaveBeenCalled();
393
+ socket.emit({
394
+ serverContent: {
395
+ modelTurn: { parts: [{ inlineData: { data: "AQID" } }] },
396
+ outputTranscription: { text: "Sure" },
397
+ },
398
+ });
399
+ await sent;
400
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
401
+ { role: "user", text: "Hello", isFinal: true },
402
+ ]);
403
+ expect(audio.player.enqueue).not.toHaveBeenCalled();
404
+ await flush();
405
+ expect(onTranscript).toHaveBeenLastCalledWith({
406
+ role: "assistant",
407
+ text: "Sure",
408
+ isFinal: false,
409
+ });
410
+ expect(audio.player.enqueue).toHaveBeenCalledWith("AQID");
411
+ });
412
+
413
+ it("finishes the interrupted reply before accepting a typed send", async () => {
414
+ const { session, socket } = await connected();
415
+ const onTranscript = vi.fn();
416
+ session.onTranscript(onTranscript);
417
+ socket.emit(reply("Previous"));
418
+ audio.player.isPlaying.mockReturnValue(true);
419
+ const onMode = vi.fn();
420
+ session.onModeChange(onMode);
421
+ const accepted = vi.fn();
422
+ const sent = Promise.resolve(session.sendText!("Change the topic"));
423
+ void sent.then(accepted);
424
+ expect(audio.player.clear).toHaveBeenCalledOnce();
425
+ expect(onMode).toHaveBeenLastCalledWith("listening");
426
+ audio.player.isPlaying.mockReturnValue(false);
427
+ socket.emit(reply(" reply"));
428
+ await Promise.resolve();
429
+ expect(accepted).not.toHaveBeenCalled();
430
+ socket.emit({ serverContent: { interrupted: true } });
431
+ expect(audio.player.clear).toHaveBeenCalledOnce();
432
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
433
+ { role: "assistant", text: "Previous reply", isFinal: true },
434
+ ]);
435
+ await Promise.resolve();
436
+ expect(accepted).not.toHaveBeenCalled();
437
+ socket.emit(reply("New"));
438
+ await sent;
439
+ expect(onTranscript).toHaveBeenCalledOnce();
440
+ await flush();
441
+ expect(onTranscript).toHaveBeenLastCalledWith({
442
+ role: "assistant",
443
+ text: "New",
444
+ isFinal: false,
445
+ });
446
+ });
447
+
448
+ it("does not take a late transcript of the interrupted reply as the typed reply", async () => {
449
+ const { session, socket } = await connected();
450
+ const onTranscript = vi.fn();
451
+ session.onTranscript(onTranscript);
452
+ socket.emit({
453
+ serverContent: { inputTranscription: { text: "Hi", finished: true } },
454
+ });
455
+ socket.emit(reply("Let me"));
456
+ const accepted = vi.fn();
457
+ const sent = Promise.resolve(session.sendText!("Stop"));
458
+ void sent.then(accepted);
459
+ socket.emit({ serverContent: { interrupted: true } });
460
+ socket.emit({
461
+ serverContent: { outputTranscription: { text: " explain" } },
462
+ });
463
+ await flush();
464
+ expect(accepted).not.toHaveBeenCalled();
465
+ socket.emit(reply("Okay"));
466
+ await sent;
467
+ await flush();
468
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
469
+ { role: "user", text: "Hi", isFinal: true },
470
+ { role: "assistant", text: "Let me", isFinal: false },
471
+ { role: "assistant", text: "Let me", isFinal: true },
472
+ { role: "assistant", text: "Okay", isFinal: false },
473
+ ]);
474
+ });
475
+
476
+ it("does not start a reply from a transcript that trails turnComplete", async () => {
477
+ const { session, socket } = await connected();
478
+ const onTranscript = vi.fn();
479
+ session.onTranscript(onTranscript);
480
+ socket.emit({
481
+ serverContent: { inputTranscription: { text: "Hi", finished: true } },
482
+ });
483
+ socket.emit(reply("Hello"));
484
+ socket.emit({ serverContent: { turnComplete: true } });
485
+ socket.emit({ serverContent: { outputTranscription: { text: " there" } } });
486
+ socket.emit({
487
+ serverContent: { inputTranscription: { text: "Bye", finished: true } },
488
+ });
489
+ socket.emit(reply("Later"));
490
+ socket.emit({ serverContent: { turnComplete: true } });
491
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
492
+ { role: "user", text: "Hi", isFinal: true },
493
+ { role: "assistant", text: "Hello", isFinal: false },
494
+ { role: "assistant", text: "Hello", isFinal: true },
495
+ { role: "user", text: "Bye", isFinal: true },
496
+ { role: "assistant", text: "Later", isFinal: false },
497
+ { role: "assistant", text: "Later", isFinal: true },
498
+ ]);
499
+ });
500
+
501
+ it("ends with an error when a deferred typed-reply frame cannot be played", async () => {
502
+ const { session, socket } = await connected();
503
+ const sent = session.sendText!("Hello");
504
+ socket.emit(reply("One"));
505
+ audio.player.enqueue.mockImplementationOnce(() => {
506
+ throw new Error("invalid audio");
507
+ });
508
+ await sent;
509
+ await flush();
510
+ expect(session.status).toMatchObject({
511
+ type: "ended",
512
+ reason: "error",
513
+ error: expect.objectContaining({ message: "invalid audio" }),
514
+ });
515
+ });
516
+
517
+ it("does not resume the interrupted reply's audio after a typed send", async () => {
518
+ const { session, socket } = await connected();
519
+ socket.emit(reply("Long"));
520
+ audio.player.isPlaying.mockReturnValue(true);
521
+ const onMode = vi.fn();
522
+ session.onModeChange(onMode);
523
+ const sent = session.sendText!("Stop");
524
+ expect(audio.player.clear).toHaveBeenCalledOnce();
525
+ audio.player.isPlaying.mockReturnValue(false);
526
+ audio.player.enqueue.mockClear();
527
+ socket.emit(reply(" answer"));
528
+ expect(audio.player.enqueue).not.toHaveBeenCalled();
529
+ expect(onMode.mock.calls).toEqual([["listening"]]);
530
+ socket.emit({ serverContent: { interrupted: true } });
531
+ socket.emit(reply("Okay"));
532
+ await sent;
533
+ await flush();
534
+ expect(audio.player.enqueue).toHaveBeenCalledOnce();
535
+ expect(onMode).toHaveBeenLastCalledWith("speaking");
536
+ });
537
+
538
+ it("keeps frames that arrive during the deferred first chunk in order", async () => {
539
+ const { session, socket } = await connected();
540
+ const onTranscript = vi.fn();
541
+ session.onTranscript(onTranscript);
542
+ const sent = session.sendText!("Hello");
543
+ socket.emit(reply("One"));
544
+ socket.emit(reply(" two"));
545
+ socket.emit({ serverContent: { turnComplete: true } });
546
+ await sent;
547
+ expect(onTranscript).not.toHaveBeenCalled();
548
+ await flush();
549
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
550
+ { role: "assistant", text: "One", isFinal: false },
551
+ { role: "assistant", text: "One two", isFinal: false },
552
+ { role: "assistant", text: "One two", isFinal: true },
553
+ ]);
554
+ });
555
+
556
+ for (const code of [1000, 1011]) {
557
+ it(`delivers a deferred typed reply before a ${code} close`, async () => {
558
+ const { session, socket } = await connected();
559
+ const onTranscript = vi.fn();
560
+ session.onTranscript(onTranscript);
561
+ const sent = session.sendText!("Hello");
562
+ socket.emit(reply("Hi there"));
563
+ await sent;
564
+ socket.emit({ serverContent: { inputTranscription: { text: "wait" } } });
565
+ socket.readyState = Socket.CLOSED;
566
+ socket.onclose?.({ code, reason: "" });
567
+ await flush();
568
+
569
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
570
+ { role: "assistant", text: "Hi there", isFinal: false },
571
+ { role: "assistant", text: "Hi there", isFinal: true },
572
+ { role: "user", text: "wait", isFinal: true },
573
+ ]);
574
+ expect(session.status).toMatchObject({
575
+ type: "ended",
576
+ reason: code === 1000 ? "finished" : "error",
577
+ });
578
+ });
579
+ }
580
+
581
+ it("ends with an error and releases the microphone if close-time playback fails", async () => {
582
+ const { session, socket } = await connected();
583
+ const sent = session.sendText!("Hello");
584
+ socket.emit(reply("Unplayable"));
585
+ await sent;
586
+ audio.player.enqueue.mockImplementationOnce(() => {
587
+ throw new Error("invalid audio");
588
+ });
589
+ expect(() => socket.close()).not.toThrow();
590
+ expect(session.status).toMatchObject({
591
+ type: "ended",
592
+ reason: "error",
593
+ error: expect.objectContaining({ message: "invalid audio" }),
594
+ });
595
+ expect(audio.microphone.close).toHaveBeenCalledOnce();
596
+ expect(audio.player.close).toHaveBeenCalledOnce();
597
+ });
598
+
599
+ it("rejects a new typed send made by a transcript listener during an open-socket end", async () => {
600
+ const { session, socket } = await connected();
601
+ const attempted: Promise<void>[] = [];
602
+ session.onTranscript((item) => {
603
+ if (item.role !== "assistant" || item.isFinal || attempted.length > 0)
604
+ return;
605
+ const sentBefore = socket.send.mock.calls.length;
606
+ attempted.push(Promise.resolve(session.sendText!("Again")));
607
+ if (socket.send.mock.calls.length > sentBefore)
608
+ socket.emit(reply("Extra"));
609
+ });
610
+ const onTranscript = vi.fn();
611
+ session.onTranscript(onTranscript);
612
+ const sent = session.sendText!("Hello");
613
+ socket.emit(reply("First"));
614
+ await sent;
615
+ socket.onerror?.();
616
+ await expect(attempted[0]).rejects.toThrow("not connected");
617
+ expect(socket.events().filter((event) => event.clientContent)).toHaveLength(
618
+ 1,
619
+ );
620
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
621
+ { role: "assistant", text: "First", isFinal: false },
622
+ { role: "assistant", text: "First", isFinal: true },
623
+ ]);
624
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" });
625
+ });
626
+
627
+ it("reports the close code when the socket closes before setup", async () => {
628
+ const { session, socket } = await opened();
629
+ socket.readyState = 3;
630
+ socket.onclose?.({ code: 1008, reason: "invalid token" });
631
+ expect(session.status).toMatchObject({
632
+ type: "ended",
633
+ reason: "error",
634
+ error: expect.objectContaining({
635
+ message: expect.stringContaining("1008: invalid token"),
636
+ }),
637
+ });
638
+ });
639
+
640
+ it("accepts a typed send after the previous reply completed on its own", async () => {
641
+ const { session, socket } = await connected();
642
+ socket.emit(reply("Old"));
643
+ const sent = session.sendText!("Hello");
644
+ socket.emit({ serverContent: { turnComplete: true } });
645
+ socket.emit(reply("New"));
646
+ await expect(sent).resolves.toBeUndefined();
647
+ });
648
+
649
+ it("clears audio still buffered after turnComplete when text is typed", async () => {
650
+ const { session, socket } = await connected();
651
+ socket.emit({
652
+ serverContent: {
653
+ modelTurn: { parts: [{ inlineData: { data: "AQID" } }] },
654
+ turnComplete: true,
655
+ },
656
+ });
657
+ audio.player.isPlaying.mockReturnValue(true);
658
+ const onMode = vi.fn();
659
+ session.onModeChange(onMode);
660
+ const sent = session.sendText!("Next question");
661
+ expect(audio.player.clear).toHaveBeenCalledOnce();
662
+ expect(onMode).toHaveBeenLastCalledWith("listening");
663
+ socket.emit(reply("Sure"));
664
+ await expect(sent).resolves.toBeUndefined();
665
+ });
666
+
667
+ it("accepts a typed send whose reply arrives while the previous one is still deferred", async () => {
668
+ const { session, socket } = await connected();
669
+ const onTranscript = vi.fn();
670
+ session.onTranscript(onTranscript);
671
+ const first = session.sendText!("One");
672
+ socket.emit(reply("First"));
673
+ await first;
674
+ const second = session.sendText!("Two");
675
+ socket.emit({ serverContent: { interrupted: true } });
676
+ socket.emit(reply("Second"));
677
+ await flush();
678
+ await expect(second).resolves.toBeUndefined();
679
+ await flush();
680
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
681
+ { role: "assistant", text: "First", isFinal: false },
682
+ { role: "assistant", text: "First", isFinal: true },
683
+ { role: "assistant", text: "Second", isFinal: false },
684
+ ]);
685
+ });
686
+
687
+ it("rejects a typed send before setup completes", async () => {
688
+ const { session } = await opened();
689
+ await expect(session.sendText!("Hello")).rejects.toThrow("not connected");
690
+ expect(session.status).toEqual({ type: "starting" });
691
+ });
692
+
693
+ it("rejects a typed send that gets no reply", async () => {
694
+ const { session } = await connected();
695
+ vi.useFakeTimers();
696
+ const rejected = expect(session.sendText!("Hello")).rejects.toThrow(
697
+ "did not start a reply",
698
+ );
699
+ await vi.advanceTimersByTimeAsync(10_000);
700
+ await rejected;
701
+ expect(session.status.type).toBe("running");
702
+ });
703
+
704
+ it("rejects a pending typed send on disconnect and when replaced", async () => {
705
+ const { session } = await connected();
706
+ const first = expect(session.sendText!("First")).rejects.toThrow(
707
+ "replaced",
708
+ );
709
+ const second = expect(session.sendText!("Second")).rejects.toThrow(
710
+ "disconnected before accepting",
711
+ );
712
+ await first;
713
+ session.disconnect();
714
+ await second;
715
+ });
716
+
717
+ it("ends with an error when the socket closes before setup completes", async () => {
718
+ const { session, socket } = await opened();
719
+ socket.close();
720
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" });
721
+ expect(audio.microphone.close).toHaveBeenCalledOnce();
722
+ expect(audio.player.close).toHaveBeenCalledOnce();
723
+ });
724
+
725
+ it("ends normally when the server closes a running session", async () => {
726
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
727
+ try {
728
+ const { session, socket } = await connected();
729
+ socket.emit({ goAway: { timeLeft: "10s" } });
730
+ expect(warn).toHaveBeenCalledOnce();
731
+ socket.close();
732
+ expect(session.status).toEqual({ type: "ended", reason: "finished" });
733
+ } finally {
734
+ warn.mockRestore();
735
+ }
736
+ });
737
+
738
+ it("keeps separate utterances heard during a streaming reply as separate messages", async () => {
739
+ const { session, socket } = await connected();
740
+ const onTranscript = vi.fn();
741
+ session.onTranscript(onTranscript);
742
+ socket.emit({
743
+ serverContent: { inputTranscription: { text: "Hi", finished: true } },
744
+ });
745
+ socket.emit(reply("Hello"));
746
+ socket.emit({
747
+ serverContent: { inputTranscription: { text: "Again", finished: true } },
748
+ });
749
+ socket.emit({
750
+ serverContent: { inputTranscription: { text: "Wait", finished: true } },
751
+ });
752
+ socket.emit({ serverContent: { turnComplete: true } });
753
+ socket.emit(reply("Sure"));
754
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
755
+ { role: "user", text: "Hi", isFinal: true },
756
+ { role: "assistant", text: "Hello", isFinal: false },
757
+ { role: "assistant", text: "Hello", isFinal: true },
758
+ { role: "user", text: "Again", isFinal: true },
759
+ { role: "user", text: "Wait", isFinal: true },
760
+ { role: "assistant", text: "Sure", isFinal: false },
761
+ ]);
762
+ });
763
+
764
+ it("ignores the turnComplete that follows an interruption", async () => {
765
+ const { session, socket } = await connected();
766
+ const onTranscript = vi.fn();
767
+ session.onTranscript(onTranscript);
768
+ socket.emit({
769
+ serverContent: { inputTranscription: { text: "Hi", finished: true } },
770
+ });
771
+ socket.emit(reply("Sure"));
772
+ socket.emit({ serverContent: { inputTranscription: { text: "No" } } });
773
+ socket.emit({ serverContent: { interrupted: true } });
774
+ socket.emit({ serverContent: { turnComplete: true } });
775
+ socket.emit({ serverContent: { inputTranscription: { text: " wait" } } });
776
+ socket.emit(reply("Ok"));
777
+ socket.emit({ serverContent: { turnComplete: true } });
778
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
779
+ { role: "user", text: "Hi", isFinal: true },
780
+ { role: "assistant", text: "Sure", isFinal: false },
781
+ { role: "assistant", text: "Sure", isFinal: true },
782
+ { role: "user", text: "No wait", isFinal: true },
783
+ { role: "assistant", text: "Ok", isFinal: true },
784
+ ]);
785
+ });
786
+
787
+ it("keeps input transcription that arrives with the first frame of a typed reply ahead of it", async () => {
788
+ const { session, socket } = await connected();
789
+ const onTranscript = vi.fn();
790
+ session.onTranscript(onTranscript);
791
+ const sent = session.sendText!("Typed");
792
+ socket.emit({
793
+ serverContent: {
794
+ inputTranscription: { text: "Um" },
795
+ modelTurn: { parts: [{ inlineData: { data: "AQID" } }] },
796
+ outputTranscription: { text: "Sure" },
797
+ },
798
+ });
799
+ await sent;
800
+ await flush();
801
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
802
+ { role: "user", text: "Um", isFinal: true },
803
+ { role: "assistant", text: "Sure", isFinal: false },
804
+ ]);
805
+ });
806
+
807
+ it("emits unfinished speech heard during a reply when the server closes", async () => {
808
+ const { session, socket } = await connected();
809
+ const onTranscript = vi.fn();
810
+ session.onTranscript(onTranscript);
811
+ socket.emit({
812
+ serverContent: { inputTranscription: { text: "Hi", finished: true } },
813
+ });
814
+ socket.emit(reply("Hello"));
815
+ socket.emit({ serverContent: { inputTranscription: { text: "Bye" } } });
816
+ socket.close();
817
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
818
+ { role: "user", text: "Hi", isFinal: true },
819
+ { role: "assistant", text: "Hello", isFinal: false },
820
+ { role: "assistant", text: "Hello", isFinal: true },
821
+ { role: "user", text: "Bye", isFinal: true },
822
+ ]);
823
+ });
824
+
825
+ it("emits held transcripts when the socket fails", async () => {
826
+ const { session, socket } = await connected();
827
+ const onTranscript = vi.fn();
828
+ session.onTranscript(onTranscript);
829
+ socket.emit({ serverContent: { inputTranscription: { text: "Hi" } } });
830
+ socket.emit(reply("Hello"));
831
+ socket.onerror?.();
832
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
833
+ { role: "user", text: "Hi", isFinal: true },
834
+ { role: "assistant", text: "Hello", isFinal: true },
835
+ ]);
836
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" });
837
+ });
838
+
839
+ it("ends with an error when a running session closes abnormally without goAway", async () => {
840
+ const { session, socket } = await connected();
841
+ socket.readyState = 3;
842
+ socket.onclose?.({ code: 1011, reason: "internal error" });
843
+ expect(session.status).toMatchObject({
844
+ type: "ended",
845
+ reason: "error",
846
+ error: expect.objectContaining({
847
+ message: expect.stringContaining("1011: internal error"),
848
+ }),
849
+ });
850
+ });
851
+
852
+ it("emits held transcripts when the server closes mid-turn", async () => {
853
+ const { session, socket } = await connected();
854
+ const onTranscript = vi.fn();
855
+ session.onTranscript(onTranscript);
856
+ socket.emit({ serverContent: { inputTranscription: { text: "Hi" } } });
857
+ socket.emit(reply("Hello"));
858
+ socket.close();
859
+ expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([
860
+ { role: "user", text: "Hi", isFinal: true },
861
+ { role: "assistant", text: "Hello", isFinal: true },
862
+ ]);
863
+ expect(session.status).toEqual({ type: "ended", reason: "finished" });
864
+ });
865
+
866
+ it("ends with an error on socket failure", async () => {
867
+ const { session, socket } = await connected();
868
+ socket.onerror?.();
869
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" });
870
+ expect(socket.close).toHaveBeenCalledOnce();
871
+ });
872
+
873
+ it("ends with an error when the token request fails", async () => {
874
+ getToken.mockRejectedValue(new Error("Unauthorized"));
875
+ const session = start();
876
+ await vi.waitFor(() =>
877
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" }),
878
+ );
879
+ expect(Socket.instances).toHaveLength(0);
880
+ expect(audio.microphone.close).toHaveBeenCalledOnce();
881
+ });
882
+
883
+ it("ends with an error when a frame cannot be played", async () => {
884
+ const { session, socket } = await connected();
885
+ audio.player.enqueue.mockImplementationOnce(() => {
886
+ throw new Error("invalid audio");
887
+ });
888
+ socket.emit({
889
+ serverContent: { modelTurn: { parts: [{ inlineData: { data: "!" } }] } },
890
+ });
891
+ expect(session.status).toMatchObject({
892
+ type: "ended",
893
+ reason: "error",
894
+ error: expect.objectContaining({ message: "invalid audio" }),
895
+ });
896
+ });
897
+
898
+ it("decodes binary frames", async () => {
899
+ const { session, socket } = await connected();
900
+ expect(socket.binaryType).toBe("arraybuffer");
901
+ const onTranscript = vi.fn();
902
+ session.onTranscript(onTranscript);
903
+ socket.onmessage?.({
904
+ data: new TextEncoder().encode(
905
+ JSON.stringify({
906
+ serverContent: {
907
+ inputTranscription: { text: "One", finished: true },
908
+ },
909
+ }),
910
+ ).buffer,
911
+ });
912
+ expect(onTranscript).toHaveBeenCalledWith({
913
+ role: "user",
914
+ text: "One",
915
+ isFinal: true,
916
+ });
917
+ });
918
+
919
+ it("stops the socket, microphone, playback, and timers exactly once", async () => {
920
+ vi.useFakeTimers();
921
+ const { session, socket } = await connected();
922
+ session.disconnect();
923
+ session.disconnect();
924
+ expect(socket.close).toHaveBeenCalledOnce();
925
+ expect(audio.microphone.close).toHaveBeenCalledOnce();
926
+ expect(audio.player.close).toHaveBeenCalledOnce();
927
+ expect(vi.getTimerCount()).toBe(0);
928
+ expect(session.status).toEqual({ type: "ended", reason: "cancelled" });
929
+ });
930
+
931
+ it("aborts an in-flight token request and releases the microphone", async () => {
932
+ let resolveToken!: (token: string) => void;
933
+ getToken.mockReturnValue(
934
+ new Promise<string>((resolve) => {
935
+ resolveToken = resolve;
936
+ }),
937
+ );
938
+ const controller = new AbortController();
939
+ const session = start(controller.signal);
940
+ await vi.waitFor(() => expect(getToken).toHaveBeenCalled());
941
+ controller.abort();
942
+ expect(getToken.mock.calls[0]![0].signal.aborted).toBe(true);
943
+ resolveToken("late-token");
944
+ await flush();
945
+ expect(Socket.instances).toHaveLength(0);
946
+ expect(audio.microphone.close).toHaveBeenCalledOnce();
947
+ expect(session.status).toEqual({ type: "ended", reason: "cancelled" });
948
+ });
949
+
950
+ it("releases a microphone that opens after disconnect", async () => {
951
+ let resolveMicrophone!: (microphone: typeof audio.microphone) => void;
952
+ audio.openMicrophone.mockReturnValueOnce(
953
+ new Promise((resolve) => {
954
+ resolveMicrophone = resolve;
955
+ }),
956
+ );
957
+ const session = start();
958
+ await vi.waitFor(() => expect(audio.openMicrophone).toHaveBeenCalled());
959
+ session.disconnect();
960
+ resolveMicrophone(audio.microphone);
961
+ await flush();
962
+ expect(audio.microphone.close).toHaveBeenCalledOnce();
963
+ expect(Socket.instances).toHaveLength(0);
964
+ });
965
+
966
+ it("times out a connection that never completes setup", async () => {
967
+ vi.useFakeTimers();
968
+ const { session } = await opened();
969
+ await vi.advanceTimersByTimeAsync(30_000);
970
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" });
971
+ });
972
+
973
+ it("fails outside a browser", async () => {
974
+ vi.stubGlobal("WebSocket", undefined);
975
+ const session = start();
976
+ await vi.waitFor(() =>
977
+ expect(session.status).toMatchObject({ type: "ended", reason: "error" }),
978
+ );
979
+ expect(getToken).not.toHaveBeenCalled();
980
+ });
981
+
982
+ it("does not allocate anything for an already-aborted connection", () => {
983
+ const session = new GeminiLiveVoiceAdapter({
984
+ getToken,
985
+ model: "live-test-model",
986
+ }).connect({
987
+ abortSignal: AbortSignal.abort(),
988
+ });
989
+ expect(session.status).toEqual({ type: "ended", reason: "cancelled" });
990
+ expect(getToken).not.toHaveBeenCalled();
991
+ });
992
+ });