fix(translator): drop or map agent_message on Chat Completions fallback (#12880)

Validado numa worktree combinada com a onda de streaming desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-api-typecheck OK (289), check-file-size OK após rebaseline, 88/88 nos testes focados.

`agent_message` chegando num fallback de Chat Completions é um item que o cliente não sabe interpretar; mapear ou descartar é a escolha certa, e escolher por item em vez de derrubar a resposta inteira mantém o fallback útil.
This commit is contained in:
Ravi Tharuma
2026-09-10 15:18:58 +02:00
committed by GitHub
parent a1b260146d
commit 3272bedd1b
3 changed files with 76 additions and 8 deletions

View File

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

View File

@@ -1,12 +1,20 @@
type JsonRecord = Record<string, unknown>;
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" };

View File

@@ -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: [