fix(frontend): recover startup and discard stale reasoning answers

This commit is contained in:
2026-10-07 09:24:31 -03:00
parent abd03ed07d
commit ff32c6a605
8 changed files with 280 additions and 41 deletions
@@ -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<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();
@@ -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");