Skip to content

Commit 41c91d0

Browse files
committed
Fix image sending: use pre-processed data instead of re-reading from disk
Images attached via clipboard or file path were being re-processed from disk when sending messages, causing failures when paths resolved differently across repos. Now uses the already-processed base64 data stored in pendingImages, only calling processImageFile for inline image paths detected in message content.
1 parent 56595d6 commit 41c91d0

File tree

2 files changed

+200
-18
lines changed

2 files changed

+200
-18
lines changed

cli/src/utils/__tests__/image-processor.test.ts

Lines changed: 143 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,25 @@ import { processImagesForMessage } from '../image-processor'
44

55
import type { PendingImageAttachment } from '../../state/chat-store'
66

7-
const createPendingImage = (path: string): PendingImageAttachment => ({
7+
const createPendingImage = (path: string, processedImage?: { base64: string; mediaType: string }): PendingImageAttachment => ({
88
kind: 'image',
99
path,
1010
filename: path.split('/').pop() ?? 'image.png',
1111
status: 'ready',
12+
...(processedImage && { processedImage }),
1213
})
1314

1415
describe('processImagesForMessage', () => {
15-
test('deduplicates image paths and returns message content', async () => {
16-
const pendingImages = [createPendingImage('/tmp/pic.png')]
16+
test('uses pre-processed image data from pendingImages without re-reading from disk', async () => {
17+
const pendingImages = [createPendingImage('/tmp/pic.png', {
18+
base64: 'pre-processed-base64-data',
19+
mediaType: 'image/png',
20+
})]
1721
const processor = mock(async () => ({
1822
success: true,
1923
imagePart: {
2024
type: 'image' as const,
21-
image: 'base64-data',
25+
image: 'disk-base64-data',
2226
mediaType: 'image/png',
2327
},
2428
}))
@@ -30,24 +34,123 @@ describe('processImagesForMessage', () => {
3034
processor: processor as any,
3135
})
3236

33-
expect(processor).toHaveBeenCalledTimes(1)
37+
// Should NOT call processor since we have pre-processed data
38+
expect(processor).not.toHaveBeenCalled()
3439
expect(result.attachments).toHaveLength(1)
3540
expect(result.messageContent?.[0]).toMatchObject({
3641
type: 'image',
37-
image: 'base64-data',
42+
image: 'pre-processed-base64-data',
43+
mediaType: 'image/png',
44+
})
45+
})
46+
47+
test('processes inline image paths that are not in pendingImages', async () => {
48+
const pendingImages = [createPendingImage('/tmp/pic.png', {
49+
base64: 'pre-processed-base64-data',
50+
mediaType: 'image/png',
51+
})]
52+
const processor = mock(async () => ({
53+
success: true,
54+
imagePart: {
55+
type: 'image' as const,
56+
image: 'inline-base64-data',
57+
mediaType: 'image/jpeg',
58+
},
59+
}))
60+
61+
const result = await processImagesForMessage({
62+
content: 'Here is another image @/tmp/other.jpg',
63+
pendingImages,
64+
projectRoot: '/repo',
65+
processor: processor as any,
66+
})
67+
68+
// Should call processor only for the inline path
69+
expect(processor).toHaveBeenCalledTimes(1)
70+
expect(processor).toHaveBeenCalledWith('/tmp/other.jpg', '/repo')
71+
expect(result.messageContent).toHaveLength(2)
72+
expect(result.messageContent?.[0]).toMatchObject({
73+
type: 'image',
74+
image: 'pre-processed-base64-data',
75+
})
76+
expect(result.messageContent?.[1]).toMatchObject({
77+
type: 'image',
78+
image: 'inline-base64-data',
79+
})
80+
})
81+
82+
test('backwards compatibility: processes from disk when processedImage is missing', async () => {
83+
// This tests the edge case where processedImage is missing but status is 'ready'
84+
const pendingImages = [createPendingImage('/tmp/pic.png')] // No processedImage
85+
const warn = mock(() => {})
86+
const processor = mock(async () => ({
87+
success: true,
88+
imagePart: {
89+
type: 'image' as const,
90+
image: 'disk-base64-data',
91+
mediaType: 'image/png',
92+
},
93+
}))
94+
95+
const result = await processImagesForMessage({
96+
content: '',
97+
pendingImages,
98+
projectRoot: '/repo',
99+
processor: processor as any,
100+
log: { warn } as any,
101+
})
102+
103+
// Should warn about missing processedImage and fall back to disk
104+
expect(warn).toHaveBeenCalled()
105+
expect(processor).toHaveBeenCalledTimes(1)
106+
expect(result.messageContent?.[0]).toMatchObject({
107+
type: 'image',
108+
image: 'disk-base64-data',
38109
})
39110
})
40111

41-
test('logs warnings when processing fails', async () => {
112+
test('skips images with processing or error status', async () => {
113+
const pendingImages: PendingImageAttachment[] = [
114+
{ kind: 'image', path: '/tmp/processing.png', filename: 'processing.png', status: 'processing' },
115+
{ kind: 'image', path: '/tmp/error.png', filename: 'error.png', status: 'error', note: 'failed' },
116+
createPendingImage('/tmp/ready.png', { base64: 'ready-data', mediaType: 'image/png' }),
117+
]
118+
const processor = mock(async () => ({
119+
success: true,
120+
imagePart: {
121+
type: 'image' as const,
122+
image: 'should-not-be-used',
123+
mediaType: 'image/png',
124+
},
125+
}))
126+
127+
const result = await processImagesForMessage({
128+
content: '',
129+
pendingImages,
130+
projectRoot: '/repo',
131+
processor: processor as any,
132+
})
133+
134+
// Should not call processor at all (ready image has processedImage)
135+
expect(processor).not.toHaveBeenCalled()
136+
// Only the ready image should be in messageContent
137+
expect(result.messageContent).toHaveLength(1)
138+
expect(result.messageContent?.[0]).toMatchObject({
139+
type: 'image',
140+
image: 'ready-data',
141+
})
142+
})
143+
144+
test('logs warnings when inline path processing fails', async () => {
42145
const warn = mock(() => {})
43-
const pendingImages = [createPendingImage('/tmp/fail.png')]
146+
const pendingImages: PendingImageAttachment[] = []
44147
const processor = mock(async () => ({
45148
success: false,
46149
error: 'boom',
47150
}))
48151

49152
const result = await processImagesForMessage({
50-
content: '',
153+
content: 'Here is an image @/tmp/fail.png',
51154
pendingImages,
52155
projectRoot: '/repo',
53156
processor: processor as any,
@@ -57,4 +160,35 @@ describe('processImagesForMessage', () => {
57160
expect(warn).toHaveBeenCalled()
58161
expect(result.messageContent).toBeUndefined()
59162
})
163+
164+
test('deduplicates: does not process inline path that matches pending image path', async () => {
165+
const pendingImages = [createPendingImage('/tmp/pic.png', {
166+
base64: 'pre-processed-data',
167+
mediaType: 'image/png',
168+
})]
169+
const processor = mock(async () => ({
170+
success: true,
171+
imagePart: {
172+
type: 'image' as const,
173+
image: 'disk-data',
174+
mediaType: 'image/png',
175+
},
176+
}))
177+
178+
const result = await processImagesForMessage({
179+
content: 'Here is the same image @/tmp/pic.png and again /tmp/pic.png',
180+
pendingImages,
181+
projectRoot: '/repo',
182+
processor: processor as any,
183+
})
184+
185+
// Should not call processor since the path is already in pendingImages
186+
expect(processor).not.toHaveBeenCalled()
187+
// Should only have one image in messageContent (no duplicates)
188+
expect(result.messageContent).toHaveLength(1)
189+
expect(result.messageContent?.[0]).toMatchObject({
190+
type: 'image',
191+
image: 'pre-processed-data',
192+
})
193+
})
60194
})

cli/src/utils/image-processor.ts

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,69 @@ export const processImagesForMessage = async (params: {
3434
log = logger,
3535
} = params
3636

37-
const detectedImagePaths = extractImagePaths(content)
38-
const allImagePaths = [
39-
...pendingImages.map((img) => img.path),
40-
...detectedImagePaths,
41-
]
42-
const uniqueImagePaths = [...new Set(allImagePaths)]
43-
4437
const attachments = pendingImages.map((img) => ({
4538
path: img.path,
4639
filename: img.filename,
4740
size: img.size,
4841
}))
4942

5043
const validImageParts: ProcessedImagePart[] = []
51-
for (const imagePath of uniqueImagePaths) {
44+
45+
// First, use pre-processed data from pendingImages (already processed when attached)
46+
// This avoids re-reading from disk, which can fail if the path is relative to a different cwd
47+
const pendingImagePaths = new Set<string>()
48+
for (const img of pendingImages) {
49+
pendingImagePaths.add(img.path)
50+
51+
if (img.processedImage) {
52+
// Use the already-processed image data
53+
validImageParts.push({
54+
type: 'image',
55+
image: img.processedImage.base64,
56+
mediaType: img.processedImage.mediaType,
57+
filename: img.filename,
58+
size: img.size,
59+
width: img.width,
60+
height: img.height,
61+
path: img.path,
62+
})
63+
} else if (img.status === 'ready') {
64+
// Backwards compatibility: if processedImage is missing but status is ready,
65+
// try to process from disk (shouldn't happen in normal flow)
66+
log.warn(
67+
{ imagePath: img.path },
68+
'Pending image marked ready but missing processedImage data, re-processing from disk',
69+
)
70+
const result = await processor(img.path, projectRoot)
71+
if (result.success && result.imagePart) {
72+
validImageParts.push({
73+
type: 'image',
74+
image: result.imagePart.image,
75+
mediaType: result.imagePart.mediaType,
76+
filename: result.imagePart.filename,
77+
size: result.imagePart.size,
78+
width: result.imagePart.width,
79+
height: result.imagePart.height,
80+
path: img.path,
81+
})
82+
} else if (!result.success) {
83+
log.warn(
84+
{ imagePath: img.path, error: result.error },
85+
'Failed to process pending image from disk',
86+
)
87+
}
88+
}
89+
// Skip images with status 'processing' or 'error' - they shouldn't be sent
90+
}
91+
92+
// Then, process any inline image paths from the message content that aren't already in pendingImages
93+
const detectedImagePaths = extractImagePaths(content)
94+
for (const imagePath of detectedImagePaths) {
95+
// Skip if this path is already handled by pendingImages
96+
if (pendingImagePaths.has(imagePath)) {
97+
continue
98+
}
99+
52100
const result = await processor(imagePath, projectRoot)
53101
if (result.success && result.imagePart) {
54102
validImageParts.push({
@@ -64,7 +112,7 @@ export const processImagesForMessage = async (params: {
64112
} else if (!result.success) {
65113
log.warn(
66114
{ imagePath, error: result.error },
67-
'Failed to process image for SDK',
115+
'Failed to process inline image path for SDK',
68116
)
69117
}
70118
}

0 commit comments

Comments
 (0)