UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

993 lines (932 loc) • 37 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GeminiLiveVoiceAdapter } from "./GeminiLiveVoiceAdapter"; const audio = vi.hoisted(() => { const microphone = { setMuted: vi.fn(), level: vi.fn(() => 0.7), close: vi.fn(async () => {}), }; const player = { enqueue: vi.fn(), clear: vi.fn(), isPlaying: vi.fn(() => false), level: vi.fn(() => 0.4), close: vi.fn(async () => {}), }; return { microphone, player, onChunk: undefined as ((base64: string) => void) | undefined, onIdle: undefined as (() => void) | undefined, openMicrophone: vi.fn( async (options: { onChunk: (base64: string) => void; signal?: AbortSignal; }) => { audio.onChunk = options.onChunk; return microphone; }, ), createPcmPlayer: vi.fn((options: { onIdle?: () => void }) => { audio.onIdle = options.onIdle; return player; }), }; }); vi.mock("./gemini-live/audio", async (importOriginal) => ({ ...(await importOriginal()), openMicrophone: audio.openMicrophone, createPcmPlayer: audio.createPcmPlayer, })); class Socket { static instances: Socket[] = []; static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; readyState = 0; binaryType = "blob"; onopen: (() => void) | null = null; onclose: ((event: { code: number; reason: string }) => void) | null = null; onerror: (() => void) | null = null; onmessage: ((event: { data: unknown }) => void) | null = null; send = vi.fn<(data: string) => void>(); close = vi.fn(() => { this.readyState = 3; this.onclose?.({ code: 1000, reason: "" }); }); url: string; constructor(url: string) { this.url = url; Socket.instances.push(this); } open() { this.readyState = 1; this.onopen?.(); } emit(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } events() { return this.send.mock.calls.map(([data]) => JSON.parse(data)); } } const getToken = vi.fn<(options: { signal: AbortSignal }) => Promise<string>>( async () => "ephemeral-token", ); const sessions: ReturnType<GeminiLiveVoiceAdapter["connect"]>[] = []; const start = (abortSignal?: AbortSignal) => { const session = new GeminiLiveVoiceAdapter({ getToken, model: "live-test-model", }).connect(abortSignal ? { abortSignal } : {}); sessions.push(session); return session; }; const opened = async (abortSignal?: AbortSignal) => { const session = start(abortSignal); await vi.waitFor(() => expect(Socket.instances).toHaveLength(1)); const socket = Socket.instances[0]!; socket.open(); return { session, socket }; }; const connected = async () => { const result = await opened(); result.socket.emit({ setupComplete: {} }); await vi.waitFor(() => expect(result.session.status.type).toBe("running")); return result; }; const reply = (text: string) => ({ serverContent: { modelTurn: { parts: [{ inlineData: { data: "AQID" } }] }, outputTranscription: { text }, }, }); const flush = async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }; beforeEach(() => { Socket.instances = []; audio.onChunk = undefined; audio.onIdle = undefined; audio.player.isPlaying.mockReturnValue(false); getToken.mockResolvedValue("ephemeral-token"); vi.stubGlobal("WebSocket", Socket); vi.stubGlobal("AudioContext", class {}); vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } }); }); afterEach(() => { for (const session of sessions.splice(0)) session.disconnect(); vi.unstubAllGlobals(); vi.useRealTimers(); }); describe("GeminiLiveVoiceAdapter", () => { it("fetches a token, sends setup, and waits for setupComplete before running", async () => { const { session, socket } = await opened(); expect(getToken).toHaveBeenCalledWith({ signal: expect.any(AbortSignal) }); expect(socket.url).toBe( "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContentConstrained?access_token=ephemeral-token", ); expect(socket.events()).toEqual([ { setup: { model: "models/live-test-model", generationConfig: { responseModalities: ["AUDIO"] }, inputAudioTranscription: {}, outputAudioTranscription: {}, }, }, ]); expect(session.status).toEqual({ type: "starting" }); expect(audio.openMicrophone).toHaveBeenCalledWith( expect.objectContaining({ sampleRate: 16000 }), ); expect(audio.createPcmPlayer).toHaveBeenCalledWith( expect.objectContaining({ sampleRate: 24000 }), ); const onMode = vi.fn(); session.onModeChange(onMode); socket.emit({ setupComplete: {} }); expect(session.status).toEqual({ type: "running" }); expect(onMode).toHaveBeenCalledWith("listening"); expect(session.sendText).toBeTypeOf("function"); }); it("streams microphone chunks only once setup is complete", async () => { const { socket } = await opened(); audio.onChunk?.("AAAA"); expect(socket.events()).toHaveLength(1); socket.emit({ setupComplete: {} }); audio.onChunk?.("AAAA"); expect(socket.events().at(-1)).toEqual({ realtimeInput: { audio: { data: "AAAA", mimeType: "audio/pcm;rate=16000" }, }, }); }); it("plays model audio and reports speaking until playback drains", async () => { const { session, socket } = await connected(); const onMode = vi.fn(); session.onModeChange(onMode); socket.emit({ serverContent: { modelTurn: { parts: [ { inlineData: { mimeType: "audio/pcm;rate=24000", data: "AQID" } }, ], }, }, }); audio.player.isPlaying.mockReturnValue(true); socket.emit({ serverContent: { modelTurn: { parts: [{ inlineData: { data: "BAUG" } }] }, }, }); expect(audio.player.enqueue.mock.calls).toEqual([["AQID"], ["BAUG"]]); expect(onMode.mock.calls).toEqual([["speaking"]]); socket.emit({ serverContent: { turnComplete: true } }); expect(onMode.mock.calls).toEqual([["speaking"]]); audio.onIdle?.(); expect(onMode).toHaveBeenLastCalledWith("listening"); }); it("streams cumulative assistant transcripts once the user transcription is finished", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { interimInputTranscription: { text: "Hel" } }, }); socket.emit({ serverContent: { inputTranscription: { text: "Hel" } } }); socket.emit({ serverContent: { inputTranscription: { text: "lo", finished: true } }, }); socket.emit(reply("Good ")); socket.emit({ serverContent: { outputTranscription: { text: "morning" } }, }); socket.emit({ serverContent: { generationComplete: true } }); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hello", isFinal: true }, { role: "assistant", text: "Good ", isFinal: false }, { role: "assistant", text: "Good morning", isFinal: false }, { role: "assistant", text: "Good morning", isFinal: true }, ]); }); it("keeps late input transcription in the spoken turn by holding the reply transcript", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "What's" } } }); socket.emit({ serverContent: { inputTranscription: { text: " the weather?" } }, }); socket.emit({ serverContent: { modelTurn: { parts: [{ inlineData: { data: "AQID" } }] }, }, }); socket.emit(reply("Sunny")); socket.emit({ serverContent: { inputTranscription: { text: " Thanks." } }, }); socket.emit({ serverContent: { outputTranscription: { text: " today" } } }); expect(onTranscript).not.toHaveBeenCalled(); expect(audio.player.enqueue).toHaveBeenCalledWith("AQID"); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "What's the weather? Thanks.", isFinal: true }, { role: "assistant", text: "Sunny today", isFinal: true }, ]); }); it("releases the held reply transcript when the user transcription finishes mid-reply", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi" } } }); socket.emit(reply("Hello")); socket.emit({ serverContent: { inputTranscription: { text: " there", finished: true } }, }); socket.emit({ serverContent: { outputTranscription: { text: "!" } } }); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi there", isFinal: true }, { role: "assistant", text: "Hello", isFinal: false }, { role: "assistant", text: "Hello!", isFinal: false }, { role: "assistant", text: "Hello!", isFinal: true }, ]); }); it("carries speech that arrives after the reply streams into the next turn", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi", finished: true } }, }); socket.emit(reply("Hello")); socket.emit({ serverContent: { inputTranscription: { text: "Again" } } }); socket.emit({ serverContent: { turnComplete: true } }); socket.emit(reply("Sure")); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Hello", isFinal: false }, { role: "assistant", text: "Hello", isFinal: true }, { role: "user", text: "Again", isFinal: true }, { role: "assistant", text: "Sure", isFinal: true }, ]); }); it("flushes input transcription when the server marks it finished or the turn ends", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Stop" } } }); socket.emit({ serverContent: { inputTranscription: { text: " now", finished: true } }, }); expect(onTranscript).toHaveBeenLastCalledWith({ role: "user", text: "Stop now", isFinal: true, }); socket.emit({ serverContent: { inputTranscription: { text: "Bye" } } }); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript).toHaveBeenLastCalledWith({ role: "user", text: "Bye", isFinal: true, }); socket.emit({ serverContent: { inputTranscription: { text: "Wait" } } }); socket.emit({ serverContent: { interrupted: true } }); expect(onTranscript).toHaveBeenLastCalledWith({ role: "user", text: "Wait", isFinal: true, }); expect(onTranscript).toHaveBeenCalledTimes(3); }); it("clears playback and finalizes the reply when the user interrupts", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const onMode = vi.fn(); session.onModeChange(onMode); audio.player.isPlaying.mockReturnValue(true); socket.emit(reply("Let me")); socket.emit({ serverContent: { interrupted: true } }); expect(audio.player.clear).toHaveBeenCalledOnce(); expect(onMode).toHaveBeenLastCalledWith("listening"); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "assistant", text: "Let me", isFinal: true }, ]); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript).toHaveBeenCalledOnce(); }); it("mutes the microphone and tells Gemini the audio stream paused", async () => { const { session, socket } = await connected(); session.mute(); expect(audio.microphone.setMuted).toHaveBeenLastCalledWith(true); expect(socket.events().at(-1)).toEqual({ realtimeInput: { audioStreamEnd: true }, }); session.unmute(); expect(audio.microphone.setMuted).toHaveBeenLastCalledWith(false); }); it("applies a mute requested before the microphone opened", async () => { const session = start(); session.mute(); await vi.waitFor(() => expect(Socket.instances).toHaveLength(1)); expect(audio.microphone.setMuted).toHaveBeenCalledWith(true); }); it("reports the louder of microphone and playback, ignoring a muted microphone", async () => { vi.useFakeTimers(); const { session } = await connected(); const onVolume = vi.fn(); session.onVolumeChange(onVolume); await vi.advanceTimersByTimeAsync(100); expect(onVolume).toHaveBeenLastCalledWith(0.7); session.mute(); await vi.advanceTimersByTimeAsync(100); expect(onVolume).toHaveBeenLastCalledWith(0.4); }); it("resolves a typed send when the new reply starts and forwards that reply afterwards", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const accepted = vi.fn(); const sent = Promise.resolve(session.sendText!("Change the topic")); void sent.then(accepted); expect(socket.events().at(-1)).toEqual({ clientContent: { turns: [{ role: "user", parts: [{ text: "Change the topic" }] }], turnComplete: true, }, }); socket.emit({ serverContent: { inputTranscription: { text: "Hello" } } }); await Promise.resolve(); expect(accepted).not.toHaveBeenCalled(); socket.emit({ serverContent: { modelTurn: { parts: [{ inlineData: { data: "AQID" } }] }, outputTranscription: { text: "Sure" }, }, }); await sent; expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hello", isFinal: true }, ]); expect(audio.player.enqueue).not.toHaveBeenCalled(); await flush(); expect(onTranscript).toHaveBeenLastCalledWith({ role: "assistant", text: "Sure", isFinal: false, }); expect(audio.player.enqueue).toHaveBeenCalledWith("AQID"); }); it("finishes the interrupted reply before accepting a typed send", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit(reply("Previous")); audio.player.isPlaying.mockReturnValue(true); const onMode = vi.fn(); session.onModeChange(onMode); const accepted = vi.fn(); const sent = Promise.resolve(session.sendText!("Change the topic")); void sent.then(accepted); expect(audio.player.clear).toHaveBeenCalledOnce(); expect(onMode).toHaveBeenLastCalledWith("listening"); audio.player.isPlaying.mockReturnValue(false); socket.emit(reply(" reply")); await Promise.resolve(); expect(accepted).not.toHaveBeenCalled(); socket.emit({ serverContent: { interrupted: true } }); expect(audio.player.clear).toHaveBeenCalledOnce(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "assistant", text: "Previous reply", isFinal: true }, ]); await Promise.resolve(); expect(accepted).not.toHaveBeenCalled(); socket.emit(reply("New")); await sent; expect(onTranscript).toHaveBeenCalledOnce(); await flush(); expect(onTranscript).toHaveBeenLastCalledWith({ role: "assistant", text: "New", isFinal: false, }); }); it("does not take a late transcript of the interrupted reply as the typed reply", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi", finished: true } }, }); socket.emit(reply("Let me")); const accepted = vi.fn(); const sent = Promise.resolve(session.sendText!("Stop")); void sent.then(accepted); socket.emit({ serverContent: { interrupted: true } }); socket.emit({ serverContent: { outputTranscription: { text: " explain" } }, }); await flush(); expect(accepted).not.toHaveBeenCalled(); socket.emit(reply("Okay")); await sent; await flush(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Let me", isFinal: false }, { role: "assistant", text: "Let me", isFinal: true }, { role: "assistant", text: "Okay", isFinal: false }, ]); }); it("does not start a reply from a transcript that trails turnComplete", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi", finished: true } }, }); socket.emit(reply("Hello")); socket.emit({ serverContent: { turnComplete: true } }); socket.emit({ serverContent: { outputTranscription: { text: " there" } } }); socket.emit({ serverContent: { inputTranscription: { text: "Bye", finished: true } }, }); socket.emit(reply("Later")); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Hello", isFinal: false }, { role: "assistant", text: "Hello", isFinal: true }, { role: "user", text: "Bye", isFinal: true }, { role: "assistant", text: "Later", isFinal: false }, { role: "assistant", text: "Later", isFinal: true }, ]); }); it("ends with an error when a deferred typed-reply frame cannot be played", async () => { const { session, socket } = await connected(); const sent = session.sendText!("Hello"); socket.emit(reply("One")); audio.player.enqueue.mockImplementationOnce(() => { throw new Error("invalid audio"); }); await sent; await flush(); expect(session.status).toMatchObject({ type: "ended", reason: "error", error: expect.objectContaining({ message: "invalid audio" }), }); }); it("does not resume the interrupted reply's audio after a typed send", async () => { const { session, socket } = await connected(); socket.emit(reply("Long")); audio.player.isPlaying.mockReturnValue(true); const onMode = vi.fn(); session.onModeChange(onMode); const sent = session.sendText!("Stop"); expect(audio.player.clear).toHaveBeenCalledOnce(); audio.player.isPlaying.mockReturnValue(false); audio.player.enqueue.mockClear(); socket.emit(reply(" answer")); expect(audio.player.enqueue).not.toHaveBeenCalled(); expect(onMode.mock.calls).toEqual([["listening"]]); socket.emit({ serverContent: { interrupted: true } }); socket.emit(reply("Okay")); await sent; await flush(); expect(audio.player.enqueue).toHaveBeenCalledOnce(); expect(onMode).toHaveBeenLastCalledWith("speaking"); }); it("keeps frames that arrive during the deferred first chunk in order", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const sent = session.sendText!("Hello"); socket.emit(reply("One")); socket.emit(reply(" two")); socket.emit({ serverContent: { turnComplete: true } }); await sent; expect(onTranscript).not.toHaveBeenCalled(); await flush(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "assistant", text: "One", isFinal: false }, { role: "assistant", text: "One two", isFinal: false }, { role: "assistant", text: "One two", isFinal: true }, ]); }); for (const code of [1000, 1011]) { it(`delivers a deferred typed reply before a ${code} close`, async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const sent = session.sendText!("Hello"); socket.emit(reply("Hi there")); await sent; socket.emit({ serverContent: { inputTranscription: { text: "wait" } } }); socket.readyState = Socket.CLOSED; socket.onclose?.({ code, reason: "" }); await flush(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "assistant", text: "Hi there", isFinal: false }, { role: "assistant", text: "Hi there", isFinal: true }, { role: "user", text: "wait", isFinal: true }, ]); expect(session.status).toMatchObject({ type: "ended", reason: code === 1000 ? "finished" : "error", }); }); } it("ends with an error and releases the microphone if close-time playback fails", async () => { const { session, socket } = await connected(); const sent = session.sendText!("Hello"); socket.emit(reply("Unplayable")); await sent; audio.player.enqueue.mockImplementationOnce(() => { throw new Error("invalid audio"); }); expect(() => socket.close()).not.toThrow(); expect(session.status).toMatchObject({ type: "ended", reason: "error", error: expect.objectContaining({ message: "invalid audio" }), }); expect(audio.microphone.close).toHaveBeenCalledOnce(); expect(audio.player.close).toHaveBeenCalledOnce(); }); it("rejects a new typed send made by a transcript listener during an open-socket end", async () => { const { session, socket } = await connected(); const attempted: Promise<void>[] = []; session.onTranscript((item) => { if (item.role !== "assistant" || item.isFinal || attempted.length > 0) return; const sentBefore = socket.send.mock.calls.length; attempted.push(Promise.resolve(session.sendText!("Again"))); if (socket.send.mock.calls.length > sentBefore) socket.emit(reply("Extra")); }); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const sent = session.sendText!("Hello"); socket.emit(reply("First")); await sent; socket.onerror?.(); await expect(attempted[0]).rejects.toThrow("not connected"); expect(socket.events().filter((event) => event.clientContent)).toHaveLength( 1, ); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "assistant", text: "First", isFinal: false }, { role: "assistant", text: "First", isFinal: true }, ]); expect(session.status).toMatchObject({ type: "ended", reason: "error" }); }); it("reports the close code when the socket closes before setup", async () => { const { session, socket } = await opened(); socket.readyState = 3; socket.onclose?.({ code: 1008, reason: "invalid token" }); expect(session.status).toMatchObject({ type: "ended", reason: "error", error: expect.objectContaining({ message: expect.stringContaining("1008: invalid token"), }), }); }); it("accepts a typed send after the previous reply completed on its own", async () => { const { session, socket } = await connected(); socket.emit(reply("Old")); const sent = session.sendText!("Hello"); socket.emit({ serverContent: { turnComplete: true } }); socket.emit(reply("New")); await expect(sent).resolves.toBeUndefined(); }); it("clears audio still buffered after turnComplete when text is typed", async () => { const { session, socket } = await connected(); socket.emit({ serverContent: { modelTurn: { parts: [{ inlineData: { data: "AQID" } }] }, turnComplete: true, }, }); audio.player.isPlaying.mockReturnValue(true); const onMode = vi.fn(); session.onModeChange(onMode); const sent = session.sendText!("Next question"); expect(audio.player.clear).toHaveBeenCalledOnce(); expect(onMode).toHaveBeenLastCalledWith("listening"); socket.emit(reply("Sure")); await expect(sent).resolves.toBeUndefined(); }); it("accepts a typed send whose reply arrives while the previous one is still deferred", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const first = session.sendText!("One"); socket.emit(reply("First")); await first; const second = session.sendText!("Two"); socket.emit({ serverContent: { interrupted: true } }); socket.emit(reply("Second")); await flush(); await expect(second).resolves.toBeUndefined(); await flush(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "assistant", text: "First", isFinal: false }, { role: "assistant", text: "First", isFinal: true }, { role: "assistant", text: "Second", isFinal: false }, ]); }); it("rejects a typed send before setup completes", async () => { const { session } = await opened(); await expect(session.sendText!("Hello")).rejects.toThrow("not connected"); expect(session.status).toEqual({ type: "starting" }); }); it("rejects a typed send that gets no reply", async () => { const { session } = await connected(); vi.useFakeTimers(); const rejected = expect(session.sendText!("Hello")).rejects.toThrow( "did not start a reply", ); await vi.advanceTimersByTimeAsync(10_000); await rejected; expect(session.status.type).toBe("running"); }); it("rejects a pending typed send on disconnect and when replaced", async () => { const { session } = await connected(); const first = expect(session.sendText!("First")).rejects.toThrow( "replaced", ); const second = expect(session.sendText!("Second")).rejects.toThrow( "disconnected before accepting", ); await first; session.disconnect(); await second; }); it("ends with an error when the socket closes before setup completes", async () => { const { session, socket } = await opened(); socket.close(); expect(session.status).toMatchObject({ type: "ended", reason: "error" }); expect(audio.microphone.close).toHaveBeenCalledOnce(); expect(audio.player.close).toHaveBeenCalledOnce(); }); it("ends normally when the server closes a running session", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { const { session, socket } = await connected(); socket.emit({ goAway: { timeLeft: "10s" } }); expect(warn).toHaveBeenCalledOnce(); socket.close(); expect(session.status).toEqual({ type: "ended", reason: "finished" }); } finally { warn.mockRestore(); } }); it("keeps separate utterances heard during a streaming reply as separate messages", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi", finished: true } }, }); socket.emit(reply("Hello")); socket.emit({ serverContent: { inputTranscription: { text: "Again", finished: true } }, }); socket.emit({ serverContent: { inputTranscription: { text: "Wait", finished: true } }, }); socket.emit({ serverContent: { turnComplete: true } }); socket.emit(reply("Sure")); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Hello", isFinal: false }, { role: "assistant", text: "Hello", isFinal: true }, { role: "user", text: "Again", isFinal: true }, { role: "user", text: "Wait", isFinal: true }, { role: "assistant", text: "Sure", isFinal: false }, ]); }); it("ignores the turnComplete that follows an interruption", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi", finished: true } }, }); socket.emit(reply("Sure")); socket.emit({ serverContent: { inputTranscription: { text: "No" } } }); socket.emit({ serverContent: { interrupted: true } }); socket.emit({ serverContent: { turnComplete: true } }); socket.emit({ serverContent: { inputTranscription: { text: " wait" } } }); socket.emit(reply("Ok")); socket.emit({ serverContent: { turnComplete: true } }); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Sure", isFinal: false }, { role: "assistant", text: "Sure", isFinal: true }, { role: "user", text: "No wait", isFinal: true }, { role: "assistant", text: "Ok", isFinal: true }, ]); }); it("keeps input transcription that arrives with the first frame of a typed reply ahead of it", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); const sent = session.sendText!("Typed"); socket.emit({ serverContent: { inputTranscription: { text: "Um" }, modelTurn: { parts: [{ inlineData: { data: "AQID" } }] }, outputTranscription: { text: "Sure" }, }, }); await sent; await flush(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Um", isFinal: true }, { role: "assistant", text: "Sure", isFinal: false }, ]); }); it("emits unfinished speech heard during a reply when the server closes", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi", finished: true } }, }); socket.emit(reply("Hello")); socket.emit({ serverContent: { inputTranscription: { text: "Bye" } } }); socket.close(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Hello", isFinal: false }, { role: "assistant", text: "Hello", isFinal: true }, { role: "user", text: "Bye", isFinal: true }, ]); }); it("emits held transcripts when the socket fails", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi" } } }); socket.emit(reply("Hello")); socket.onerror?.(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Hello", isFinal: true }, ]); expect(session.status).toMatchObject({ type: "ended", reason: "error" }); }); it("ends with an error when a running session closes abnormally without goAway", async () => { const { session, socket } = await connected(); socket.readyState = 3; socket.onclose?.({ code: 1011, reason: "internal error" }); expect(session.status).toMatchObject({ type: "ended", reason: "error", error: expect.objectContaining({ message: expect.stringContaining("1011: internal error"), }), }); }); it("emits held transcripts when the server closes mid-turn", async () => { const { session, socket } = await connected(); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.emit({ serverContent: { inputTranscription: { text: "Hi" } } }); socket.emit(reply("Hello")); socket.close(); expect(onTranscript.mock.calls.map(([item]) => item)).toEqual([ { role: "user", text: "Hi", isFinal: true }, { role: "assistant", text: "Hello", isFinal: true }, ]); expect(session.status).toEqual({ type: "ended", reason: "finished" }); }); it("ends with an error on socket failure", async () => { const { session, socket } = await connected(); socket.onerror?.(); expect(session.status).toMatchObject({ type: "ended", reason: "error" }); expect(socket.close).toHaveBeenCalledOnce(); }); it("ends with an error when the token request fails", async () => { getToken.mockRejectedValue(new Error("Unauthorized")); const session = start(); await vi.waitFor(() => expect(session.status).toMatchObject({ type: "ended", reason: "error" }), ); expect(Socket.instances).toHaveLength(0); expect(audio.microphone.close).toHaveBeenCalledOnce(); }); it("ends with an error when a frame cannot be played", async () => { const { session, socket } = await connected(); audio.player.enqueue.mockImplementationOnce(() => { throw new Error("invalid audio"); }); socket.emit({ serverContent: { modelTurn: { parts: [{ inlineData: { data: "!" } }] } }, }); expect(session.status).toMatchObject({ type: "ended", reason: "error", error: expect.objectContaining({ message: "invalid audio" }), }); }); it("decodes binary frames", async () => { const { session, socket } = await connected(); expect(socket.binaryType).toBe("arraybuffer"); const onTranscript = vi.fn(); session.onTranscript(onTranscript); socket.onmessage?.({ data: new TextEncoder().encode( JSON.stringify({ serverContent: { inputTranscription: { text: "One", finished: true }, }, }), ).buffer, }); expect(onTranscript).toHaveBeenCalledWith({ role: "user", text: "One", isFinal: true, }); }); it("stops the socket, microphone, playback, and timers exactly once", async () => { vi.useFakeTimers(); const { session, socket } = await connected(); session.disconnect(); session.disconnect(); expect(socket.close).toHaveBeenCalledOnce(); expect(audio.microphone.close).toHaveBeenCalledOnce(); expect(audio.player.close).toHaveBeenCalledOnce(); expect(vi.getTimerCount()).toBe(0); expect(session.status).toEqual({ type: "ended", reason: "cancelled" }); }); it("aborts an in-flight token request and releases the microphone", async () => { let resolveToken!: (token: string) => void; getToken.mockReturnValue( new Promise<string>((resolve) => { resolveToken = resolve; }), ); const controller = new AbortController(); const session = start(controller.signal); await vi.waitFor(() => expect(getToken).toHaveBeenCalled()); controller.abort(); expect(getToken.mock.calls[0]![0].signal.aborted).toBe(true); resolveToken("late-token"); await flush(); expect(Socket.instances).toHaveLength(0); expect(audio.microphone.close).toHaveBeenCalledOnce(); expect(session.status).toEqual({ type: "ended", reason: "cancelled" }); }); it("releases a microphone that opens after disconnect", async () => { let resolveMicrophone!: (microphone: typeof audio.microphone) => void; audio.openMicrophone.mockReturnValueOnce( new Promise((resolve) => { resolveMicrophone = resolve; }), ); const session = start(); await vi.waitFor(() => expect(audio.openMicrophone).toHaveBeenCalled()); session.disconnect(); resolveMicrophone(audio.microphone); await flush(); expect(audio.microphone.close).toHaveBeenCalledOnce(); expect(Socket.instances).toHaveLength(0); }); it("times out a connection that never completes setup", async () => { vi.useFakeTimers(); const { session } = await opened(); await vi.advanceTimersByTimeAsync(30_000); expect(session.status).toMatchObject({ type: "ended", reason: "error" }); }); it("fails outside a browser", async () => { vi.stubGlobal("WebSocket", undefined); const session = start(); await vi.waitFor(() => expect(session.status).toMatchObject({ type: "ended", reason: "error" }), ); expect(getToken).not.toHaveBeenCalled(); }); it("does not allocate anything for an already-aborted connection", () => { const session = new GeminiLiveVoiceAdapter({ getToken, model: "live-test-model", }).connect({ abortSignal: AbortSignal.abort(), }); expect(session.status).toEqual({ type: "ended", reason: "cancelled" }); expect(getToken).not.toHaveBeenCalled(); }); });