fix(frontend): recover startup and discard stale reasoning answers
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user