diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 346c23ffa9..4533b30958 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -1185,10 +1185,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { !(state.reasoningItemsWithDelta instanceof Set && state.reasoningItemsWithDelta.size > 0); if (emittedForItem || emittedWithoutItemId) return null; - // #7095/#7176 reconciliation: computed WITHOUT mutating `item`, so an - // encrypted-only reasoning item (and its `encrypted_content`) is never - // rewritten with a fabricated `summary` — the placeholder only feeds this - // synthetic client-facing delta chunk. + // #7176/#7243: only synthesize from real upstream plaintext — never mutate + // `item` and never fabricate placeholder text for encrypted-only reasoning. const summaryText = getVisibleResponsesReasoningSummaryText(item); if (!summaryText) return null; return buildResponsesReasoningDeltaChunk(state, summaryText); diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index e35ad24854..01999f9ac9 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -177,27 +177,19 @@ export function extractResponsesReasoningSummaryText(item) { .join("\n\n"); } -// #7095/#7176 — when Codex exposes a reasoning item only as encrypted private -// reasoning (no plaintext summary), chat clients would otherwise see nothing in -// their thinking panel. Reconciles two goals that used to be in tension: -// - #7095 wants a visible placeholder in the chat client. -// - #7176 wants the upstream response item left untouched, so `encrypted_content` -// (needed by Codex for subsequent requests) is never overwritten by a -// fabricated `summary`. -// This function computes the placeholder text WITHOUT mutating `item` — callers -// use the returned text for synthetic client-facing events only. -const ENCRYPTED_REASONING_PLACEHOLDER = - "Codex is reasoning, but the upstream Responses API exposed this reasoning block only as encrypted private reasoning. OmniRoute cannot recover the plaintext."; - +// #7095/#7176/#7243 — when Codex exposes a reasoning item only as encrypted +// private reasoning (no plaintext summary), callers may synthesize client-facing +// reasoning summary events from this helper. Reconciles three goals: +// - #7176: never mutate the upstream item — `encrypted_content` (needed by +// Codex for subsequent requests) must not be overwritten with a fabricated +// `summary`. +// - #7095: real plaintext summaries from upstream are forwarded to chat +// clients that render a thinking panel. +// - #7243: when upstream provides no plaintext summary, do NOT fabricate an +// alarming error-like paragraph into `reasoning_summary_text.delta` — clients +// would display it as if it were real reasoning. Return empty so synthetic +// summary events are suppressed; the reasoning item (with `encrypted_content`) +// still arrives on `response.output_item.done`. export function getVisibleResponsesReasoningSummaryText(item) { - const existingSummary = extractResponsesReasoningSummaryText(item); - if (existingSummary) return existingSummary; - - const hasEncryptedReasoning = - item && - item.type === "reasoning" && - typeof item.encrypted_content === "string" && - item.encrypted_content.length > 0; - - return hasEncryptedReasoning ? ENCRYPTED_REASONING_PLACEHOLDER : ""; + return extractResponsesReasoningSummaryText(item); } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 45b0d92c0c..c2f81e035c 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1047,9 +1047,9 @@ export function createSSEStream(options: StreamOptions = {}) { return; } - // #7095/#7176 reconciliation: compute the visible placeholder WITHOUT - // mutating `item` — the encrypted reasoning item (and its `encrypted_content`, - // required by Codex for subsequent requests) is forwarded to the client intact. + // #7176/#7243: only synthesize summary events from real upstream plaintext — + // never mutate `item` and never fabricate alarming placeholder text for + // encrypted-only reasoning (`encrypted_content` still forwards intact). const visibleSummary = getVisibleResponsesReasoningSummaryText(item); if (!visibleSummary) { diff --git a/tests/integration/codex-chat-reasoning-http-e2e.test.ts b/tests/integration/codex-chat-reasoning-http-e2e.test.ts index 44363fd8fc..051b5ba235 100644 --- a/tests/integration/codex-chat-reasoning-http-e2e.test.ts +++ b/tests/integration/codex-chat-reasoning-http-e2e.test.ts @@ -285,14 +285,16 @@ test("chat completions streams Codex Responses reasoning through real route HTTP const chunks = parseSse(raw); assert.equal(chunks.at(-1), "[DONE]"); const payloads = chunks.slice(0, -1).map((chunk) => JSON.parse(chunk)); + // #7243: encrypted-only reasoning must not fabricate client-visible + // reasoning_content (old #7095/#7304 placeholder is gone). const reasoningContentDeltas = payloads .map((payload) => payload.choices?.[0]?.delta?.reasoning_content) .filter((content): content is string => Boolean(content)); - assert.equal(reasoningContentDeltas.length, 1); + assert.equal(reasoningContentDeltas.length, 0); const reasoningContent = reasoningContentDeltas.join(""); - assert.match(reasoningContent, /encrypted (?:state|private reasoning)/i); + assert.doesNotMatch(reasoningContent, /encrypted (?:state|private reasoning)/i); + assert.doesNotMatch(raw, /OmniRoute cannot recover|Codex is reasoning/i); assert(!raw.includes(ENCRYPTED_CONTENT_SENTINEL), raw); - assert(!reasoningContent.includes(ENCRYPTED_CONTENT_SENTINEL), reasoningContent); assert( payloads.some((payload) => payload.choices?.[0]?.delta?.content === "The answer is 42.") ); diff --git a/tests/unit/encrypted-reasoning-summary-7243.test.ts b/tests/unit/encrypted-reasoning-summary-7243.test.ts new file mode 100644 index 0000000000..12f918adaf --- /dev/null +++ b/tests/unit/encrypted-reasoning-summary-7243.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { getVisibleResponsesReasoningSummaryText } = await import( + "../../open-sse/translator/response/openai-responses/pureHelpers.ts" +); + +test("#7243 getVisibleResponsesReasoningSummaryText returns upstream plaintext summary", () => { + const item = { + type: "reasoning", + summary: [{ type: "summary_text", text: "Planning the next edit." }], + encrypted_content: "opaque-blob", + }; + + assert.equal(getVisibleResponsesReasoningSummaryText(item), "Planning the next edit."); +}); + +test("#7243 getVisibleResponsesReasoningSummaryText suppresses synthetic text for encrypted-only reasoning", () => { + const item = { + type: "reasoning", + encrypted_content: "opaque-blob", + }; + + assert.equal(getVisibleResponsesReasoningSummaryText(item), ""); + assert.doesNotMatch( + getVisibleResponsesReasoningSummaryText(item), + /OmniRoute cannot recover|encrypted private reasoning/i + ); +}); + +test("#7243 getVisibleResponsesReasoningSummaryText returns empty for non-reasoning or empty summary", () => { + assert.equal(getVisibleResponsesReasoningSummaryText(null), ""); + assert.equal(getVisibleResponsesReasoningSummaryText({ type: "message" }), ""); + assert.equal(getVisibleResponsesReasoningSummaryText({ type: "reasoning", summary: [] }), ""); +}); diff --git a/tests/unit/stream-utilities.test.ts b/tests/unit/stream-utilities.test.ts index 52d6d58b64..ba344fe6f2 100644 --- a/tests/unit/stream-utilities.test.ts +++ b/tests/unit/stream-utilities.test.ts @@ -230,16 +230,11 @@ test("createPassthroughStreamWithLogger synthesizes reasoning summary events fro assert.match(result, /event: response\.output_item\.done/); }); -// Reconciles #7095 (xz-dev — chat clients never saw ANY signal that Codex was -// reasoning when the upstream Responses API exposed only encrypted private -// reasoning) with #7176 (JxnLexn — mutating `item.summary` with a fabricated -// placeholder corrupted the response item forwarded downstream, discarding the -// `encrypted_content` shape Codex needs for follow-up requests). Both goals are -// satisfied simultaneously: the client-facing synthetic delta/part events still -// carry the placeholder text, but the forwarded `response.output_item.done` -// payload is untouched — `encrypted_content` survives and no `summary` is -// fabricated onto the wire item. -test("createPassthroughStreamWithLogger shows a placeholder for encrypted reasoning items without mutating the forwarded item", async () => { +// #7176/#7243 — encrypted-only reasoning must not mutate the forwarded item and +// must not fabricate alarming placeholder text into client-visible reasoning +// summary streams. The reasoning item (with `encrypted_content`) still reaches +// the client on `response.output_item.done` for continuation. +test("createPassthroughStreamWithLogger suppresses synthetic summary for encrypted-only reasoning without mutating the forwarded item", async () => { const transform = createPassthroughStreamWithLogger( "codex", null, @@ -275,9 +270,9 @@ test("createPassthroughStreamWithLogger shows a placeholder for encrypted reason result += decoder.decode(value); } - // #7095: chat clients still see the placeholder via the synthetic events. - assert.match(result, /event: response\.reasoning_summary_text\.delta/); - assert.match(result, /Codex is reasoning/); + // #7243: no fabricated reasoning summary deltas when upstream has no plaintext. + assert.doesNotMatch(result, /event: response\.reasoning_summary_text\.delta/); + assert.doesNotMatch(result, /Codex is reasoning/); // #7176: the forwarded response.output_item.done payload is untouched — // encrypted_content survives intact and no fabricated `summary` is present. @@ -332,9 +327,9 @@ test("createPassthroughStreamWithLogger backfills completed output with encrypte result += decoder.decode(value); } - // The placeholder still reached the client (#7095). - assert.match(result, /event: response\.reasoning_summary_text\.delta/); - assert.match(result, /Codex is reasoning/); + // #7243: no fabricated reasoning summary deltas when upstream has no plaintext. + assert.doesNotMatch(result, /event: response\.reasoning_summary_text\.delta/); + assert.doesNotMatch(result, /Codex is reasoning/); // The item re-serialized into the completed snapshot carries the original // encrypted_content and never a fabricated summary (#7176).