diff --git a/changelog.d/fixes/8089-responses-system-image.md b/changelog.d/fixes/8089-responses-system-image.md new file mode 100644 index 0000000000..d372c97b89 --- /dev/null +++ b/changelog.d/fixes/8089-responses-system-image.md @@ -0,0 +1 @@ +- fix(sse): stop Codex/Responses sanitizer from turning system/developer image_url parts into invalid output_text (#8089) diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 3546a29462..5d81b787a1 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -67,7 +67,11 @@ function sanitizeContentPart(part: unknown, role: string): unknown { if (record.type === "image_url") { const url = imageUrlToText(record.image_url); - if (role === "user") { + // `output_text` is only a legal content-part type on assistant-role OUTPUT + // items. Every other role (user, system, developer, ...) is input-side and + // must use `input_image` -- otherwise the Codex/Responses backend rejects + // the replayed history with "Invalid value: 'output_text'" (#8089). + if (role !== "assistant") { const next: JsonRecord = { type: "input_image", image_url: url }; const image = toRecord(record.image_url); if (image?.detail !== undefined) next.detail = image.detail; diff --git a/tests/unit/codex-system-message-image-8089.test.ts b/tests/unit/codex-system-message-image-8089.test.ts new file mode 100644 index 0000000000..757ba53ef3 --- /dev/null +++ b/tests/unit/codex-system-message-image-8089.test.ts @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { sanitizeResponsesInputItems } from "../../open-sse/services/responsesInputSanitizer.ts"; +import { CodexExecutor } from "../../open-sse/executors/codex.ts"; + +test("[repro #8089] mid-conversation system-role message with image_url must NOT become output_text", () => { + const input = [ + { + type: "message", + role: "system", + content: [ + { type: "text", text: "Reminder: verify the diff before committing." }, + { type: "image_url", image_url: { url: "https://example.com/diff-screenshot.png" } }, + ], + }, + ]; + + const sanitized = sanitizeResponsesInputItems(input, true, {}) as Array<{ + content: Array<{ type: string }>; + }>; + const imagePart = sanitized[0].content.find( + (p) => p.type === "output_text" || p.type === "input_image" + ); + + assert.ok(imagePart); + assert.equal(imagePart?.type, "input_image"); +}); + +test("[repro #8089] developer-role message with image_url must NOT become output_text", () => { + const input = [ + { + type: "message", + role: "developer", + content: [{ type: "image_url", image_url: { url: "https://example.com/diff-screenshot.png" } }], + }, + ]; + + const sanitized = sanitizeResponsesInputItems(input, true, {}) as Array<{ + content: Array<{ type: string }>; + }>; + + assert.equal(sanitized[0].content[0].type, "input_image"); +}); + +test("assistant-role message with image_url still becomes output_text (unchanged behavior)", () => { + const input = [ + { + type: "message", + role: "assistant", + content: [{ type: "image_url", image_url: { url: "https://example.com/diff-screenshot.png" } }], + }, + ]; + + const sanitized = sanitizeResponsesInputItems(input, true, {}) as Array<{ + content: Array<{ type: string }>; + }>; + + assert.equal(sanitized[0].content[0].type, "output_text"); +}); + +test("Codex native passthrough: mid-conversation system message with image content never serializes output_text (#8089)", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [ + { + type: "message", + role: "system", + content: [ + { type: "text", text: "Reminder: verify the diff before committing." }, + { type: "image_url", image_url: { url: "https://example.com/diff-screenshot.png" } }, + ], + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "What do you see?" }], + }, + ], + stream: false, + }; + + const result = executor.transformRequest("gpt-5.6", body, false, { + requestEndpointPath: "/responses", + }); + + assert.equal(JSON.stringify(result.input).includes('"type":"output_text"'), false); +});