@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
993 lines (932 loc) • 37 kB
text/typescript
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();
});
});