From ff32c6a605576a5057ccce584dface79dbfc86bc Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Wed, 7 Oct 2026 09:24:31 -0300 Subject: [PATCH] fix(frontend): recover startup and discard stale reasoning answers --- vocero-api/CHANGELOG.md | 7 ++ vocero-frontend/src/App.test.tsx | 9 ++ vocero-frontend/src/App.tsx | 30 ++++- vocero-frontend/src/components/VoiceStage.tsx | 8 +- vocero-frontend/src/lib/api.ts | 47 +++++-- .../src/realtime/controller.test.ts | 100 +++++++++++++++ vocero-frontend/src/realtime/controller.ts | 115 ++++++++++++++---- vocero-frontend/src/styles.css | 5 + 8 files changed, 280 insertions(+), 41 deletions(-) diff --git a/vocero-api/CHANGELOG.md b/vocero-api/CHANGELOG.md index 1f03219..68eea7b 100644 --- a/vocero-api/CHANGELOG.md +++ b/vocero-api/CHANGELOG.md @@ -7,6 +7,13 @@ the implementation at the recorded date; current operating instructions live in ## Unreleased +### Fixed + +- Make frontend configuration failures retryable, bound startup requests, and + load optional reports independently while waiting for caller context before + starting a session. Cancel superseded external reasoning and reject stale + answers after a new turn, interruption, or session teardown. + ### Changed - Renamed the API and browser projects to `vocero-api/` and `vocero-frontend/`. diff --git a/vocero-frontend/src/App.test.tsx b/vocero-frontend/src/App.test.tsx index 9bfe371..36b94d1 100644 --- a/vocero-frontend/src/App.test.tsx +++ b/vocero-frontend/src/App.test.tsx @@ -43,6 +43,15 @@ afterEach(() => { }); describe("React application", () => { + it("offers configuration retry without exposing a broken start control", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("offline")); + const user = userEvent.setup(); + render(); + await screen.findByRole("alert"); + expect(screen.queryByRole("button", { name: "Iniciar conversación de voz" })).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Reintentar" })); + expect(await screen.findByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled(); + }); it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => { const user = userEvent.setup(); const view = render( diff --git a/vocero-frontend/src/App.tsx b/vocero-frontend/src/App.tsx index a018102..0b5cc38 100644 --- a/vocero-frontend/src/App.tsx +++ b/vocero-frontend/src/App.tsx @@ -21,17 +21,18 @@ export default function App() { const white = state.config.theme === "white"; const name = profileName(state.profile); const hasReports = debug && !white && !!state.reports?.count; + const ready = state.configReady && !state.contextLoading; useEffect(() => { const abort = new AbortController(); void controller.initialize(abort.signal); const leave = () => { - void controller.stop(); + controller.dispose(); }; window.addEventListener("pagehide", leave); return () => { abort.abort(); window.removeEventListener("pagehide", leave); - void controller.stop(); + controller.dispose(); }; }, [controller]); useLayoutEffect(() => { @@ -40,7 +41,7 @@ export default function App() { } }, [state.loading, white]); useEffect(() => { - if (state.loading) { + if (!ready) { return; } const timer = setTimeout(() => { @@ -49,8 +50,8 @@ export default function App() { return () => { clearTimeout(timer); }; - }, [state.loading, controller]); - useWakeWord(!state.loading && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller); + }, [ready, controller]); + useWakeWord(ready && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller); useEffect(() => { if (!reportsOpen) { return; @@ -75,6 +76,25 @@ export default function App() { ); } + if (!state.configReady) { + return ( +
+
+

No pudimos preparar Vocero

+

{state.configError}

