diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a88279efa..de158d1ec 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - Fixed `skill://` tool resolution losing loaded session skills when a tool runs outside the session-initialization module state. Internal URL resolution now prefers the caller's `session.skills` snapshot before falling back to the process-global skill list, so `read skill://` works across tool execution boundaries. ([#3436](https://github.com/can1357/oh-my-pi/issues/3436)) -- Fixed `@image` mentions on OpenAI Codex Responses (chatgpt.com `gpt-5.5` and siblings) failing with `Codex error event: [OneOfParam] [input[N].content[…]] [invalid_enum_value] Invalid value: 'input_image'. Supported values are: 'input_text'.`. `convertToLlm` for `fileMention` always emitted a `developer`-role message, so the auto-attached image landed in a Responses content array that the Codex backend (and OpenAI Responses generally) only allows to carry `input_text`. #3421's previous fix only stopped the Codex Responses Lite header from going out on image-bearing turns; the full transport kept rejecting the same payload. Image-bearing file mentions now ride as `user` (text-only mentions keep `developer` to preserve the existing instruction-priority intent). ([#3443](https://github.com/can1357/oh-my-pi/issues/3443)) +- Fixed `@image` mentions on OpenAI Codex Responses (chatgpt.com `gpt-5.5` and siblings) failing with `Codex error event: [OneOfParam] [input[N].content[…]] [invalid_enum_value] Invalid value: 'input_image'. Supported values are: 'input_text'.`. `convertToLlm` for `fileMention` always emitted a `developer`-role message, so the auto-attached image landed in a Responses content array that the Codex backend (and OpenAI Responses generally) only allows to carry `input_text`. #3421's previous fix only stopped the Codex Responses Lite header from going out on image-bearing turns; the full transport kept rejecting the same payload. `convertToLlm` now splits a mixed-content `fileMention` into two messages — text-only files stay on `developer` (so the auto-read context keeps instruction priority), while image-bearing files ride on `user` (the only Responses content slot that accepts `input_image`). ([#3443](https://github.com/can1357/oh-my-pi/issues/3443)) ## [16.1.18] - 2026-06-25 diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index 7b21230ea..867923562 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -482,72 +482,88 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis * - Custom extensions and tools */ export function convertToLlm(messages: AgentMessage[]): Message[] { - return messages - .map((m): Message | undefined => { - switch (m.role) { - case "bashExecution": - if (m.excludeFromContext) { - return undefined; - } - return { + return messages.flatMap((m): Message[] => { + switch (m.role) { + case "bashExecution": + if (m.excludeFromContext) { + return []; + } + return [ + { role: "user", content: [{ type: "text", text: bashExecutionToText(m) }], attribution: "user", timestamp: m.timestamp, - }; - case "pythonExecution": - if (m.excludeFromContext) { - return undefined; - } - return { + }, + ]; + case "pythonExecution": + if (m.excludeFromContext) { + return []; + } + return [ + { role: "user", content: [{ type: "text", text: pythonExecutionToText(m) }], attribution: "user", timestamp: m.timestamp, - }; - case "fileMention": { - const fileContents = m.files - .map(file => { - const inner = file.content ? `\n${file.content}\n` : "\n"; - return `${inner}`; - }) - .join("\n"); - const content: (TextContent | ImageContent)[] = [{ type: "text" as const, text: fileContents }]; - let hasImage = false; - for (const file of m.files) { - if (file.image) { - content.push(file.image); - hasImage = true; - } + }, + ]; + case "fileMention": { + // One `fileMention` can mix `@notes.md` (text) and `@screenshot.png` (image) + // in the same turn (`generateFileMentionMessages` packs every `@…` into a + // single message). Splitting by image presence keeps text-only mentions on + // the higher-priority `developer` slot while routing image attachments + // through `user`, the only Responses content slot that legitimately accepts + // `input_image` (Codex chatgpt.com /codex/responses rejects everything else + // with `Invalid value: 'input_image'`, #3443). + const wrap = (file: FileMentionMessage["files"][number]): string => { + const inner = file.content ? `\n${file.content}\n` : "\n"; + return `${inner}`; + }; + const textFiles = m.files.filter(file => !file.image); + const imageFiles = m.files.filter(file => file.image); + const out: Message[] = []; + if (textFiles.length > 0) { + out.push({ + role: "developer", + content: [{ type: "text" as const, text: textFiles.map(wrap).join("\n") }], + attribution: "user", + timestamp: m.timestamp, + }); + } + if (imageFiles.length > 0) { + const content: (TextContent | ImageContent)[] = [ + { type: "text" as const, text: imageFiles.map(wrap).join("\n") }, + ]; + for (const file of imageFiles) { + if (file.image) content.push(file.image); } - // Image-bearing mentions must ride as `user`: developer/system - // messages only accept text on OpenAI Responses + Codex; sending - // an `input_image` in a developer-role content array gets - // rejected with `Invalid value: 'input_image'. Supported values - // are: 'input_text'.` (Codex chatgpt.com backend, #3443). - return { - role: hasImage ? "user" : "developer", + out.push({ + role: "user", content, attribution: "user", timestamp: m.timestamp, - }; + }); } - case "custom": - case "hookMessage": - case "branchSummary": - case "compactionSummary": - case "user": - case "developer": - case "assistant": - case "toolResult": - // Core roles share one transformer with agent-core — - // duplicating them here is how snapcompact frames once - // silently fell off the provider request. - return convertMessageToLlm(m); - default: - m satisfies never; - return undefined; + return out; } - }) - .filter(m => m !== undefined); + case "custom": + case "hookMessage": + case "branchSummary": + case "compactionSummary": + case "user": + case "developer": + case "assistant": + case "toolResult": { + // Core roles share one transformer with agent-core — + // duplicating them here is how snapcompact frames once + // silently fell off the provider request. + const converted = convertMessageToLlm(m); + return converted ? [converted] : []; + } + default: + m satisfies never; + return []; + } + }); } diff --git a/packages/coding-agent/test/session-messages.test.ts b/packages/coding-agent/test/session-messages.test.ts index f067e336b..c52676e51 100644 --- a/packages/coding-agent/test/session-messages.test.ts +++ b/packages/coding-agent/test/session-messages.test.ts @@ -136,12 +136,13 @@ describe("convertToLlm custom message mapping", () => { expect(text).toContain("export const config = {};"); }); - it("routes image-bearing file mentions through user role to satisfy Codex schema (#3443)", () => { - // `developer` (and `system`) Responses messages reject `input_image` - // with `Invalid value: 'input_image'. Supported values are: 'input_text'.` - // Issue #3421's earlier fix only disabled Codex Responses Lite for image - // turns; the role-promotion here closes the bug at the source so every - // Responses-family provider accepts attached images. + it("splits mixed text + image file mentions into developer + user messages (#3443)", () => { + // `developer` (and `system`) Responses messages reject `input_image` with + // `Invalid value: 'input_image'. Supported values are: 'input_text'.` + // `generateFileMentionMessages` packs every `@…` into one `fileMention`, + // so a `@notes.md @diagram.png` turn would have demoted the text payload + // to `user` (losing the instruction-priority intent) before #3443; now the + // text-only file stays on `developer` and only the image file rides as `user`. const image: ImageContent = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; const messages: AgentMessage[] = [ { @@ -156,17 +157,50 @@ describe("convertToLlm custom message mapping", () => { const converted = convertToLlm(messages); + expect(converted).toHaveLength(2); + + const dev = converted[0]; + expect(dev?.role).toBe("developer"); + expectAttribution(dev, "user"); + if (dev?.role !== "developer" || !Array.isArray(dev.content)) { + throw new Error("Expected developer array content for text mention"); + } + const devText = dev.content.find(content => content.type === "text")?.text ?? ""; + expect(devText).toContain(''); + expect(devText).toContain("alpha"); + expect(devText).not.toContain(''); + expect(dev.content.some(content => content.type === "image")).toBe(false); + + const user = converted[1]; + expect(user?.role).toBe("user"); + expectAttribution(user, "user"); + if (user?.role !== "user" || !Array.isArray(user.content)) { + throw new Error("Expected user array content for image mention"); + } + const userText = user.content.find(content => content.type === "text")?.text ?? ""; + expect(userText).toContain(''); + expect(userText).not.toContain(''); + expect(user.content.filter(content => content.type === "image")).toEqual([image]); + }); + + it("emits a user-only message when every mention is an image (#3443)", () => { + const image: ImageContent = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; + const messages: AgentMessage[] = [ + { + role: "fileMention", + files: [{ path: "screenshot.png", content: "", image }], + timestamp: Date.now(), + }, + ]; + + const converted = convertToLlm(messages); + expect(converted).toHaveLength(1); expect(converted[0]?.role).toBe("user"); - expectAttribution(converted[0], "user"); if (converted[0]?.role !== "user" || !Array.isArray(converted[0].content)) { throw new Error("Expected user array content"); } - const text = converted[0].content.find(content => content.type === "text")?.text ?? ""; - expect(text).toContain(''); - expect(text).toContain(''); - const images = converted[0].content.filter(content => content.type === "image") as ImageContent[]; - expect(images).toEqual([image]); + expect(converted[0].content.filter(content => content.type === "image")).toEqual([image]); }); it("allows custom messages to opt into user attribution", () => {