From 97b07e573a05d6b0f6bbf97bc08bfbb999c0ffc4 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 21 Jan 2026 17:23:52 +0100 Subject: [PATCH] fix(ai): filtered empty user messages across all providers --- packages/ai/CHANGELOG.md | 8 +++ packages/ai/src/providers/amazon-bedrock.ts | 49 +++++++++++++------ packages/ai/src/providers/cursor.ts | 14 ++++-- packages/ai/src/providers/google-shared.ts | 11 ++++- .../src/providers/openai-codex-responses.ts | 11 ++++- packages/ai/src/providers/openai-responses.ts | 11 ++++- 6 files changed, 81 insertions(+), 23 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6ab7dba0b..57b28171c 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Fixed + +- Fixed API validation errors when sending empty user messages (resume with `.`) across all providers: + - Google Cloud Code Assist (google-shared.ts) + - OpenAI Responses API (openai-responses.ts) + - OpenAI Codex Responses API (openai-codex-responses.ts) + - Cursor (cursor.ts) + - Amazon Bedrock (amazon-bedrock.ts) ## [6.9.69] - 2026-01-21 ### Added diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index cd0424407..acffc172d 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -342,22 +342,39 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream switch (m.role) { case "user": - result.push({ - role: ConversationRole.USER, - content: - typeof m.content === "string" - ? [{ text: sanitizeSurrogates(m.content) }] - : m.content.map((c) => { - switch (c.type) { - case "text": - return { text: sanitizeSurrogates(c.text) }; - case "image": - return { image: createImageBlock(c.mimeType, c.data) }; - default: - throw new Error("Unknown user content type"); - } - }), - }); + if (typeof m.content === "string") { + // Skip empty user messages + if (!m.content || m.content.trim() === "") continue; + result.push({ + role: ConversationRole.USER, + content: [{ text: sanitizeSurrogates(m.content) }], + }); + } else { + const contentBlocks = m.content + .map((c) => { + switch (c.type) { + case "text": + return { text: sanitizeSurrogates(c.text) }; + case "image": + return { image: createImageBlock(c.mimeType, c.data) }; + default: + throw new Error("Unknown user content type"); + } + }) + .filter((block) => { + // Filter out empty text blocks + if ("text" in block && block.text) { + return block.text.trim().length > 0; + } + return true; // Keep non-text blocks (images) + }); + // Skip message if all blocks filtered out + if (contentBlocks.length === 0) continue; + result.push({ + role: ConversationRole.USER, + content: contentBlocks, + }); + } break; case "assistant": { // Skip assistant messages with empty content (e.g., from aborted requests) diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 80aba04ee..22a8d63ea 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1838,11 +1838,12 @@ function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] function extractUserMessageText(msg: Message): string { if (msg.role !== "user") return ""; const content = msg.content; - if (typeof content === "string") return content; - return content + if (typeof content === "string") return content.trim(); + const text = content .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) .join("\n"); + return text.trim(); } /** @@ -1891,7 +1892,7 @@ function buildConversationTurns(messages: Message[]): Uint8Array[] { // Create and serialize user message const userText = extractUserMessageText(msg); - if (!userText) { + if (!userText || userText.length === 0) { i++; continue; } @@ -1982,10 +1983,15 @@ function buildGrpcRequest( const userText = lastMessage?.role === "user" ? typeof lastMessage.content === "string" - ? lastMessage.content + ? lastMessage.content.trim() : extractText(lastMessage.content) : ""; + // Validate that we have non-empty user text for the action + if (!userText || userText.trim().length === 0) { + throw new Error("Cannot send empty user message to Cursor API"); + } + const userMessage = create(UserMessageSchema, { text: userText, messageId: crypto.randomUUID(), diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 9dd143b40..7c65378bd 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -79,6 +79,8 @@ export function convertMessages(model: Model, contex for (const msg of transformedMessages) { if (msg.role === "user") { if (typeof msg.content === "string") { + // Skip empty user messages + if (!msg.content || msg.content.trim() === "") continue; contents.push({ role: "user", parts: [{ text: sanitizeSurrogates(msg.content) }], @@ -96,7 +98,14 @@ export function convertMessages(model: Model, contex }; } }); - const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; + // Filter out images if model doesn't support them, and empty text blocks + let filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; + filteredParts = filteredParts.filter((p) => { + if (p.text !== undefined) { + return p.text.trim().length > 0; + } + return true; // Keep non-text parts (images) + }); if (filteredParts.length === 0) continue; contents.push({ role: "user", diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 9c3ac46ec..061ee0dfd 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -571,6 +571,8 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex for (const msg of transformedMessages) { if (msg.role === "user") { if (typeof msg.content === "string") { + // Skip empty user messages + if (!msg.content || msg.content.trim() === "") continue; messages.push({ role: "user", content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }], @@ -589,9 +591,16 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex image_url: `data:${item.mimeType};base64,${item.data}`, } satisfies ResponseInputImage; }); - const filteredContent = !model.input.includes("image") + // Filter out images if model doesn't support them, and empty text blocks + let filteredContent = !model.input.includes("image") ? content.filter((c) => c.type !== "input_image") : content; + filteredContent = filteredContent.filter((c) => { + if (c.type === "input_text") { + return c.text.trim().length > 0; + } + return true; // Keep non-text content (images) + }); if (filteredContent.length === 0) continue; messages.push({ role: "user", diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index e364a31b4..905c4ce93 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -468,6 +468,8 @@ function convertMessages( for (const msg of transformedMessages) { if (msg.role === "user") { if (typeof msg.content === "string") { + // Skip empty user messages + if (!msg.content || msg.content.trim() === "") continue; messages.push({ role: "user", content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }], @@ -487,9 +489,16 @@ function convertMessages( } satisfies ResponseInputImage; } }); - const filteredContent = !model.input.includes("image") + // Filter out images if model doesn't support them, and empty text blocks + let filteredContent = !model.input.includes("image") ? content.filter((c) => c.type !== "input_image") : content; + filteredContent = filteredContent.filter((c) => { + if (c.type === "input_text") { + return c.text.trim().length > 0; + } + return true; // Keep non-text content (images) + }); if (filteredContent.length === 0) continue; messages.push({ role: "user",