diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 5c9f98d738..18e8f41940 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -227,7 +227,7 @@ export function openaiResponsesToOpenAIRequest( const itemType = toString(item.type) || (item.role ? "message" : ""); if (itemType === "message") { - const role = toString(item.role); + const role = toString(item.role) === "agent_message" ? "assistant" : toString(item.role); if (role !== "assistant") { if (currentAssistantMsg) { @@ -484,6 +484,13 @@ export function openaiResponsesToOpenAIRequest( continue; } + // Defense in depth for Responses/subagent fallback: agent_message is + // Responses-only. Normalization should already have rewritten or dropped it; + // never throw a 5xx-looking unsupported-feature error if a shape slips through. + if (itemType === "agent_message" || toString(item.role) === "agent_message") { + continue; + } + throw unsupportedFeature( `Unsupported Responses API feature: input item type '${itemType || "missing"}' cannot be represented in Chat Completions` ); @@ -773,7 +780,10 @@ export function openaiResponsesToOpenAIRequest( // ("When using tool_choice, tools must be set"). Contradictory choices like "required" // or forced functions are preserved so the upstream error remains visible. const finalChatTools = Array.isArray(result.tools) ? result.tools : []; - if (finalChatTools.length === 0 && (result.tool_choice === "auto" || result.tool_choice === "none")) { + if ( + finalChatTools.length === 0 && + (result.tool_choice === "auto" || result.tool_choice === "none") + ) { delete result.tool_choice; } diff --git a/open-sse/utils/responsesInputNormalization.ts b/open-sse/utils/responsesInputNormalization.ts index 490080c3db..d56acb6590 100644 --- a/open-sse/utils/responsesInputNormalization.ts +++ b/open-sse/utils/responsesInputNormalization.ts @@ -1,12 +1,20 @@ type JsonRecord = Record; -function normalizeAgentMessageForChat(item: JsonRecord): JsonRecord | null { - if (item.type !== "agent_message") return null; +function isAgentMessageItem(item: JsonRecord): boolean { + return item.type === "agent_message" || item.role === "agent_message"; +} +function collectAgentMessageText(item: JsonRecord): string | null { + if (typeof item.content === "string") return item.content; + if (typeof item.text === "string") return item.text; if (!Array.isArray(item.content)) return null; const textParts: string[] = []; for (const partValue of item.content) { + if (typeof partValue === "string") { + textParts.push(partValue); + continue; + } if (!partValue || typeof partValue !== "object" || Array.isArray(partValue)) { return null; } @@ -17,12 +25,21 @@ function normalizeAgentMessageForChat(item: JsonRecord): JsonRecord | null { // partial plaintext envelope or forward an opaque payload the model cannot use. return null; } - if (part.type !== "input_text" || typeof part.text !== "string") return null; + if (part.type !== "input_text" && part.type !== "output_text" && part.type !== "text") { + return null; + } + if (typeof part.text !== "string") return null; textParts.push(part.text); } - const text = textParts.join("\n"); - if (!text.trim()) return null; + return textParts.join("\n"); +} + +function normalizeAgentMessageForChat(item: JsonRecord): JsonRecord | null { + if (!isAgentMessageItem(item)) return null; + + const text = collectAgentMessageText(item); + if (typeof text !== "string" || !text.trim()) return null; return { type: "message", @@ -125,7 +142,7 @@ function normalizeResponsesInputItemForChat(value: unknown): unknown { const agentMessage = normalizeAgentMessageForChat(item); if (agentMessage) return agentMessage; - if (item.type === "agent_message") { + if (isAgentMessageItem(item)) { // Encrypted or malformed agent messages have no lossless Chat equivalent. // Treat them like other Responses-only metadata instead of failing the whole turn. return { type: "reasoning" }; diff --git a/tests/unit/responses-chat-translation-gaps.test.ts b/tests/unit/responses-chat-translation-gaps.test.ts index 093594e160..b7892b1478 100644 --- a/tests/unit/responses-chat-translation-gaps.test.ts +++ b/tests/unit/responses-chat-translation-gaps.test.ts @@ -178,6 +178,47 @@ test("Responses -> Chat skips encrypted or mixed agent_message items", () => { ]); }); +test("Responses -> Chat converts string-content agent_message items to assistant history", () => { + const result = translate({ + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Run the task" }] }, + { type: "agent_message", author: "worker", content: "Task completed" }, + ], + }); + + assert.deepEqual(result.messages, [ + { role: "user", content: [{ type: "text", text: "Run the task" }] }, + { role: "assistant", content: [{ type: "text", text: "Task completed" }] }, + ]); +}); + +test("Responses -> Chat converts role-based agent_message items without a type field", () => { + const result = translate({ + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Run the task" }] }, + { role: "agent_message", content: [{ type: "text", text: "Worker reply" }] }, + ], + }); + + assert.deepEqual(result.messages, [ + { role: "user", content: [{ type: "text", text: "Run the task" }] }, + { role: "assistant", content: [{ type: "text", text: "Worker reply" }] }, + ]); +}); + +test("Responses -> Chat does not throw when an agent_message item slips past normalize", () => { + const result = translate({ + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Run the task" }] }, + { type: "agent_message", content: [{ type: "unknown_part" }] }, + ], + }); + + assert.deepEqual(result.messages, [ + { role: "user", content: [{ type: "text", text: "Run the task" }] }, + ]); +}); + test("Responses -> Chat consumes additional_tools input items without emitting messages", () => { const result = translate({ input: [