Skip to content

Commit 7eba824

Browse files
committed
fix(cli): include fallback prompt in content array for image-only messages
When users send only images without text, the fallback prompt "See attached image(s)" was being passed as a separate prompt parameter but ignored by buildUserMessageContent. Now the fallback text is included directly in the content array so the model receives the instruction.
1 parent 64d884c commit 7eba824

File tree

3 files changed

+114
-13
lines changed

3 files changed

+114
-13
lines changed

cli/src/hooks/use-send-message.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -468,10 +468,10 @@ export const useSendMessage = ({
468468
// and prepare context for the LLM
469469
const { pendingBashMessages, clearPendingBashMessages } =
470470
useChatStore.getState()
471-
471+
472472
// Format bash context to add to message history for the LLM
473473
const bashContext = formatBashContextForPrompt(pendingBashMessages)
474-
474+
475475
if (pendingBashMessages.length > 0) {
476476
// Convert pending bash messages to chat messages and add to history (UI only)
477477
// Skip messages that were already added to history (non-ghost mode)
@@ -594,17 +594,15 @@ export const useSendMessage = ({
594594
}
595595
}
596596

597-
// Build message content array for SDK
597+
// Build message content array for SDK (images only - text comes from prompt parameter
598+
// which includes bash context and fallback text for image-only messages)
598599
let messageContent: MessageContent[] | undefined
599600
if (validImageParts.length > 0) {
600-
messageContent = [
601-
{ type: 'text' as const, text: content },
602-
...validImageParts.map((img) => ({
603-
type: 'image' as const,
604-
image: img.image,
605-
mediaType: img.mediaType,
606-
})),
607-
]
601+
messageContent = validImageParts.map((img) => ({
602+
type: 'image' as const,
603+
image: img.image,
604+
mediaType: img.mediaType,
605+
}))
608606

609607
logger.info(
610608
{
@@ -1111,10 +1109,12 @@ export const useSendMessage = ({
11111109
const promptWithBashContext = bashContext
11121110
? bashContext + content
11131111
: content
1112+
const hasNonWhitespacePromptWithContext =
1113+
(promptWithBashContext ?? '').trim().length > 0
11141114

11151115
// Use a default prompt when only images are attached (no text content)
11161116
const effectivePrompt =
1117-
promptWithBashContext ||
1117+
(hasNonWhitespacePromptWithContext ? promptWithBashContext : '') ||
11181118
(messageContent ? 'See attached image(s)' : '')
11191119

11201120
runState = await client.run({

packages/agent-runtime/src/util/__tests__/messages.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
messagesWithSystem,
2020
getPreviouslyReadFiles,
2121
filterUnfinishedToolCalls,
22+
buildUserMessageContent,
2223
} from '../../util/messages'
2324
import * as tokenCounter from '../token-counter'
2425

@@ -40,6 +41,91 @@ describe('messagesWithSystem', () => {
4041
})
4142
})
4243

44+
describe('buildUserMessageContent', () => {
45+
it('wraps prompt in user_message tags when no content provided', () => {
46+
const result = buildUserMessageContent('Hello world', undefined, undefined)
47+
48+
expect(result).toHaveLength(1)
49+
expect(result[0].type).toBe('text')
50+
expect((result[0] as any).text).toContain('<user_message>')
51+
expect((result[0] as any).text).toContain('Hello world')
52+
})
53+
54+
it('wraps text content in user_message tags', () => {
55+
const result = buildUserMessageContent(undefined, undefined, [
56+
{ type: 'text', text: 'Hello from content' },
57+
])
58+
59+
expect(result).toHaveLength(1)
60+
expect(result[0].type).toBe('text')
61+
expect((result[0] as any).text).toContain('<user_message>')
62+
expect((result[0] as any).text).toContain('Hello from content')
63+
})
64+
65+
it('uses prompt when content has empty text part', () => {
66+
const result = buildUserMessageContent('See attached image(s)', undefined, [
67+
{ type: 'text', text: '' },
68+
{ type: 'image', image: 'base64data', mediaType: 'image/png' },
69+
])
70+
71+
expect(result).toHaveLength(2)
72+
expect(result[0].type).toBe('text')
73+
expect((result[0] as any).text).toContain('See attached image(s)')
74+
expect(result[1].type).toBe('image')
75+
})
76+
77+
it('uses prompt when content has whitespace-only text part', () => {
78+
const result = buildUserMessageContent('See attached image(s)', undefined, [
79+
{ type: 'text', text: ' ' },
80+
{ type: 'image', image: 'base64data', mediaType: 'image/png' },
81+
])
82+
83+
expect(result).toHaveLength(2)
84+
expect(result[0].type).toBe('text')
85+
expect((result[0] as any).text).toContain('See attached image(s)')
86+
expect(result[1].type).toBe('image')
87+
})
88+
89+
it('uses prompt when content has only images (no text part)', () => {
90+
const result = buildUserMessageContent('See attached image(s)', undefined, [
91+
{ type: 'image', image: 'base64data', mediaType: 'image/png' },
92+
])
93+
94+
expect(result).toHaveLength(2)
95+
expect(result[0].type).toBe('text')
96+
expect((result[0] as any).text).toContain('See attached image(s)')
97+
expect(result[1].type).toBe('image')
98+
})
99+
100+
it('uses content text when it has meaningful content (ignores prompt)', () => {
101+
const result = buildUserMessageContent(
102+
'This prompt should be ignored',
103+
undefined,
104+
[
105+
{ type: 'text', text: 'User provided text' },
106+
{ type: 'image', image: 'base64data', mediaType: 'image/png' },
107+
],
108+
)
109+
110+
expect(result).toHaveLength(2)
111+
expect(result[0].type).toBe('text')
112+
expect((result[0] as any).text).toContain('User provided text')
113+
expect((result[0] as any).text).not.toContain(
114+
'This prompt should be ignored',
115+
)
116+
expect(result[1].type).toBe('image')
117+
})
118+
119+
it('ignores whitespace-only prompt when content has no text', () => {
120+
const result = buildUserMessageContent(' ', undefined, [
121+
{ type: 'image', image: 'base64data', mediaType: 'image/png' },
122+
])
123+
124+
expect(result).toHaveLength(1)
125+
expect(result[0].type).toBe('image')
126+
})
127+
})
128+
43129
// Mock logger for tests
44130
const logger = {
45131
debug: () => {},

packages/agent-runtime/src/util/messages.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,23 @@ export function buildUserMessageContent(
4343
params: Record<string, any> | undefined,
4444
content?: Array<TextPart | ImagePart>,
4545
): Array<TextPart | ImagePart> {
46+
const promptHasNonWhitespaceText = (prompt ?? '').trim().length > 0
47+
4648
// If we have content array (e.g., text + images)
4749
if (content && content.length > 0) {
50+
// Check if content has a non-empty text part
51+
const firstTextPart = content.find((p): p is TextPart => p.type === 'text')
52+
const hasNonEmptyText = firstTextPart && firstTextPart.text.trim()
53+
54+
// If content has no meaningful text but prompt is provided, prepend prompt
55+
if (!hasNonEmptyText && promptHasNonWhitespaceText) {
56+
const nonTextContent = content.filter((p) => p.type !== 'text')
57+
return [
58+
{ type: 'text' as const, text: asUserMessage(prompt!) },
59+
...nonTextContent,
60+
]
61+
}
62+
4863
// Find the first text part and wrap it in <user_message> tags
4964
let hasWrappedText = false
5065
const wrappedContent = content.map((part) => {
@@ -67,7 +82,7 @@ export function buildUserMessageContent(
6782

6883
// Only prompt/params, combine and return as simple text
6984
const textParts = buildArray([
70-
prompt,
85+
promptHasNonWhitespaceText ? prompt : undefined,
7186
params && JSON.stringify(params, null, 2),
7287
])
7388
return [

0 commit comments

Comments
 (0)