diff --git a/src/server/responses-reasoning-summary-rewrite.ts b/src/server/responses-reasoning-summary-rewrite.ts index 21a8b6a5bf..55c6d8ae7b 100644 --- a/src/server/responses-reasoning-summary-rewrite.ts +++ b/src/server/responses-reasoning-summary-rewrite.ts @@ -34,6 +34,13 @@ 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; + // `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 // 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..2074d78978 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -797,6 +797,16 @@ 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. 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 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..4ba42acf91 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]"); }); + + // `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", + 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", () => {