From 3d11f6fddd5039900feb52260ae2cb684de60a65 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 15:09:05 -0700 Subject: [PATCH 1/2] fix(responses): stop reshaping reasoning items that carry encrypted_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex replays the reasoning item it received in the next request's input, and a backend that issued `encrypted_content` verifies what comes back. The content-to-summary channel rewrite deletes `content` and substitutes a synthesized `summary`, so the client stored and replayed an item the issuer had never sent, and every later turn failed with "Could not decrypt the provided encrypted_content. Ensure the value is the unmodified encrypted_content from a previous response." No route change is needed to reach this: it fires on the second turn of a fresh session. The rewrite's replay round trip was verified against DeepSeek, which is `statelessResponses` and issues no blob — its reasoning replay goes through the proxy-side cache instead. Providers that do issue a blob joined the same route later through `preserveReasoningContentModels`, a flag whose own purpose is Chat-wire prompt-cache replay, and the verified premise did not follow them. Only the stored item is exempt. The `reasoning_text` delta events carry no blob and still route to the summary channel, so the expandable trace Codex renders for the live turn is unchanged. Co-Authored-By: Claude Fable 5 --- .../responses-reasoning-summary-rewrite.ts | 8 ++++ structure/04_transports-and-sidecars.md | 9 ++++ ...esponses-reasoning-summary-rewrite.test.ts | 46 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 21a8b6a5bf..0230879313 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -34,6 +34,14 @@ function reasoningTextOf(item: Record): string { /** Move a reasoning item's content channel into the summary channel. */ function reasoningItemToSummaryShape(item: Record): Record { if (item.type !== "reasoning") return item; + // An item carrying `encrypted_content` is replayed verbatim by the client, and the backend that + // issued the blob verifies the item it gets back ("Could not decrypt the provided + // encrypted_content. Ensure the value is the unmodified encrypted_content from a previous + // response."). Reshaping it here is a modification the client then replays on every later turn. + // The delta rewrite still gives Codex the expandable trace for the live turn; only the stored + // shape has to stay exactly as the upstream sent it. DeepSeek — the provider this rewrite was + // verified against — is `statelessResponses` and issues no blob, so it is unaffected. + if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; const text = reasoningTextOf(item); // Items that already use the summary channel (or carry no content text at // all) are left untouched: rewriting them could clear a valid summary. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index efd1c87dbb..538605ee6d 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -797,6 +797,15 @@ Codex app, so tool cells group like native models — while the text still round `content[reasoning_text]` shape. Diagnosis and codex-rs grouping evidence: `devlog/_fin/260709_native_response_pattern/`. +The content-to-summary channel rewrite skips any reasoning item that carries a native +`encrypted_content` blob. Codex replays the reasoning item it received, and a backend that issued +that blob verifies what comes back, so reshaping the stored item makes every later turn fail with +`Could not decrypt the provided encrypted_content`. The rewrite's round trip was verified against +DeepSeek, which is `statelessResponses` and issues no blob; providers that do issue one joined the +same route later through `preserveReasoningContentModels`. Only the stored item is exempt — the +`reasoning_text` delta events carry no blob and still route to the summary channel, so the live +expandable trace is unchanged. + The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential identity. API-key material is represented only by a process-keyed HMAC; OAuth replay is bound to the diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index 09b8e1bad7..868cc77d49 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -209,6 +209,52 @@ describe("responses reasoning summary channel rewrite", () => { expect(rewrite("not json")).toBe("not json"); expect(rewrite("[1,2]")).toBe("[1,2]"); }); + + // The client replays the reasoning item it received, and a backend that issued + // `encrypted_content` rejects a reshaped item on that replay. This rewrite's round-trip was + // verified against DeepSeek, which is stateless and issues no blob; providers that do issue one + // joined later through `preserveReasoningContentModels`. + describe("items carrying encrypted_content", () => { + const blobItem = { + type: "reasoning", + id: "rs_1", + status: "completed", + encrypted_content: "gAAAAAB-upstream-issued-blob", + content: [{ type: "reasoning_text", text: "thinking" }], + summary: [], + }; + + test("are returned byte-for-byte on output_item.done", () => { + const payload = { type: "response.output_item.done", output_index: 0, item: blobItem }; + expect(apply(payload)).toEqual(payload); + }); + + test("are returned byte-for-byte inside response.completed output", () => { + const payload = { + type: "response.completed", + response: { id: "resp_1", output: [blobItem] }, + }; + expect(apply(payload)).toEqual(payload); + }); + + test("are returned byte-for-byte through the non-streaming document rewrite", () => { + const doc = { id: "resp_1", object: "response", output: [blobItem] }; + expect(rewriteReasoningSummaryInJson(doc)).toBe(doc); + const json = JSON.stringify(doc); + expect(rewriteReasoningSummaryInJsonString(json)).toBe(json); + }); + + // Only the stored item is protected: the live trace Codex renders comes from the delta events, + // which carry no blob and are still routed to the summary channel. + test("do not disable the delta rewrite that renders the live trace", () => { + expect(apply({ + type: "response.reasoning_text.delta", + delta: "think", + item_id: "rs_1", + output_index: 0, + })).toMatchObject({ type: "response.reasoning_summary_text.delta", delta: "think" }); + }); + }); }); describe("routeUsesContentChannelReasoning", () => { From 10b68d21f8079dee5e87ebd4033c161bcea2d0d7 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 18:50:12 -0700 Subject: [PATCH 2/2] docs(responses): stop asserting a disproven cause for the blob-preservation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard is sound, but its comments claimed it fixed Grok's `Could not decrypt the provided encrypted_content` failure. Live bisection disproved that: Grok emits summary-channel reasoning natively, so `reasoningItemToSummaryShape` returns early and this rewrite never fires on that route. The real cause was `"content": null` on the replayed reasoning item, fixed separately. A false causal claim in a comment is worse than none — the next reader trusts it. The rule is restated on its own terms: an item carrying opaque provider state should not have its stored shape changed unless that backend has an explicit replay contract, which is why DeepSeek was safe and why the Kimi/GLM/NeuralWatt routes now on `preserveReasoningContentModels` are the ones this actually guards. Comments and prose only; no behaviour change. Co-Authored-By: Claude Fable 5 --- src/server/responses-reasoning-summary-rewrite.ts | 13 ++++++------- structure/04_transports-and-sidecars.md | 15 ++++++++------- tests/responses-reasoning-summary-rewrite.test.ts | 8 ++++---- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 0230879313..55c6d8ae7b 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -34,13 +34,12 @@ function reasoningTextOf(item: Record): string { /** Move a reasoning item's content channel into the summary channel. */ function reasoningItemToSummaryShape(item: Record): Record { if (item.type !== "reasoning") return item; - // An item carrying `encrypted_content` is replayed verbatim by the client, and the backend that - // issued the blob verifies the item it gets back ("Could not decrypt the provided - // encrypted_content. Ensure the value is the unmodified encrypted_content from a previous - // response."). Reshaping it here is a modification the client then replays on every later turn. - // The delta rewrite still gives Codex the expandable trace for the live turn; only the stored - // shape has to stay exactly as the upstream sent it. DeepSeek — the provider this rewrite was - // verified against — is `statelessResponses` and issues no blob, so it is unaffected. + // `encrypted_content` is opaque, state-bearing provider data, so the entire item must retain its + // upstream shape unless that backend has an explicit replay contract permitting a rewrite. This + // defensively protects content-channel backends that do issue blobs when the client replays the + // stored item. The delta rewrite can still provide the expandable trace for the live turn. + // DeepSeek — the provider this rewrite was verified against — is `statelessResponses` and issues + // no blob, so it is unaffected. if (typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) return item; const text = reasoningTextOf(item); // Items that already use the summary channel (or carry no content text at diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 538605ee6d..2074d78978 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -798,13 +798,14 @@ Codex app, so tool cells group like native models — while the text still round `devlog/_fin/260709_native_response_pattern/`. The content-to-summary channel rewrite skips any reasoning item that carries a native -`encrypted_content` blob. Codex replays the reasoning item it received, and a backend that issued -that blob verifies what comes back, so reshaping the stored item makes every later turn fail with -`Could not decrypt the provided encrypted_content`. The rewrite's round trip was verified against -DeepSeek, which is `statelessResponses` and issues no blob; providers that do issue one joined the -same route later through `preserveReasoningContentModels`. Only the stored item is exempt — the -`reasoning_text` delta events carry no blob and still route to the summary channel, so the live -expandable trace is unchanged. +`encrypted_content` blob. The blob is opaque, state-bearing provider data, so the item must +round-trip unchanged unless that backend has an explicit replay contract permitting a rewrite. +This defensively protects providers that issue blobs and later join the route through +`preserveReasoningContentModels`. The rewrite's round trip was verified against DeepSeek, which is +`statelessResponses` and issues no blob. Grok is unaffected in practice because it natively emits +summary-channel reasoning and no `reasoning_text` events, so this content-to-summary item rewrite +does not engage on its route. Only the stored item is exempt — `reasoning_text` delta events carry +no blob and still route to the summary channel, so the live expandable trace is unchanged. The process-local raw-reasoning fallback is fail-closed unless a request has an explicit client thread plus an exact provider destination, wire adapter, final model, and physical credential diff --git a/tests/responses-reasoning-summary-rewrite.test.ts b/tests/responses-reasoning-summary-rewrite.test.ts index 868cc77d49..4ba42acf91 100644 --- a/tests/responses-reasoning-summary-rewrite.test.ts +++ b/tests/responses-reasoning-summary-rewrite.test.ts @@ -210,10 +210,10 @@ describe("responses reasoning summary channel rewrite", () => { expect(rewrite("[1,2]")).toBe("[1,2]"); }); - // The client replays the reasoning item it received, and a backend that issued - // `encrypted_content` rejects a reshaped item on that replay. This rewrite's round-trip was - // verified against DeepSeek, which is stateless and issues no blob; providers that do issue one - // joined later through `preserveReasoningContentModels`. + // `encrypted_content` is opaque, state-bearing provider data, so preserve the complete item + // shape defensively when the client replays it. This rewrite's round-trip was verified against + // DeepSeek, which is stateless and issues no blob; providers that do issue one joined later + // through `preserveReasoningContentModels`. describe("items carrying encrypted_content", () => { const blobItem = { type: "reasoning",