From 676cd512b08f37b7debc1925caa6cc4e7564f085 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 2 Sep 2026 20:15:00 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AB=99=20fix:=20Drop=20Blank=20Content=20?= =?UTF-8?q?Blocks=20From=20Promptless=20Sends=20(#15530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🫙 fix: Drop Blank Content Blocks From Promptless Sends * fix: Preserve the promptless turn instead of dropping it --- .../prompts/formatAgentMessages.spec.js | 55 +++++++++++++++++++ api/app/clients/prompts/formatMessages.js | 32 ++++++++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/api/app/clients/prompts/formatAgentMessages.spec.js b/api/app/clients/prompts/formatAgentMessages.spec.js index b5a9775974..86ec5c2e03 100644 --- a/api/app/clients/prompts/formatAgentMessages.spec.js +++ b/api/app/clients/prompts/formatAgentMessages.spec.js @@ -622,4 +622,59 @@ describe('formatAgentMessages', () => { expect(result[0].content).toEqual(media); }); }); + + describe('promptless sends', () => { + const PLACEHOLDER = [{ type: 'text', text: '(no text)' }]; + + it('should stand in for a replayed promptless turn instead of a blank block', () => { + const result = formatAgentMessages([ + { role: 'user', content: '' }, + { role: 'assistant', content: 'Hi' }, + ]); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(HumanMessage); + expect(result[0].content).toEqual(PLACEHOLDER); + expect(result[0].content).not.toContainEqual( + expect.objectContaining({ type: 'text', text: '' }), + ); + }); + + it('should keep the turn so assistant messages never become adjacent', () => { + const result = formatAgentMessages([ + { role: 'assistant', content: 'first' }, + { role: 'user', content: '' }, + { role: 'assistant', content: 'second' }, + ]); + + expect(result).toHaveLength(3); + expect(result[1]).toBeInstanceOf(HumanMessage); + }); + + it('should not emit a blank text block for a whitespace-only user message', () => { + const result = formatAgentMessages([{ role: 'user', content: ' ' }]); + + expect(result).toHaveLength(1); + expect(result[0].content).toEqual(PLACEHOLDER); + }); + + it('should keep a promptless user message that still carries its images', () => { + const image_urls = [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAA' } }]; + const result = formatAgentMessages([{ role: 'user', content: '', image_urls }]); + + expect(result).toHaveLength(1); + expect(result[0].content).toEqual(image_urls); + expect(result[0].content).not.toContainEqual( + expect.objectContaining({ type: 'text', text: '' }), + ); + }); + + it('should still format a user message that has text', () => { + const result = formatAgentMessages([{ role: 'user', content: 'hello' }]); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(HumanMessage); + expect(result[0].content).toEqual([{ type: 'text', text: 'hello' }]); + }); + }); }); diff --git a/api/app/clients/prompts/formatMessages.js b/api/app/clients/prompts/formatMessages.js index d21aa0259f..06662e08e7 100644 --- a/api/app/clients/prompts/formatMessages.js +++ b/api/app/clients/prompts/formatMessages.js @@ -7,6 +7,12 @@ const { SystemMessage, } = require('@librechat/agents/langchain/messages'); +/** + * Stands in for a user turn that carries no text and whose attachments are no longer + * being resent, so the turn stays valid without inventing content it never had. + */ +const EMPTY_MESSAGE_PLACEHOLDER = '(no text)'; + /** * Formats a message to OpenAI Vision API payload format. * @@ -163,10 +169,32 @@ const formatAgentMessages = (payload) => { for (const message of payload) { if (typeof message.content === 'string') { - message.content = [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content }]; + /** An empty string yields a blank text block, which strict providers (Bedrock, + * Anthropic) reject outright for the whole request. `formatVisionMessage` + * already guards this for image-bearing sends; history replay of a + * promptless send reaches here with no `image_urls`, so guard it too. */ + message.content = message.content.trim() + ? [{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: message.content }] + : []; } if (message.role !== 'assistant') { - messages.push(formatMessage({ message, langChain: true })); + const formatted = formatMessage({ message, langChain: true }); + /** A promptless send replayed from history can reduce to nothing once its + * attachments are no longer resent. Providers reject a blank text block and + * an empty content array alike, but dropping the turn is not safe either: + * nothing merges the assistant turns it would leave adjacent, and the same + * providers reject consecutive assistant messages. Keep the turn, and give + * it the smallest honest stand-in for the content that is no longer there. */ + const { content: formattedContent } = formatted; + const isEmpty = Array.isArray(formattedContent) + ? formattedContent.length === 0 + : typeof formattedContent === 'string' && formattedContent.trim() === ''; + if (isEmpty) { + formatted.content = [ + { type: ContentTypes.TEXT, [ContentTypes.TEXT]: EMPTY_MESSAGE_PLACEHOLDER }, + ]; + } + messages.push(formatted); continue; }