Files
vocero-client/vocero-frontend/src/realtime/controller.test.ts
T
alvaro 96895996d9 feat(mcp): connect authenticated AquaPortal tools to Vocero (#1)
Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged.

Release notes:

- Added browser-bound conversations that keep backend credentials server-side and isolate callers.
- Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results.
- Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection.
- Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup.
- Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints.
- Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage.

Reviewed-on: AquaPortal/s2s#1
2026-10-08 08:49:39 -04:00

563 lines
24 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import type { ClientOptions } from "./client";
import { VoiceController } from "./controller";
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;
private listeners = new Map<string, Set<(value: unknown) => void>>();
constructor(options: ClientOptions) {
this.options = options;
MockClient.instances.push(this);
}
on(name: string, listener: (value: unknown) => void) {
const listeners = this.listeners.get(name) ?? new Set();
listeners.add(listener);
this.listeners.set(name, listeners);
return () => {
listeners.delete(listener);
};
}
emit(name: string, value: unknown) {
this.listeners.get(name)?.forEach((listener) => {
listener(value);
});
}
connect = vi.fn(() => {
this.emit("status", "listening");
return Promise.resolve();
});
close = vi.fn(() => {
this.emit("status", "closed");
return Promise.resolve();
});
updateSession = vi.fn();
setGate = vi.fn();
setMuted = vi.fn();
renderExternalAnswer = vi.fn();
sendToolOutput = vi.fn();
join = vi.fn();
}
return { MockClient, closeContext, createContext };
});
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient }));
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());
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } });
fetchMock = vi.fn((path: string) => {
if (path === "/api/reasoning") {
return new Promise<Response>((resolve) => {
pendingReasoning = resolve;
});
}
const pathname = new URL(path, "http://localhost").pathname;
const responses: Record<string, unknown> = {
"/api/config": { reasoningType: "OPENAI", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
"/api/user/phone": { result: { name: "Ana" } },
"/api/user/reports": { count: 0 },
};
return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {})));
});
vi.stubGlobal("fetch", fetchMock);
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" });
});
afterEach(async () => {
await controller.stop();
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function start() {
await controller.initialize(new AbortController().signal);
await controller.start();
const client = MockClient.instances[0];
if (!client) {
throw new Error("No client created");
}
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();
const meter = vi.fn();
controller.subscribe(app);
controller.subscribeLevel(meter);
const snapshot = controller.getSnapshot();
client.emit("level", 0.2);
expect(controller.getLevel()).toBe(0.2);
expect(meter).toHaveBeenCalledOnce();
expect(app).not.toHaveBeenCalled();
expect(controller.getSnapshot()).toBe(snapshot);
});
it("saves only changed preferences and keeps noise calibration out of session updates", async () => {
const client = await start();
const save = vi.spyOn(Storage.prototype, "setItem");
client.updateSession.mockClear();
controller.changeSettings({ noiseGate: -35 });
expect(save).toHaveBeenCalledExactlyOnceWith("s2s.ws.noiseGate", "-35");
expect(client.setGate).toHaveBeenCalledExactlyOnceWith({ enabled: true, thresholdDb: -35 });
expect(client.updateSession).not.toHaveBeenCalled();
controller.changeSettings({ voice: "Serena", instructions: "New instructions" });
expect(client.updateSession).toHaveBeenCalledOnce();
controller.changeSettings({ voice: "Serena", instructions: "New instructions" });
expect(client.updateSession).toHaveBeenCalledOnce();
});
it("renders configuration and starts without waiting for optional reports", async () => {
const original = fetchMock.getMockImplementation();
if (!original) {
throw new Error("Missing fetch mock");
}
let finishReports: ((value: Response) => void) | undefined;
fetchMock.mockImplementation((path, init) =>
path.startsWith("/api/user/reports")
? new Promise<Response>((resolve) => {
finishReports = resolve;
})
: original(path, init),
);
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(1);
expect(controller.getSnapshot()).toMatchObject({ loading: false, configReady: true, profile: { name: "Ana" } });
await controller.start();
expect(MockClient.instances).toHaveLength(1);
finishReports?.(new Response(JSON.stringify({ count: 2 })));
await initializing;
expect(controller.getSnapshot().reports?.count).toBe(2);
});
it("waits for caller context but bounds an unavailable profile request", async () => {
const original = fetchMock.getMockImplementation();
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) =>
path.startsWith("/api/user/phone")
? new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new DOMException("Request cancelled", "AbortError"));
});
})
: original(path, init),
);
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(1);
expect(controller.getSnapshot()).toMatchObject({ loading: false, contextLoading: true });
await controller.start();
expect(MockClient.instances).toHaveLength(0);
await vi.advanceTimersByTimeAsync(8000);
await initializing;
expect(controller.getSnapshot()).toMatchObject({ contextLoading: false, profile: null });
await controller.start();
expect(MockClient.instances).toHaveLength(1);
});
it("blocks session startup after a configuration failure and allows retry", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
await controller.initialize();
expect(controller.getSnapshot()).toMatchObject({ loading: false, configReady: false });
await controller.start();
expect(MockClient.instances).toHaveLength(0);
await controller.initialize();
expect(controller.getSnapshot()).toMatchObject({ configReady: true, configError: "" });
await controller.start();
expect(MockClient.instances).toHaveLength(1);
});
it("discards a superseded initialization even if its fetch ignores cancellation", async () => {
let finishConfig: ((value: Response) => void) | undefined;
fetchMock.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
finishConfig = resolve;
}),
);
const earlier = controller.initialize();
await controller.initialize();
finishConfig?.(new Response(JSON.stringify({ theme: "stale" })));
await earlier;
expect(controller.getSnapshot()).toMatchObject({ configReady: true, config: { theme: "" } });
});
it("loads configuration and caller context without an account request on startup or stop", async () => {
const client = await start();
await controller.stop();
expect(client.close).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([
"/api/config",
"/api/user/phone",
"/api/user/reports",
]);
});
it("renders an external answer and preserves separate profile context", async () => {
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ answer: " Hola, Ana " })));
await vi.advanceTimersByTimeAsync(1);
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
expect(request?.[1]?.body).toContain("Caller profile resolved");
expect(client.options.instructions).not.toContain("Caller profile resolved");
expect(client.renderExternalAnswer).toHaveBeenCalledWith("Hola, Ana");
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola", "Hola, Ana"]);
});
it.each(["PROXY", "UNSUPPORTED"])("keeps native reasoning for mode %s", async (reasoningType) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ reasoningType, s2sUrl: "ws://localhost/v1/realtime" })),
);
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
await vi.advanceTimersByTimeAsync(1);
expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false);
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola"]);
});
it("discards external reasoning that finishes after the session was stopped", async () => {
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Consulta", partial: false });
await controller.stop();
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta tardía" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]);
});
it("aborts an older answer and discards it if it finishes after the current turn", async () => {
const client = await start();
client.emit("transcript", { id: "older", role: "user", text: "Anterior", partial: false });
const earlier = pendingReasoning;
const earlierSignal = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning")?.[1]?.signal;
client.emit("transcript", { id: "newer", role: "user", text: "Actual", partial: false });
expect(earlierSignal?.aborted).toBe(true);
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta actual" })));
await vi.advanceTimersByTimeAsync(1);
earlier?.(new Response(JSON.stringify({ answer: "Respuesta anterior" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Respuesta actual");
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual([
"Anterior",
"Actual",
"Respuesta actual",
]);
expect(controller.getSnapshot().error).toBe("");
});
it("invalidates pending reasoning as soon as the user interrupts", async () => {
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Pregunta", partial: false });
client.emit("status", "user-speaking");
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta interrumpida" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
expect(controller.getSnapshot().status).toBe("user-speaking");
});
it("applies and removes RAG context in the live session", async () => {
const client = await start();
controller.setRag("Datos importantes");
expect(client.updateSession).toHaveBeenLastCalledWith(expect.stringContaining("Datos importantes"), "Aiden", []);
controller.setRag("");
expect(client.updateSession).toHaveBeenLastCalledWith(expect.not.stringContaining("CUSTOM_RAG_BEGIN"), "Aiden", []);
});
it("closes after silence but does not cut off the assistant while speaking", async () => {
const client = await start();
client.emit("status", "ai-speaking");
await vi.advanceTimersByTimeAsync(60000);
expect(client.close).not.toHaveBeenCalled();
client.emit("status", "listening");
await vi.advanceTimersByTimeAsync(30000);
expect(client.close).toHaveBeenCalledOnce();
expect(controller.getSnapshot().status).toBe("closed");
});
it("does not count native processing or an external answer request as user inactivity", async () => {
const client = await start();
client.emit("status", "processing");
await vi.advanceTimersByTimeAsync(60000);
expect(client.close).not.toHaveBeenCalled();
client.emit("transcript", { id: "u1", role: "user", text: "Consulta lenta", partial: false });
client.emit("status", "listening");
await vi.advanceTimersByTimeAsync(60000);
expect(client.close).not.toHaveBeenCalled();
expect(controller.getSnapshot().status).toBe("processing");
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta" })));
await vi.advanceTimersByTimeAsync(1);
expect(controller.getSnapshot().status).toBe("listening");
});
});
describe("authenticated MCP conversations", () => {
const relayUrl = () =>
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/v1/realtime`;
function enableMcp(reasoningType = "OPENAI") {
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1" });
const original = fetchMock.getMockImplementation();
let sequence = 0;
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) => {
if (path === "/api/config") {
return Promise.resolve(new Response(JSON.stringify({ mcp: true, reasoningType, s2sUrl: relayUrl() })));
}
if (path === "/api/conversations") {
sequence++;
return Promise.resolve(
new Response(
JSON.stringify({
conversationId: `conversation-${String(sequence)}`,
expiresIn: 3600,
profile: { id: "alice", name: "Alice" },
}),
),
);
}
if (path === "/api/mcp/tools") {
return Promise.resolve(
new Response(
JSON.stringify({
tools: [
{
name: "list_reports",
parameters: { properties: { request: { type: "object" } } },
requires_auth: true,
},
],
}),
),
);
}
if (path.startsWith("/api/conversations/current")) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return original(path, init);
});
}
it("bootstraps once, keeps credentials out of storage, and skips legacy context loading", async () => {
enableMcp("PROXY");
const client = await start();
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config", "/api/conversations", "/api/mcp/tools"]);
expect(controller.getSnapshot()).toMatchObject({
profile: { name: "Alice" },
reports: null,
tools: [{ name: "list_reports" }],
});
expect(client.options.directUrl).toBe(`${relayUrl()}?conversation_id=conversation-1`);
expect(client.options.instructions).not.toContain("APIPILOT");
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);
const discovery = fetchMock.mock.calls.find(([path]) => path === "/api/mcp/tools");
expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-1");
await controller.stop();
const teardown = fetchMock.mock.calls.find(([path]) => path === "/api/conversations/current");
expect(teardown?.[1]).toMatchObject({ method: "DELETE", keepalive: true });
await controller.start();
expect(MockClient.instances[1]?.options.directUrl).toContain("conversation_id=conversation-2");
});
it("blocks voice startup when phone login fails and retries on a later manual start", async () => {
enableMcp();
const original = fetchMock.getMockImplementation();
let denied = true;
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) =>
path === "/api/conversations" && denied
? Promise.resolve(new Response(JSON.stringify({ detail: "Usuario no disponible." }), { status: 401 }))
: original(path, init),
);
await controller.initialize();
expect(controller.getSnapshot()).toMatchObject({
configReady: true,
contextLoading: false,
error: "Usuario no disponible.",
});
controller.offerAutoStart();
await controller.start();
expect(MockClient.instances).toHaveLength(0);
denied = false;
await controller.start();
expect(MockClient.instances).toHaveLength(1);
});
it("does not initialize voice when the phone selector is missing", async () => {
enableMcp();
controller = new VoiceController({ phoneNumber: "", episodeId: "" });
await controller.initialize();
await controller.start();
expect(MockClient.instances).toHaveLength(0);
expect(controller.getSnapshot().error).toContain("número de teléfono");
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config"]);
});
it("binds HTTP reasoning and cancellation to the conversation and exact turn", async () => {
enableMcp();
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false });
const old = pendingReasoning;
const first = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
const firstHeaders = new Headers(first?.[1]?.headers);
expect(firstHeaders.get("X-Vocero-Conversation")).toBe("conversation-1");
client.emit("status", "user-speaking");
const interrupt = fetchMock.mock.calls.find(([path]) => path.endsWith("/interrupt"));
expect(new Headers(interrupt?.[1]?.headers).get("X-Vocero-Turn")).toBe(firstHeaders.get("X-Vocero-Turn"));
client.emit("transcript", { id: "u2", role: "user", text: "El último", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ answer: "Actual" })));
await vi.advanceTimersByTimeAsync(1);
old?.(new Response(JSON.stringify({ answer: "Anterior" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Actual");
});
it("closes a revoked conversation without falling back to public providers", async () => {
enableMcp();
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ detail: "La sesión expiró." }), { status: 401 }));
await vi.advanceTimersByTimeAsync(1);
expect(client.close).toHaveBeenCalledOnce();
expect(controller.getSnapshot()).toMatchObject({ status: "error", error: "La sesión expiró." });
expect(fetchMock.mock.calls.some(([path]) => path.startsWith("/api/user/"))).toBe(false);
});
it("cleans up a conversation whose catalog arrives after teardown", async () => {
enableMcp();
const original = fetchMock.getMockImplementation();
let finishCatalog: ((value: Response) => void) | undefined;
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) =>
path === "/api/mcp/tools"
? new Promise<Response>((resolve) => {
finishCatalog = resolve;
})
: original(path, init),
);
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(1);
await controller.stop();
finishCatalog?.(new Response(JSON.stringify({ tools: [] })));
await initializing;
expect(controller.getSnapshot()).toMatchObject({
profile: null,
tools: [],
status: "closed",
contextLoading: false,
});
expect(
fetchMock.mock.calls.some(([path, init]) => path === "/api/conversations/current" && init?.method === "DELETE"),
).toBe(true);
expect(MockClient.instances).toHaveLength(0);
});
it("keeps separate conversation IDs in separate controller instances", async () => {
enableMcp();
await controller.initialize();
const other = new VoiceController({ phoneNumber: "5678", episodeId: "" });
await other.initialize();
await controller.start();
await other.start();
expect(MockClient.instances.map((client) => client.options.directUrl)).toEqual([
`${relayUrl()}?conversation_id=conversation-1`,
`${relayUrl()}?conversation_id=conversation-2`,
]);
await other.stop();
});
it("does not forward the conversation ID to a direct external speech service", async () => {
enableMcp();
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ mcp: true, reasoningType: "OPENAI", s2sUrl: "wss://speech.example.test/v1/realtime" }),
),
);
const client = await start();
expect(client.options.directUrl).toBe("wss://speech.example.test/v1/realtime");
});
});