fix(providers): convert agent_message input items for non-Codex-native Responses upstreams (#13698)

This commit is contained in:
diegosouzapw
2026-09-17 19:45:50 -03:00
parent 176d632a2d
commit 556bb3148f
4 changed files with 223 additions and 9 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -3,6 +3,12 @@ import { isValidResponsesItemId } from "./responsesItemId.ts";
type JsonRecord = Record<string, unknown>;
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<string, string> = {
@@ -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<string, unknown>,
nativeCodexPassthrough: boolean
): void {
if (!Array.isArray(body.input)) return;
body.input = sanitizeResponsesInputItems(body.input, false, {
dropInternalAssistantMessages: !nativeCodexPassthrough,
preserveAgentMessages: nativeCodexPassthrough,
});
}

View File

@@ -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<Record<string, unknown>>;
assert.equal(item.type, "message");
assert.equal(item.role, "user");
const content = item.content as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
assert.equal(item.type, "message");
assert.equal(item.role, "user");
const content = item.content as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
const content = item.content as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
const content = item.content as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
const content = item.content as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
const content = item.content as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
const twice = sanitizeResponsesInputItems(once, false) as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
assert.equal(item.type, "message");
});