Files
HealthLog/src/lib/ai/__tests__/codex-timeout.test.ts
T
Marc-André Bombeck 7e48e2c26d fix(insights): stop the daily briefing going blank on slow generations
The comprehensive insight that feeds both the daily briefing and the
insights trend narrative is a single reasoning-heavy generation over the
full feature snapshot. On a large account it runs past the provider
client's fixed 60 s timeout and is aborted mid-stream, so the chain
reports an all-failed outcome, no cached block is written, and the
surface falls back to its static placeholder on every visit. The warm
paths bounded the same generation tighter still (45 s) and counted the
failure with no observable signal, so the blank state was permanent and
silent.

Make the per-request upstream timeout configurable
(CompletionParams.timeoutMs, default 60 s for every existing caller) and
raise it to 120 s for the comprehensive surface only, across all three
generation paths: the on-demand regenerate, the on-demand warm, and the
nightly pre-generation. Raise the warm-pass budget to 130 s so the
provider's own timeout governs a genuine completion rather than clipping
it early. Annotate the comprehensive failure in both the generator and
the nightly loop so a generation that keeps failing is queryable instead
of leaving the surface empty without a trace.
2026-06-27 12:37:25 +02:00

77 lines
2.3 KiB
TypeScript

/**
* v1.21.5 — the Codex client honours the caller's per-request timeout
* override (`CompletionParams.timeoutMs`), falling back to the shared 60 s
* default when unset. The comprehensive briefing is the one caller that
* overrides it; the 60 s default was aborting its reasoning-heavy generation
* mid-stream on large accounts, leaving the briefing blank.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const safeFetch = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...a: unknown[]) => safeFetch(...a),
}));
import { CodexClient } from "../codex-client";
import { singleUserTurn } from "../types";
function sseResponse(): Response {
const encoder = new TextEncoder();
const events = [
`event: response.output_item.done\ndata: ${JSON.stringify({
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: '{"summary":"ok"}' }],
},
})}\n\n`,
`event: response.completed\ndata: ${JSON.stringify({
type: "response.completed",
response: { id: "r1", usage: { total_tokens: 10 } },
})}\n\n`,
];
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const ev of events) controller.enqueue(encoder.encode(ev));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function makeClient() {
return new CodexClient({
accessToken: "t",
accountId: "acct",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "t", accountId: "acct" }),
slugChain: ["gpt-5.5"],
});
}
beforeEach(() => {
vi.clearAllMocks();
safeFetch.mockResolvedValue(sseResponse());
});
describe("CodexClient timeout override", () => {
it("forwards an explicit timeoutMs to safeFetch", async () => {
await makeClient().generateCompletion(
singleUserTurn({ system: "s", user: "u", timeoutMs: 120_000 }),
);
expect(safeFetch.mock.calls[0][2].timeoutMs).toBe(120_000);
});
it("falls back to the 60 s default when no override is set", async () => {
await makeClient().generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
expect(safeFetch.mock.calls[0][2].timeoutMs).toBe(60_000);
});
});