Skip to content

Commit fea5e51

Browse files
songwendongcursoragent
authored andcommitted
fix(gateway): pass image-only /v1/responses turns to the agent
The one-line guard alone was insufficient: even after letting image-only input past the `Missing user message` check, the downstream agent command (`prepareAgentCommandExecution`) throws `Message (--message) is required` for an empty message, so image-only `/v1/responses` returned 500. Mirror the /v1/chat/completions prompt builder: substitute the shared IMAGE_ONLY_USER_MESSAGE placeholder for the active image-only user turn so the turn is not dropped and the real image is still attached via `images`. Promote the placeholder constant to the shared gateway agent-prompt module so both endpoints stay in sync, and revert the responses guard back to the original `!prompt.message` check (responses images are not scoped to the active turn, so the placeholder is the correct, single source of truth). Co-authored-by: Cursor <[email protected]>
1 parent da14855 commit fea5e51

6 files changed

Lines changed: 68 additions & 9 deletions

File tree

src/gateway/agent-prompt.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export type ConversationEntry = {
1010
internalStreamError?: boolean;
1111
};
1212

13+
// Placeholder user text for an image-only turn. The agent command requires a
14+
// non-empty message even when the real payload is the attached image, so both
15+
// the /v1/chat/completions and /v1/responses prompt builders substitute this
16+
// for the active user turn. Keep it shared so the two endpoints stay in sync.
17+
export const IMAGE_ONLY_USER_MESSAGE = "User sent image(s) with no text.";
18+
1319
/**
1420
* Coerce body to string. Handles cases where body is a content array
1521
* (e.g. [{type:"text", text:"hello"}]) that would serialize as

src/gateway/openai-http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { resolveAssistantStreamDeltaText } from "./agent-event-assistant-text.js
3939
import {
4040
buildAgentMessageFromConversationEntries,
4141
type ConversationEntry,
42+
IMAGE_ONLY_USER_MESSAGE,
4243
} from "./agent-prompt.js";
4344
import type { AuthRateLimiter } from "./auth-rate-limit.js";
4445
import type { ResolvedGatewayAuth } from "./auth.js";
@@ -105,7 +106,6 @@ type OpenAiChatCompletionRequest = {
105106
};
106107

107108
const DEFAULT_OPENAI_CHAT_COMPLETIONS_BODY_BYTES = 20 * 1024 * 1024;
108-
const IMAGE_ONLY_USER_MESSAGE = "User sent image(s) with no text.";
109109
const DEFAULT_OPENAI_MAX_IMAGE_PARTS = 8;
110110
const DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
111111
const DEFAULT_OPENAI_IMAGE_LIMITS: InputImageLimits = {

src/gateway/openresponses-http.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js";
1010
import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js";
1111
import { resetConfigRuntimeState } from "../config/config.js";
1212
import { emitAgentEvent } from "../infra/agent-events.js";
13+
import { IMAGE_ONLY_USER_MESSAGE } from "./agent-prompt.js";
1314
import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js";
1415
import {
1516
agentCommand,
@@ -1738,7 +1739,9 @@ describe("OpenResponses HTTP API (e2e)", () => {
17381739
expect(res.status).toBe(200);
17391740
expect(agentCommand).toHaveBeenCalledTimes(1);
17401741
const opts = firstAgentOpts();
1741-
expect((opts as { message?: string }).message ?? "").toBe("");
1742+
// Image-only turn carries a non-empty placeholder so the agent command runs,
1743+
// with the real image attached via `images` (parity with /v1/chat/completions).
1744+
expect((opts as { message?: string }).message ?? "").toBe(IMAGE_ONLY_USER_MESSAGE);
17421745
expect((opts as { images?: unknown[] }).images?.length).toBe(1);
17431746
await ensureResponseConsumed(res);
17441747
});

src/gateway/openresponses-http.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -697,9 +697,7 @@ export async function handleOpenResponsesHttpRequest(
697697
.filter(Boolean)
698698
.join("\n\n");
699699

700-
// Image-only input is a valid user turn; require text only when no image accompanies it,
701-
// matching the /v1/chat/completions guard (openai-http.ts).
702-
if (!prompt.message && images.length === 0) {
700+
if (!prompt.message) {
703701
sendJson(res, 400, {
704702
error: {
705703
message: "Missing user message in `input`.",

src/gateway/openresponses-parity.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { beforeAll, describe, it, expect } from "vitest";
9+
import { IMAGE_ONLY_USER_MESSAGE } from "./agent-prompt.js";
910
import { wrapUntrustedFileContent } from "./openresponses-file-content.js";
1011

1112
let InputImageContentPartSchema: typeof import("./open-responses.schema.js").InputImageContentPartSchema;
@@ -374,6 +375,31 @@ describe("OpenResponses Feature Parity", () => {
374375
expect(result.message).toContain("72°F");
375376
expect(result.message).toContain("Thanks");
376377
});
378+
379+
it("substitutes a placeholder for an image-only active user turn", () => {
380+
const result = buildAgentPrompt([
381+
{
382+
type: "message" as const,
383+
role: "user" as const,
384+
content: [
385+
{
386+
type: "input_image" as const,
387+
source: { type: "url" as const, url: "https://example.com/cat.png" },
388+
},
389+
],
390+
},
391+
]);
392+
393+
expect(result.message).toBe(IMAGE_ONLY_USER_MESSAGE);
394+
});
395+
396+
it("keeps an empty message when the active turn has neither text nor image", () => {
397+
const result = buildAgentPrompt([
398+
{ type: "message" as const, role: "user" as const, content: [] },
399+
]);
400+
401+
expect(result.message).toBe("");
402+
});
377403
});
378404

379405
describe("input_file hardening", () => {

src/gateway/openresponses-prompt.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
buildAgentMessageFromConversationEntries,
44
type ConversationEntry,
5+
IMAGE_ONLY_USER_MESSAGE,
56
} from "./agent-prompt.js";
67
import type { ContentPart, ItemParam } from "./open-responses.schema.js";
78

@@ -23,6 +24,21 @@ function extractTextContent(content: string | ContentPart[]): string {
2324
.join("\n");
2425
}
2526

27+
function hasImageContent(content: string | ContentPart[]): boolean {
28+
return typeof content !== "string" && content.some((part) => part.type === "input_image");
29+
}
30+
31+
/** Index of the last user message item, or -1 when there is none. */
32+
function findActiveUserMessageIndex(input: ItemParam[]): number {
33+
for (let i = input.length - 1; i >= 0; i -= 1) {
34+
const item = input[i];
35+
if (item?.type === "message" && item.role === "user") {
36+
return i;
37+
}
38+
}
39+
return -1;
40+
}
41+
2642
/** Build the user message and optional system prompt from Responses API input. */
2743
export function buildAgentPrompt(input: string | ItemParam[]): {
2844
message: string;
@@ -34,16 +50,26 @@ export function buildAgentPrompt(input: string | ItemParam[]): {
3450

3551
const systemParts: string[] = [];
3652
const conversationEntries: ConversationEntry[] = [];
53+
const activeUserMessageIndex = findActiveUserMessageIndex(input);
3754

38-
for (const item of input) {
55+
for (const [i, item] of input.entries()) {
3956
if (item.type === "message") {
4057
const content = extractTextContent(item.content).trim();
41-
if (!content) {
58+
// Substitute a placeholder for an image-only active user turn so the turn
59+
// is not dropped and the downstream agent command (which requires non-empty
60+
// message text) still runs with the attached image, matching /v1/chat/completions.
61+
// Historical image-only turns stay skipped because their bytes are not replayed.
62+
const body =
63+
content ||
64+
(item.role === "user" && i === activeUserMessageIndex && hasImageContent(item.content)
65+
? IMAGE_ONLY_USER_MESSAGE
66+
: "");
67+
if (!body) {
4268
continue;
4369
}
4470

4571
if (item.role === "system" || item.role === "developer") {
46-
systemParts.push(content);
72+
systemParts.push(body);
4773
continue;
4874
}
4975

@@ -52,7 +78,7 @@ export function buildAgentPrompt(input: string | ItemParam[]): {
5278

5379
conversationEntries.push({
5480
role: normalizedRole,
55-
entry: { sender, body: content },
81+
entry: { sender, body },
5682
});
5783
} else if (item.type === "function_call_output") {
5884
conversationEntries.push({

0 commit comments

Comments
 (0)