From d2d4cf0e1758265c4ca5b030fb82c093545a70be Mon Sep 17 00:00:00 2001 From: Saren Date: Sun, 12 Jul 2026 06:47:51 -0700 Subject: [PATCH] fix(sse): normalize assistant input_text to output_text in Codex Responses input (#6932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex-cli replays assistant history with content parts typed as `input_text`, but the Responses API only accepts `output_text` (or `refusal`) on assistant turns — `input_text` is user-only. `normalizeCodexMessageContentPart` previously only rewrote parts literally typed `text`, leaving explicit `input_text` on assistant turns untouched, which the Codex/OpenAI backend rejects with a 400. Rewrite explicit `input_text` (and `text`) to `output_text` on assistant-role parts, dropping the assistant-only `annotations`, `logprobs`, and `obfuscation` fields. Mode-agnostic, applies to all Codex models. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- ...odex-assistant-input-text-normalization.md | 1 + open-sse/utils/responsesInputNormalization.ts | 9 ++++ .../unit/responses-translation-fixes.test.ts | 54 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 changelog.d/fixes/6932-codex-assistant-input-text-normalization.md diff --git a/changelog.d/fixes/6932-codex-assistant-input-text-normalization.md b/changelog.d/fixes/6932-codex-assistant-input-text-normalization.md new file mode 100644 index 0000000000..3ae7dc8dc9 --- /dev/null +++ b/changelog.d/fixes/6932-codex-assistant-input-text-normalization.md @@ -0,0 +1 @@ +- fix(sse): `normalizeCodexMessageContentPart` now rewrites explicit `type: "input_text"` (not just `type: "text"`) to `output_text` on assistant-role Codex Responses input parts, so replayed assistant history sent by codex-cli as `input_text` is no longer rejected by the Codex/OpenAI backend (#6932) diff --git a/open-sse/utils/responsesInputNormalization.ts b/open-sse/utils/responsesInputNormalization.ts index da1517a812..7c176d9ab0 100644 --- a/open-sse/utils/responsesInputNormalization.ts +++ b/open-sse/utils/responsesInputNormalization.ts @@ -10,6 +10,15 @@ function normalizeCodexMessageContentPart(part: unknown, role: string): unknown const record = { ...(part as JsonRecord) }; if (record.type === "text") record.type = textPartTypeForRole(role); + // Assistant history in the Responses API must use `output_text` (or `refusal`), + // never `input_text` (which is user-only). codex-cli sends assistant turns as + // `input_text`; normalize them so the Codex/OpenAI backend accepts the replay. + if (role === "assistant" && (record.type === "input_text" || record.type === "text")) { + record.type = "output_text"; + delete record.annotations; + delete record.logprobs; + delete record.obfuscation; + } return record; } diff --git a/tests/unit/responses-translation-fixes.test.ts b/tests/unit/responses-translation-fixes.test.ts index c29641c6a3..2cffb53ff2 100644 --- a/tests/unit/responses-translation-fixes.test.ts +++ b/tests/unit/responses-translation-fixes.test.ts @@ -126,6 +126,60 @@ test("Codex Responses input: null input normalizes to an empty list (not [null]) assert.deepEqual(body.input, []); }); +test("Codex Responses input: assistant history normalized to output_text (OpenAI/Codex rejects input_text on assistant turns)", () => { + const body: Record = { + input: [ + { + type: "message", + role: "assistant", + content: [ + { + type: "input_text", + text: "Previous assistant answer", + annotations: [{ type: "url_citation", url: "https://example.com" }], + logprobs: [{ token: "Previous" }], + obfuscation: "opaque", + }, + { type: "scoped_content", scope: "internal", content: "Preserve me" }, + ], + }, + { + type: "message", + role: "user", + content: [ + { type: "input_image", image_url: "https://example.com/image.png", detail: "high" }, + { type: "input_file", file_id: "file_123" }, + ], + }, + { type: "function_call", call_id: "call_123", name: "lookup", arguments: "{}" }, + { type: "function_call_output", call_id: "call_123", output: "done" }, + ], + }; + + normalizeCodexResponsesInput(body); + + assert.deepEqual(body.input, [ + { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: "Previous assistant answer" }, + { type: "scoped_content", scope: "internal", content: "Preserve me" }, + ], + }, + { + type: "message", + role: "user", + content: [ + { type: "input_image", image_url: "https://example.com/image.png", detail: "high" }, + { type: "input_file", file_id: "file_123" }, + ], + }, + { type: "function_call", call_id: "call_123", name: "lookup", arguments: "{}" }, + { type: "function_call_output", call_id: "call_123", output: "done" }, + ]); +}); + test("Responses→Chat: null input normalizes to an empty list (not [null])", () => { assert.deepEqual(normalizeResponsesInputForChat(null), []); });