+ +
+
+ ); + } return ( <> {state.unlock && ( diff --git a/vocero-frontend/src/components/VoiceStage.tsx b/vocero-frontend/src/components/VoiceStage.tsx index 758e60b..098e764 100644 --- a/vocero-frontend/src/components/VoiceStage.tsx +++ b/vocero-frontend/src/components/VoiceStage.tsx @@ -119,8 +119,10 @@ export function VoiceStage({ state, controller }: { state: AppState; controller: const live = LIVE_STATES.has(state.status); const active = ACTIVE_STATES.has(state.status); const status = state.status === "closed" ? "idle" : state.status; - const caption = - state.muted && (status === "listening" || status === "user-speaking") + const ready = state.configReady && !state.contextLoading; + const caption = !ready + ? "Preparando contexto…" + : state.muted && (status === "listening" || status === "user-speaking") ? "Micrófono silenciado" : CAPTIONS[state.status]; const label = active @@ -136,7 +138,7 @@ export function VoiceStage({ state, controller }: { state: AppState; controller: void controller.start(); } }; - const disabled = active && status !== "your-turn"; + const disabled = !ready || (active && status !== "your-turn"); return (
{white ? ( diff --git a/vocero-frontend/src/lib/api.ts b/vocero-frontend/src/lib/api.ts index 8144f79..f1edcf1 100644 --- a/vocero-frontend/src/lib/api.ts +++ b/vocero-frontend/src/lib/api.ts @@ -15,13 +15,40 @@ export class ApiError extends Error { } } -export async function request(path: string, schema: z.ZodType, init?: RequestInit): Promise { - const response = await fetch(path, init); - const body: unknown = await response.json().catch(() => null); - if (!response.ok) { - throw new ApiError(response.status, body); +export async function request( + path: string, + schema: z.ZodType, + init?: RequestInit, + timeoutMs?: number, +): Promise { + const abort = new AbortController(); + const parent = init?.signal; + const cancel = () => { + abort.abort(parent?.reason); + }; + if (parent?.aborted) { + cancel(); + } else { + parent?.addEventListener("abort", cancel, { once: true }); + } + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + abort.abort(new DOMException("Request timed out", "TimeoutError")); + }, timeoutMs); + try { + const response = await fetch(path, { ...init, signal: abort.signal }); + const body: unknown = await response.json().catch(() => null); + abort.signal.throwIfAborted(); + if (!response.ok) { + throw new ApiError(response.status, body); + } + return schema.parse(body); + } finally { + clearTimeout(timer); + parent?.removeEventListener("abort", cancel); } - return schema.parse(body); } export function post(path: string, schema: z.ZodType, body: unknown, signal?: AbortSignal) { @@ -35,7 +62,7 @@ export function post(path: string, schema: z.ZodType, body: unknown, signa export async function optionalRequest(path: string, schema: z.ZodType, signal: AbortSignal) { try { - return await request(path, schema, { signal }); + return await request(path, schema, { signal }, 8000); } catch (error) { if (signal.aborted) { throw error; @@ -46,7 +73,7 @@ export async function optionalRequest(path: string, schema: z.ZodType, sig } export function getConfig(signal: AbortSignal) { - return request("/api/config", configSchema, { signal }); + return request("/api/config", configSchema, { signal }, 10000); } export async function getProfile(phone: string, signal: AbortSignal) { if (!phone) { @@ -72,6 +99,7 @@ export async function getTools(signal: AbortSignal, refresh = false) { `/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`, toolsEnvelopeSchema, { signal }, + 10000, ); if (!result.success || !result.data) { throw new Error("No se pudieron cargar las herramientas."); @@ -108,6 +136,9 @@ export function errorMessage(error: unknown) { return "El servidor devolvió una respuesta inesperada."; } if (error instanceof DOMException) { + if (error.name === "TimeoutError") { + return "El servidor tardó demasiado en responder. Vuelve a intentarlo."; + } if (error.name === "NotAllowedError") { return "No se pudo acceder al micrófono. Revisa el permiso del navegador y vuelve a intentarlo."; } diff --git a/vocero-frontend/src/realtime/controller.test.ts b/vocero-frontend/src/realtime/controller.test.ts index c8f3ff9..9b01014 100644 --- a/vocero-frontend/src/realtime/controller.test.ts +++ b/vocero-frontend/src/realtime/controller.test.ts @@ -87,6 +87,78 @@ async function start() { return client; } describe("voice application lifecycle", () => { + 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((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((_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((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(); @@ -130,6 +202,34 @@ describe("voice application lifecycle", () => { 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"); diff --git a/vocero-frontend/src/realtime/controller.ts b/vocero-frontend/src/realtime/controller.ts index 8348e54..bbc09fa 100644 --- a/vocero-frontend/src/realtime/controller.ts +++ b/vocero-frontend/src/realtime/controller.ts @@ -38,6 +38,9 @@ export const LIVE_STATES = new Set(["listening", "user-speakin export const ACTIVE_STATES = new Set([...LIVE_STATES, "connecting", "queued", "your-turn"]); export interface AppState { loading: boolean; + configReady: boolean; + configError: string; + contextLoading: boolean; config: ServerConfig; settings: Settings; profile: Profile | null; @@ -65,6 +68,9 @@ export class VoiceController { private client: RealtimeClient | null = null; private subscriptions: (() => void)[] = []; private requests = new AbortController(); + private initialization: AbortController | null = null; + private reasoningRequest: AbortController | null = null; + private reasoningTurn = 0; private idleTimer: ReturnType | undefined; private lastActivity = Date.now(); private lastMeter = 0; @@ -76,6 +82,9 @@ export class VoiceController { this.caller = caller; this.state = { loading: true, + configReady: false, + configError: "", + contextLoading: false, config: defaultConfig, settings: loadSettings(), profile: null, @@ -109,25 +118,64 @@ export class VoiceController { }); } - async initialize(signal: AbortSignal) { - try { - const [config, profile, reports] = await Promise.all([ - getConfig(signal), - getProfile(this.caller.phoneNumber, signal), - getReports(this.caller.phoneNumber, signal), - ]); - if (signal.aborted) { - return; - } - this.update({ config, profile, reports, loading: false, error: "" }); - if (config.apipilot) { - await this.refreshTools(signal, !!profile); - } - } catch (error) { - if (!signal.aborted) { - this.update({ loading: false, error: `No se pudo cargar la configuración. ${errorMessage(error)}` }); - } + async initialize(parent?: AbortSignal) { + this.initialization?.abort(); + const initialization = new AbortController(); + this.initialization = initialization; + const { signal } = initialization; + const cancel = () => { + initialization.abort(); + }; + if (parent?.aborted) { + cancel(); + return; } + parent?.addEventListener("abort", cancel, { once: true }); + this.update({ loading: true, configReady: false, configError: "", contextLoading: !!this.caller.phoneNumber }); + const configuration = getConfig(signal); + const profile = getProfile(this.caller.phoneNumber, signal) + .then((profile) => { + if (!signal.aborted) { + this.update({ profile, contextLoading: false }); + this.pushSettings(); + } + }) + .catch(() => undefined); + const reports = getReports(this.caller.phoneNumber, signal) + .then((reports) => { + if (!signal.aborted) { + this.update({ reports }); + } + }) + .catch(() => undefined); + const configure = async () => { + try { + const config = await configuration; + if (signal.aborted) { + return; + } + this.update({ config, configReady: true, loading: false }); + if (config.apipilot) { + await profile; + if (!signal.aborted) { + await this.refreshTools(signal, !!this.state.profile); + } + } + } catch (error) { + if (!signal.aborted) { + this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` }); + } + } + }; + try { + await Promise.all([configure(), profile, reports]); + } finally { + parent?.removeEventListener("abort", cancel); + } + } + dispose() { + this.initialization?.abort(); + void this.stop(); } async refreshTools(signal: AbortSignal, refresh = false) { this.update({ toolsLoading: true, toolsError: "" }); @@ -182,7 +230,7 @@ export class VoiceController { this.update({ error: "" }); } offerAutoStart() { - if (this.autoStarted || this.client || this.state.loading) { + if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) { return; } this.autoStarted = true; @@ -194,7 +242,7 @@ export class VoiceController { } } async start(automatic = false, preparedContext?: AudioContext, greeting?: string) { - if (this.client || this.state.loading) { + if (this.client || !this.state.configReady || this.state.contextLoading) { if (preparedContext) { void preparedContext.close().catch(() => undefined); } @@ -250,6 +298,7 @@ export class VoiceController { this.lastActivity = Date.now(); } if (status === "user-speaking") { + this.cancelReasoning(); window.speechSynthesis?.cancel(); this.update({ messages: this.state.messages.map((message) => @@ -360,11 +409,22 @@ export class VoiceController { : [...this.state.messages, message], }); } + private cancelReasoning() { + this.reasoningTurn++; + this.reasoningRequest?.abort(); + this.reasoningRequest = null; + } private async reason(transcript: Transcript, client: RealtimeClient) { const { config, profile, rag } = this.state; if (config.reasoningType === "PROXY" || !transcript.text.trim()) { return; } + this.cancelReasoning(); + const turn = this.reasoningTurn; + const request = new AbortController(); + this.reasoningRequest = request; + const current = () => this.client === client && turn === this.reasoningTurn && !request.signal.aborted; + window.speechSynthesis?.cancel(); try { const result = await post( "/api/reasoning", @@ -377,9 +437,9 @@ export class VoiceController { phoneNumber: this.caller.phoneNumber || null, episode_id: this.caller.episodeId || null, }, - this.requests.signal, + request.signal, ); - if (this.client !== client) { + if (!current()) { return; } const answer = result.answer.trim(); @@ -396,12 +456,12 @@ export class VoiceController { utterance.voice = voice; } utterance.onstart = () => { - if (this.client === client) { + if (current()) { this.update({ status: "ai-speaking" }); } }; utterance.onend = utterance.onerror = () => { - if (this.client === client) { + if (current()) { this.lastActivity = Date.now(); this.update({ status: "listening" }); } @@ -410,9 +470,13 @@ export class VoiceController { window.speechSynthesis.speak(utterance); } } catch (error) { - if (this.client === client && !this.requests.signal.aborted) { + if (current()) { this.update({ error: errorMessage(error) }); } + } finally { + if (turn === this.reasoningTurn) { + this.reasoningRequest = null; + } } } private async runTool(call: ToolCall, client: RealtimeClient) { @@ -491,6 +555,7 @@ export class VoiceController { this.subscriptions = []; clearInterval(this.idleTimer); this.requests.abort(); + this.cancelReasoning(); window.speechSynthesis?.cancel(); this.update({ status, diff --git a/vocero-frontend/src/styles.css b/vocero-frontend/src/styles.css index 02674ba..0c5a5e7 100644 --- a/vocero-frontend/src/styles.css +++ b/vocero-frontend/src/styles.css @@ -48,6 +48,11 @@ font: 500 16px system-ui; z-index: 1000; } + .startup-error { + max-width: 32rem; + padding: 24px; + text-align: center; + } .app-error { display: flex; align-items: center;