diff --git a/changelog.d/fixes/13698-agent-message-responses-sanitizer.md b/changelog.d/fixes/13698-agent-message-responses-sanitizer.md new file mode 100644 index 0000000000..226eb6a79a --- /dev/null +++ b/changelog.d/fixes/13698-agent-message-responses-sanitizer.md @@ -0,0 +1,6 @@ +- **fix(providers):** the shared Responses-API input sanitizer now converts Codex's proprietary + `agent_message` input items (used for multi-agent task/reply passing) into a plain `message` + item before forwarding to any non-Codex-native Responses upstream. Previously such items + reached third-party Responses endpoints untouched, and OpenCode Go Muse Spark 1.3 rejected the + request with `input[N] did not match any supported type` (#13698). The real Codex/ChatGPT + native passthrough path is unaffected and continues to receive `agent_message` items as-is. diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 2c9d98d25e..34ee315801 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -34,7 +34,7 @@ import { withCodexFingerprintCredentials, } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; -import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; +import { sanitizeCodexResponsesInput } from "../services/responsesInputSanitizer.ts"; import { applyReasoningInputPolicy } from "../services/reasoningInputPolicy.ts"; import { getForcedReasoningEffort } from "../utils/reasoningRuleContext.ts"; import { normalizeCodexVerbosity } from "../services/codexVerbosity.ts"; @@ -1306,11 +1306,7 @@ export class CodexExecutor extends BaseExecutor { normalizeCodexResponsesInput(body); - if (Array.isArray(body.input)) { - body.input = sanitizeResponsesInputItems(body.input, false, { - dropInternalAssistantMessages: !nativeCodexPassthrough, - }); - } + sanitizeCodexResponsesInput(body, nativeCodexPassthrough); stripOrphanedCodexFunctionCallOutputs(body); repairMissingCodexToolCallOutputs(body); diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 48f1eed051..9fd271a529 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -3,6 +3,12 @@ import { isValidResponsesItemId } from "./responsesItemId.ts"; type JsonRecord = Record; type SanitizeResponsesInputOptions = { dropInternalAssistantMessages?: boolean; + // Codex's multi_agent_v2 uses a proprietary `agent_message` input-item type to pass + // tasks/replies between a parent thread and a sub-agent. The real Codex/ChatGPT backend + // understands this type; every other Responses-API upstream (e.g. Muse Spark 1.3 / + // opencode-go) does not and rejects the request with `input[N] did not match any + // supported type` (#13698). Set true only for the native Codex/ChatGPT passthrough path. + preserveAgentMessages?: boolean; }; const INTERNAL_ASSISTANT_PHASES = new Set(["commentary"]); const SERVER_ITEM_ID_PREFIX_BY_TYPE: Record = { @@ -141,11 +147,67 @@ function sanitizeOutputContent(record: JsonRecord): JsonRecord { return { ...record, output }; } -function sanitizeInputItem(item: unknown): unknown { +function isAgentMessageInputItem(record: JsonRecord): boolean { + return record.type === "agent_message" || record.role === "agent_message"; +} + +function agentMessageContextText(record: JsonRecord): string { + return `[Agent message context] ${JSON.stringify({ author: record.author, recipient: record.recipient })}`; +} + +function agentMessageContentPart(part: unknown): unknown { + const record = toRecord(part); + if (!record) return { type: "input_text", text: typeof part === "string" ? part : "" }; + + if (record.type === "encrypted_content") { + // No plaintext to forward for an encrypted part -- surface a placeholder instead of + // leaking an opaque payload or silently dropping the item. + return { type: "input_text", text: "[Agent message encrypted content omitted]" }; + } + + if (record.type === "output_text" || record.type === "text") { + return { type: "input_text", text: typeof record.text === "string" ? record.text : "" }; + } + + if (record.type === "image_url" || record.type === "input_image") { + return sanitizeContentPart(record, "user"); + } + + return record; +} + +function convertAgentMessageItem(record: JsonRecord): JsonRecord { + const contextPart = { type: "input_text", text: agentMessageContextText(record) }; + const content = record.content; + + let bodyParts: unknown[]; + if (typeof content === "string") { + bodyParts = [{ type: "input_text", text: content }]; + } else if (Array.isArray(content)) { + bodyParts = content.map((part) => + typeof part === "string" ? { type: "input_text", text: part } : agentMessageContentPart(part) + ); + } else { + bodyParts = [{ type: "input_text", text: JSON.stringify(content ?? "") }]; + } + + return { + type: "message", + role: "user", + content: [contextPart, ...bodyParts], + }; +} + +function sanitizeInputItem(item: unknown, options: SanitizeResponsesInputOptions): unknown { const record = toRecord(item); if (!record) return item; - let next = sanitizeInputItemId(record); + let next = record; + if (!options.preserveAgentMessages && isAgentMessageInputItem(next)) { + next = convertAgentMessageItem(next); + } + + next = sanitizeInputItemId(next); if (isResponsesMessageItem(next)) { next = sanitizeMessageContent(next); } @@ -175,8 +237,22 @@ export function sanitizeResponsesInputItems( } const cloned = clone ? structuredClone(item) : item; - sanitized.push(sanitizeInputItem(cloned)); + sanitized.push(sanitizeInputItem(cloned, options)); } return sanitized; } + +// Codex-specific call site (#13698): the native Codex/ChatGPT passthrough path is the only +// caller that needs both flags derived from one boolean, kept here (not in the frozen +// open-sse/executors/codex.ts) so a per-property change never grows that file's line count. +export function sanitizeCodexResponsesInput( + body: Record, + nativeCodexPassthrough: boolean +): void { + if (!Array.isArray(body.input)) return; + body.input = sanitizeResponsesInputItems(body.input, false, { + dropInternalAssistantMessages: !nativeCodexPassthrough, + preserveAgentMessages: nativeCodexPassthrough, + }); +} diff --git a/tests/unit/responses-input-sanitizer-agent-message.test.ts b/tests/unit/responses-input-sanitizer-agent-message.test.ts new file mode 100644 index 0000000000..356152a75a --- /dev/null +++ b/tests/unit/responses-input-sanitizer-agent-message.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { sanitizeResponsesInputItems } from "../../open-sse/services/responsesInputSanitizer.ts"; + +// #13698: OpenCode Go Muse Spark 1.3 rejects Codex's proprietary `agent_message` input-item +// type on its Responses endpoint with `input[N] did not match any supported type`. The shared +// sanitizer must convert `agent_message` items into a plain `message` item for every +// non-Codex-native Responses upstream, while leaving the real Codex/ChatGPT native passthrough +// path untouched (it understands `agent_message` natively). + +test("#13698: converts a type=agent_message item into a supported message item, preserving context", () => { + const input = [ + { + type: "agent_message", + author: "worker", + recipient: "main", + content: [{ type: "input_text", text: "Synthetic worker result: 2+2=4." }], + }, + ]; + + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + + assert.equal(item.type, "message"); + assert.equal(item.role, "user"); + const content = item.content as Array>; + assert.equal(content.length, 2); + assert.equal(content[0].type, "input_text"); + assert.match(content[0].text as string, /\[Agent message context\]/); + assert.match(content[0].text as string, /"author":"worker"/); + assert.match(content[0].text as string, /"recipient":"main"/); + assert.deepEqual(content[1], { type: "input_text", text: "Synthetic worker result: 2+2=4." }); +}); + +test("#13698: converts a role=agent_message item (no explicit type) the same way", () => { + const input = [ + { role: "agent_message", author: "a", recipient: "b", content: "plain string body" }, + ]; + + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + + assert.equal(item.type, "message"); + assert.equal(item.role, "user"); + const content = item.content as Array>; + assert.equal(content.length, 2); + assert.deepEqual(content[1], { type: "input_text", text: "plain string body" }); +}); + +test("#13698: string content is flattened into one input_text part", () => { + const input = [{ type: "agent_message", content: "hello sub-agent" }]; + + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + const content = item.content as Array>; + assert.equal(content.length, 2); + assert.deepEqual(content[1], { type: "input_text", text: "hello sub-agent" }); +}); + +test("#13698: encrypted_content parts become a placeholder, not leaked plaintext or a dropped item", () => { + const input = [ + { + type: "agent_message", + content: [{ type: "encrypted_content", data: "opaque-ciphertext" }], + }, + ]; + + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + const content = item.content as Array>; + assert.equal(content.length, 2); + assert.equal(content[1].type, "input_text"); + assert.doesNotMatch(content[1].text as string, /opaque-ciphertext/); +}); + +test("#13698: image_url / input_image parts are preserved via the existing content-part sanitizer", () => { + const input = [ + { + type: "agent_message", + content: [{ type: "image_url", image_url: { url: "https://example.com/x.png" } }], + }, + ]; + + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + const content = item.content as Array>; + assert.equal(content.length, 2); + assert.equal(content[1].type, "input_image"); + assert.equal(content[1].image_url, "https://example.com/x.png"); +}); + +test("#13698: non-array/non-string content falls back to a JSON-stringified input_text part", () => { + const input = [{ type: "agent_message", content: { weird: true } }]; + + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + const content = item.content as Array>; + assert.equal(content.length, 2); + assert.equal(content[1].type, "input_text"); + assert.equal(content[1].text, JSON.stringify({ weird: true })); +}); + +test("#13698: preserveAgentMessages:true leaves the item untouched (native Codex/ChatGPT passthrough)", () => { + const input = [ + { + type: "agent_message", + author: "worker", + recipient: "main", + content: [{ type: "input_text", text: "task payload" }], + }, + ]; + + const [item] = sanitizeResponsesInputItems(input, false, { + preserveAgentMessages: true, + }) as Array>; + + assert.deepEqual(item, input[0]); +}); + +test("#13698: conversion is idempotent -- sanitizing the converted output again is a no-op", () => { + const input = [ + { + type: "agent_message", + author: "worker", + recipient: "main", + content: [{ type: "input_text", text: "task payload" }], + }, + ]; + + const once = sanitizeResponsesInputItems(input, false) as Array>; + const twice = sanitizeResponsesInputItems(once, false) as Array>; + assert.deepEqual(once, twice); +}); + +test("#13698: default sanitizeResponsesInputItems call sites (base/github executors) still convert", () => { + // Simulates the base executor / GitHub executor call sites, which pass no third argument + // and therefore must pick up the new default (convert) -- this is the OpenCode Go / Muse + // Spark 1.3 path from the issue. + const input = [{ type: "agent_message", content: "sub-agent reply" }]; + const [item] = sanitizeResponsesInputItems(input, false) as Array>; + assert.equal(item.type, "message"); +});