From fff62f10a7fe3ed7bd604b08c795e581deefcf71 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 26 Aug 2026 01:11:46 +0200 Subject: [PATCH] fix(responses-continuation): fail closed on a log-truncated stored input/output array (#11473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated in a combined 2-PR batch worktree off release/v3.8.51 tip. - Focused test: responses-continuation-store.test.ts — part of batch's 46/46 node:test run - typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity — all OK - Full-repo lint: 228 pre-existing dashboard react-hooks/* findings, unrelated to this diff Thanks for the live-verified root cause — a truncation sentinel getting forwarded upstream as a real Responses-API item, breaking the turn with a genuine 400, is exactly the kind of defect that's easy to miss without production traffic to reproduce against. --- src/lib/db/responsesContinuationStore.ts | 18 +++++++++ .../unit/responses-continuation-store.test.ts | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index e5a9710293..ef7a6e3de3 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -30,6 +30,23 @@ function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +// Both array-bounding implementations that clip a stored artifact's payload +// for log-storage size (cloneBoundedChatLogPayload in +// open-sse/handlers/chatCore/logTruncation.ts, and cloneBoundedForLog in +// open-sse/utils/requestLogger.ts) prepend this sentinel in place of the +// items they dropped once an array exceeds their tail-item cap -- so a real, +// ordinary-length conversation resolves fine, but any conversation whose +// input/output grew past that cap gets this object silently standing in for +// real history. Reading it back as a genuine Responses-API item sent a +// malformed reconstructed request upstream (translator 400: +// "input item type 'missing' cannot be represented..."), which is worse than +// the plain cache-miss this function is otherwise designed to fail into. +const TRUNCATED_ARRAY_MARKER = "_omniroute_truncated_array"; + +function containsTruncatedArrayMarker(items: readonly unknown[]): boolean { + return items.some((item) => isPlainRecord(item) && item[TRUNCATED_ARRAY_MARKER] === true); +} + /** * Resolve the full input + output a prior Responses API call produced, so * the caller can reconstruct `full_input = stored.input + stored.output + @@ -88,6 +105,7 @@ export function resolvePreviousResponseState( ? clientResponse.output : clientResponse?.summary?.output; if (!Array.isArray(input) || !Array.isArray(output)) return null; + if (containsTruncatedArrayMarker(input) || containsTruncatedArrayMarker(output)) return null; return { input, output }; } diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts index 735a1c9480..4d5a5c7cb4 100644 --- a/tests/unit/responses-continuation-store.test.ts +++ b/tests/unit/responses-continuation-store.test.ts @@ -223,6 +223,44 @@ test("resolvePreviousResponseState resolves input from clientRawRequest when pro }); }); +test("resolvePreviousResponseState fails closed when the stored input array was log-truncated", () => { + // Real production shape: cloneBoundedChatLogPayload (chatCore/logTruncation.ts) + // and cloneBoundedForLog (utils/requestLogger.ts) both prepend an + // `_omniroute_truncated_array` sentinel in place of the items they dropped + // once a logged array exceeds their tail-item cap (~24 items) -- routine + // for any conversation that's been going a while, not an edge case. Reading + // that sentinel back as a real Responses-API item and forwarding it upstream + // produced a live 400: "input item type 'missing' cannot be represented in + // Chat Completions" -- worse than the plain cache-miss this function is + // otherwise designed to fail into. + insertCallLog({ + id: "log-7", + responseId: "resp_gen-truncated-history", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-7.json", + }); + writeArtifact("2026-01-01/log-7.json", { + clientRawRequest: { + body: { + input: [ + { _omniroute_truncated_array: true, originalLength: 26, retainedTailItems: 24 }, + { type: "function_call_output", call_id: "call_1", output: "ok" }, + ], + }, + }, + providerRequest: { body: { input: [] } }, + clientResponse: { + summary: { + id: "resp_gen-truncated-history", + output: [{ type: "message", role: "assistant", content: "hello" }], + }, + }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_gen-truncated-history", "key-1"), null); +}); + test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => { insertCallLog({ id: "log-5",