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
563 lines
24 KiB
TypeScript
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");
|
|
});
|
|
});
|