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",