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;