fix(frontend): start conversations without a welcome modal

This commit is contained in:
2026-10-07 10:46:15 -03:00
parent c06c9194e8
commit 62c2692082
8 changed files with 162 additions and 69 deletions
@@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
import type { ClientOptions } from "./client";
import { VoiceController } from "./controller";
const { MockClient, closeContext } = vi.hoisted(() => {
const { MockClient, closeContext, createContext } = vi.hoisted(() => {
const closeContext = vi.fn().mockResolvedValue(undefined);
const createContext = vi.fn(() => ({ state: "running", close: closeContext }));
class MockClient {
static instances: MockClient[] = [];
readonly options: ClientOptions;
@@ -41,16 +42,17 @@ const { MockClient, closeContext } = vi.hoisted(() => {
sendToolOutput = vi.fn();
join = vi.fn();
}
return { MockClient, closeContext };
return { MockClient, closeContext, createContext };
});
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient }));
vi.mock("../audio/engine", () => ({ createAudioContext: () => ({ state: "running", close: closeContext }) }));
vi.mock("../audio/engine", () => ({ createAudioContext: createContext }));
let pendingReasoning: ((value: Response) => void) | undefined;
let controller: VoiceController;
let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>;
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
createContext.mockReturnValue({ state: "running", close: closeContext });
MockClient.instances = [];
pendingReasoning = undefined;
vi.stubGlobal("AudioContext", vi.fn());
@@ -87,6 +89,83 @@ async function start() {
return client;
}
describe("voice application lifecycle", () => {
it.each(["TALK", "TALKFIRST"])(
"automatically starts %s once with the greeting after initialization",
async (startSessionMode) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })),
);
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(0);
await controller.initialize();
controller.offerAutoStart();
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero");
expect(controller.getSnapshot().status).toBe("listening");
await controller.stop();
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
expect(controller.getSnapshot().status).toBe("closed");
},
);
it.each(["", "UNSUPPORTED"])("leaves startup manual for mode %s", async (startSessionMode) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })),
);
await controller.initialize();
controller.offerAutoStart();
expect(createContext).not.toHaveBeenCalled();
expect(MockClient.instances).toHaveLength(0);
await controller.start();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBeUndefined();
});
it("waits for caller context before the automatic attempt", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode: "TALK", s2sUrl: "ws://localhost/v1/realtime" })),
);
let finishProfile: ((value: Response) => void) | undefined;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
finishProfile = resolve;
}),
);
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(1);
expect(controller.getSnapshot()).toMatchObject({ configReady: true, contextLoading: true });
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(0);
finishProfile?.(new Response(JSON.stringify({ result: { name: "Ana" } })));
await initializing;
controller.offerAutoStart();
expect(MockClient.instances).toHaveLength(1);
});
it.each(["TALK", "TALKFIRST"])(
"leaves a manual fallback when the browser blocks %s audio",
async (startSessionMode) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ startSessionMode, s2sUrl: "ws://localhost/v1/realtime" })),
);
createContext.mockReturnValueOnce({ state: "suspended", close: closeContext });
await controller.initialize();
controller.offerAutoStart();
expect(closeContext).toHaveBeenCalledOnce();
expect(MockClient.instances).toHaveLength(0);
expect(controller.getSnapshot()).toMatchObject({
status: "idle",
error: "",
notice: "Toca el micrófono para empezar a conversar.",
});
controller.offerAutoStart();
expect(createContext).toHaveBeenCalledOnce();
await controller.start();
expect(MockClient.instances).toHaveLength(1);
expect(MockClient.instances[0]?.options.greeting).toBe("hola vocero");
expect(controller.getSnapshot()).toMatchObject({ status: "listening", notice: "" });
},
);
it("notifies only meter subscribers when microphone level changes", async () => {
const client = await start();
const app = vi.fn();