From 26813952b6b88da0b296fb0f4d4a2ca26d5a4e0e Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Mon, 25 May 2026 19:25:36 +0800 Subject: [PATCH] fix: make OpenAI payload guard content-aware --- ...penai-compatible-conversation-turn.test.ts | 53 +++++++++++++++++++ .../openai-compatible-conversation-turn.ts | 53 +++++++++++++++++++ src/agents/openai-transport-stream.test.ts | 4 +- src/agents/openai-transport-stream.ts | 15 ++---- .../run/attempt.prompt-helpers.test.ts | 13 +++++ .../run/attempt.prompt-helpers.ts | 13 +---- 6 files changed, 127 insertions(+), 24 deletions(-) create mode 100644 src/agents/openai-compatible-conversation-turn.test.ts create mode 100644 src/agents/openai-compatible-conversation-turn.ts diff --git a/src/agents/openai-compatible-conversation-turn.test.ts b/src/agents/openai-compatible-conversation-turn.test.ts new file mode 100644 index 000000000000..76d30320f57d --- /dev/null +++ b/src/agents/openai-compatible-conversation-turn.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js"; + +describe("hasOpenAICompatibleConversationTurn", () => { + it("rejects missing, system-only, and tool-only payloads", () => { + expect(hasOpenAICompatibleConversationTurn(undefined)).toBe(false); + expect(hasOpenAICompatibleConversationTurn([{ role: "system", content: "policy" }])).toBe( + false, + ); + expect( + hasOpenAICompatibleConversationTurn([ + { role: "system", content: "policy" }, + { role: "tool", content: "tool output", tool_call_id: "call_1" }, + ]), + ).toBe(false); + }); + + it("rejects empty user and assistant placeholders", () => { + expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: "" }])).toBe(false); + expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: " " }])).toBe(false); + expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: null }])).toBe(false); + expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: [] }])).toBe(false); + }); + + it("accepts non-empty user and assistant content", () => { + expect(hasOpenAICompatibleConversationTurn([{ role: "user", content: "hello" }])).toBe(true); + expect( + hasOpenAICompatibleConversationTurn([ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]), + ).toBe(true); + expect( + hasOpenAICompatibleConversationTurn([ + { role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png" } }] }, + ]), + ).toBe(true); + expect(hasOpenAICompatibleConversationTurn([{ role: "assistant", content: "answer" }])).toBe( + true, + ); + }); + + it("accepts assistant tool calls even when assistant content is empty", () => { + expect( + hasOpenAICompatibleConversationTurn([ + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "status" } }], + }, + ]), + ).toBe(true); + }); +}); diff --git a/src/agents/openai-compatible-conversation-turn.ts b/src/agents/openai-compatible-conversation-turn.ts new file mode 100644 index 000000000000..965dae621d4c --- /dev/null +++ b/src/agents/openai-compatible-conversation-turn.ts @@ -0,0 +1,53 @@ +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function hasNonEmptyContentPart(part: unknown): boolean { + if (!part || typeof part !== "object") { + return false; + } + const record = part as Record; + if (record.type === "text") { + return hasNonEmptyString(record.text); + } + return true; +} + +function hasNonEmptyMessageContent(content: unknown): boolean { + if (hasNonEmptyString(content)) { + return true; + } + if (!Array.isArray(content)) { + return false; + } + return content.some(hasNonEmptyContentPart); +} + +function hasAssistantToolCall(message: Record): boolean { + const toolCalls = message.tool_calls; + return ( + Array.isArray(toolCalls) && + toolCalls.some((toolCall) => { + return Boolean(toolCall && typeof toolCall === "object"); + }) + ); +} + +export function hasOpenAICompatibleConversationTurn(messages: unknown): boolean { + if (!Array.isArray(messages)) { + return false; + } + return messages.some((message) => { + if (!message || typeof message !== "object") { + return false; + } + const record = message as Record; + if (record.role === "user") { + return hasNonEmptyMessageContent(record.content); + } + if (record.role === "assistant") { + return hasNonEmptyMessageContent(record.content) || hasAssistantToolCall(record); + } + return false; + }); +} diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index f9a500b58f0a..d71733d1b6eb 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1062,7 +1062,9 @@ describe("openai transport stream", () => { } expect(errorPayload).toMatchObject({ stopReason: "error" }); - expect(String(errorPayload?.errorMessage)).toContain("contains no user or assistant messages"); + expect(String(errorPayload?.errorMessage)).toContain( + "contains no non-empty user or assistant messages", + ); expect(String(errorPayload?.errorMessage)).toContain("system/tool-only request"); }); diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index a629c16ebfdf..467c28205f78 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -40,6 +40,7 @@ import { resolveModelSseDebugMode, } from "./model-transport-debug.js"; import { formatModelTransportDebugBaseUrl } from "./model-transport-url.js"; +import { hasOpenAICompatibleConversationTurn } from "./openai-compatible-conversation-turn.js"; import { detectOpenAICompletionsCompat } from "./openai-completions-compat.js"; import { flattenCompletionMessagesToStringContent, @@ -2295,21 +2296,11 @@ function assertOpenAICompletionsPayloadHasConversationTurn( model: Model, ): void { const messages = params.messages; - if (!Array.isArray(messages)) { - return; - } - const hasConversationTurn = messages.some((message) => { - if (!message || typeof message !== "object") { - return false; - } - const role = (message as { role?: unknown }).role; - return role === "user" || role === "assistant"; - }); - if (hasConversationTurn) { + if (!Array.isArray(messages) || hasOpenAICompatibleConversationTurn(messages)) { return; } throw new Error( - `OpenAI-compatible chat payload for ${model.provider}/${model.id} contains no user or assistant messages after compaction and transport transforms; refusing to send a system/tool-only request. Start a new user turn or repair the compacted session history.`, + `OpenAI-compatible chat payload for ${model.provider}/${model.id} contains no non-empty user or assistant messages after compaction and transport transforms; refusing to send a system/tool-only request. Start a new user turn or repair the compacted session history.`, ); } diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts index ed9f0edbb409..f87f1e9371e2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts @@ -143,6 +143,19 @@ describe("resolvePromptSubmissionSkipReason", () => { ).toBe("empty_prompt_history_images"); }); + it("treats empty user and assistant placeholders as empty history", () => { + expect( + resolvePromptSubmissionSkipReason({ + prompt: " ", + messages: [ + { role: "user", content: " " }, + { role: "assistant", content: [] }, + ], + imageCount: 0, + }), + ).toBe("empty_prompt_history_images"); + }); + it("allows text or image prompt submissions", () => { expect( resolvePromptSubmissionSkipReason({ diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts index 37fa90acdb66..9c28ca89fc2a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts @@ -18,6 +18,7 @@ import { listActiveProcessSessionReferences } from "../../bash-process-reference import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { buildActiveImageGenerationTaskPromptContextForSession } from "../../image-generation-task-status.js"; import { buildActiveMusicGenerationTaskPromptContextForSession } from "../../music-generation-task-status.js"; +import { hasOpenAICompatibleConversationTurn } from "../../openai-compatible-conversation-turn.js"; import { resolveProcessToolScopeKey } from "../../pi-tools.js"; import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; @@ -242,16 +243,6 @@ export function shouldWarnOnOrphanedUserRepair( export type PromptSubmissionSkipReason = "blank_user_prompt" | "empty_prompt_history_images"; -function hasProviderVisibleConversationTurn(messages: readonly unknown[]): boolean { - return messages.some((message) => { - if (!message || typeof message !== "object") { - return false; - } - const role = (message as { role?: unknown }).role; - return role === "user" || role === "assistant"; - }); -} - export function resolvePromptSubmissionSkipReason(params: { prompt: string; messages: readonly unknown[]; @@ -261,7 +252,7 @@ export function resolvePromptSubmissionSkipReason(params: { if (params.prompt.trim().length > 0 || params.imageCount > 0) { return null; } - return hasProviderVisibleConversationTurn(params.messages) + return hasOpenAICompatibleConversationTurn(params.messages) ? "blank_user_prompt" : "empty_prompt_history_images"; }