feat: add support for image mentions using @ syntax

- Updated parseMentions to detect and process image file mentions
- Added image processing logic with size validation and memory tracking
- Modified processUserContentMentions to handle image data URLs
- Updated Task.ts to pass image-related parameters to mention processing
- Added comprehensive tests for image mention functionality
- Fixed existing tests to work with new parseMentions return type

Closes #6802
This commit is contained in:
Roo Code 2025-08-07 09:41:05 +00:00
parent 37330b0209
commit b2be7f4669
5 changed files with 552 additions and 57 deletions

View file

@ -0,0 +1,355 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { parseMentions } from "../index"
import { UrlContentFetcher } from "../../../services/browser/UrlContentFetcher"
import * as imageHelpers from "../../tools/helpers/imageHelpers"
import * as fs from "fs/promises"
import path from "path"
// Mock the image helpers
vi.mock("../../tools/helpers/imageHelpers", () => ({
isSupportedImageFormat: vi.fn(),
validateImageForProcessing: vi.fn(),
processImageFile: vi.fn(),
ImageMemoryTracker: vi.fn().mockImplementation(() => ({
getTotalMemoryUsed: vi.fn().mockReturnValue(0),
addMemoryUsage: vi.fn(),
reset: vi.fn(),
})),
DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5,
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20,
}))
// Mock fs
vi.mock("fs/promises", () => ({
default: {
stat: vi.fn(),
readFile: vi.fn(),
},
stat: vi.fn(),
readFile: vi.fn(),
}))
describe("Image Mentions", () => {
let mockUrlContentFetcher: UrlContentFetcher
beforeEach(() => {
vi.clearAllMocks()
mockUrlContentFetcher = {
launchBrowser: vi.fn(),
closeBrowser: vi.fn(),
urlToMarkdown: vi.fn(),
} as any
})
describe("parseMentions with image files", () => {
it("should process image mentions and return image data URLs", async () => {
const mockImageDataUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
// Mock image format check
vi.mocked(imageHelpers.isSupportedImageFormat).mockReturnValue(true)
// Mock image validation
vi.mocked(imageHelpers.validateImageForProcessing).mockResolvedValue({
isValid: true,
sizeInMB: 0.5,
})
// Mock image processing
vi.mocked(imageHelpers.processImageFile).mockResolvedValue({
dataUrl: mockImageDataUrl,
buffer: Buffer.from("test"),
sizeInKB: 500,
sizeInMB: 0.5,
notice: "Image (500 KB)",
})
// Mock file stats
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 512000,
} as any)
const result = await parseMentions(
"Check @/test/image.png for details",
"/workspace",
mockUrlContentFetcher,
undefined,
undefined,
true,
true,
50,
undefined,
true, // supportsImages
5, // maxImageFileSize
20, // maxTotalImageSize
)
expect(result.text).toContain("'test/image.png' (see below for image)")
expect(result.text).toContain('<image_content path="test/image.png">')
expect(result.text).toContain("Image (500 KB)")
expect(result.images).toHaveLength(1)
expect(result.images[0]).toBe(mockImageDataUrl)
})
it("should handle multiple image mentions", async () => {
const mockImageDataUrl1 = "data:image/png;base64,image1"
const mockImageDataUrl2 = "data:image/jpeg;base64,image2"
vi.mocked(imageHelpers.isSupportedImageFormat).mockReturnValue(true)
vi.mocked(imageHelpers.validateImageForProcessing).mockResolvedValue({
isValid: true,
sizeInMB: 0.5,
})
vi.mocked(imageHelpers.processImageFile)
.mockResolvedValueOnce({
dataUrl: mockImageDataUrl1,
buffer: Buffer.from("test1"),
sizeInKB: 500,
sizeInMB: 0.5,
notice: "Image (500 KB)",
})
.mockResolvedValueOnce({
dataUrl: mockImageDataUrl2,
buffer: Buffer.from("test2"),
sizeInKB: 300,
sizeInMB: 0.3,
notice: "Image (300 KB)",
})
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 512000,
} as any)
const result = await parseMentions(
"Compare @/image1.png with @/image2.jpg",
"/workspace",
mockUrlContentFetcher,
undefined,
undefined,
true,
true,
50,
undefined,
true,
5,
20,
)
expect(result.images).toHaveLength(2)
expect(result.images[0]).toBe(mockImageDataUrl1)
expect(result.images[1]).toBe(mockImageDataUrl2)
expect(result.text).toContain("'image1.png' (see below for image)")
expect(result.text).toContain("'image2.jpg' (see below for image)")
})
it("should handle image size limit exceeded", async () => {
vi.mocked(imageHelpers.isSupportedImageFormat).mockReturnValue(true)
vi.mocked(imageHelpers.validateImageForProcessing).mockResolvedValue({
isValid: false,
reason: "size_limit",
notice: "Image file is too large (10 MB). Maximum allowed size is 5 MB.",
sizeInMB: 10,
})
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 10485760, // 10 MB
} as any)
const result = await parseMentions(
"Check @/large-image.png",
"/workspace",
mockUrlContentFetcher,
undefined,
undefined,
true,
true,
50,
undefined,
true,
5,
20,
)
expect(result.images).toHaveLength(0)
expect(result.text).toContain("Image file is too large (10 MB). Maximum allowed size is 5 MB.")
})
it("should handle model that doesn't support images", async () => {
vi.mocked(imageHelpers.isSupportedImageFormat).mockReturnValue(true)
vi.mocked(imageHelpers.validateImageForProcessing).mockResolvedValue({
isValid: false,
reason: "unsupported_model",
notice: "Image file detected but current model does not support images. Skipping image processing.",
})
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 512000,
} as any)
const result = await parseMentions(
"Check @/image.png",
"/workspace",
mockUrlContentFetcher,
undefined,
undefined,
true,
true,
50,
undefined,
false, // supportsImages = false
5,
20,
)
expect(result.images).toHaveLength(0)
expect(result.text).toContain("Image file detected but current model does not support images")
})
it("should handle mixed content with images and regular files", async () => {
const mockImageDataUrl = "data:image/png;base64,testimage"
// Mock for image file
vi.mocked(imageHelpers.isSupportedImageFormat).mockImplementation((ext) => ext === ".png")
vi.mocked(imageHelpers.validateImageForProcessing).mockResolvedValue({
isValid: true,
sizeInMB: 0.5,
})
vi.mocked(imageHelpers.processImageFile).mockResolvedValue({
dataUrl: mockImageDataUrl,
buffer: Buffer.from("test"),
sizeInKB: 500,
sizeInMB: 0.5,
notice: "Image (500 KB)",
})
// Mock file stats - need to handle both image and script file
let statCallCount = 0
vi.mocked(fs.stat).mockImplementation(async (path) => {
statCallCount++
// First call is for image.png, second is for script.js
return {
isFile: () => true,
isDirectory: () => false,
size: statCallCount === 1 ? 512000 : 100,
} as any
})
// Mock file read for text file
vi.mocked(fs.readFile).mockResolvedValue("console.log('test');")
const result = await parseMentions(
"Check @/image.png and @/script.js",
"/workspace",
mockUrlContentFetcher,
undefined,
undefined,
true,
true,
50,
undefined,
true,
5,
20,
)
expect(result.images).toHaveLength(1)
expect(result.images[0]).toBe(mockImageDataUrl)
expect(result.text).toContain("'image.png' (see below for image)")
// The script.js file will have an error because we're not fully mocking the file system
// but that's okay for this test - we're mainly testing that images and non-images are handled differently
expect(result.text).toContain("script.js")
})
it("should respect .rooignore for image files", async () => {
vi.mocked(imageHelpers.isSupportedImageFormat).mockReturnValue(true)
const mockRooIgnoreController = {
validateAccess: vi.fn().mockReturnValue(false),
}
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 512000,
} as any)
const result = await parseMentions(
"Check @/ignored-image.png",
"/workspace",
mockUrlContentFetcher,
undefined,
mockRooIgnoreController as any,
true,
true,
50,
undefined,
true,
5,
20,
)
expect(result.images).toHaveLength(0)
expect(result.text).toContain("(Image ignored-image.png is ignored by .rooignore)")
})
it("should handle total memory limit for multiple images", async () => {
vi.mocked(imageHelpers.isSupportedImageFormat).mockReturnValue(true)
// First image validates successfully
vi.mocked(imageHelpers.validateImageForProcessing)
.mockResolvedValueOnce({
isValid: true,
sizeInMB: 15,
})
.mockResolvedValueOnce({
isValid: false,
reason: "memory_limit",
notice: "Image skipped to avoid size limit (20MB). Current: 15MB + this file: 8MB. Try fewer or smaller images.",
sizeInMB: 8,
})
vi.mocked(imageHelpers.processImageFile).mockResolvedValue({
dataUrl: "data:image/png;base64,firstimage",
buffer: Buffer.from("test"),
sizeInKB: 15360,
sizeInMB: 15,
notice: "Image (15360 KB)",
})
vi.mocked(fs.stat).mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 15728640, // 15 MB
} as any)
const result = await parseMentions(
"Check @/large1.png and @/large2.png",
"/workspace",
mockUrlContentFetcher,
undefined,
undefined,
true,
true,
50,
undefined,
true,
25, // maxImageFileSize
20, // maxTotalImageSize
)
expect(result.images).toHaveLength(1)
expect(result.text).toContain("Image skipped to avoid size limit")
})
})
})

View file

@ -23,8 +23,11 @@ describe("processUserContentMentions", () => {
mockFileContextTracker = {} as FileContextTracker
mockRooIgnoreController = {}
// Default mock implementation
vi.mocked(parseMentions).mockImplementation(async (text) => `parsed: ${text}`)
// Default mock implementation - parseMentions now returns an object with text and images
vi.mocked(parseMentions).mockImplementation(async (text) => ({
text: `parsed: ${text}`,
images: [],
}))
})
describe("maxReadFileLine parameter", () => {
@ -55,6 +58,9 @@ describe("processUserContentMentions", () => {
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
100,
false, // supportsImages
5, // maxImageFileSize
20, // maxTotalImageSize
)
})
@ -84,6 +90,9 @@ describe("processUserContentMentions", () => {
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
undefined,
false, // supportsImages
5, // maxImageFileSize
20, // maxTotalImageSize
)
})
@ -114,6 +123,9 @@ describe("processUserContentMentions", () => {
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
-1,
false, // supportsImages
5, // maxImageFileSize
20, // maxTotalImageSize
)
})
})
@ -318,6 +330,9 @@ describe("processUserContentMentions", () => {
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
undefined,
false, // supportsImages
5, // maxImageFileSize
20, // maxTotalImageSize
)
})
@ -347,6 +362,9 @@ describe("processUserContentMentions", () => {
true, // includeDiagnosticMessages
50, // maxDiagnosticMessages
undefined,
false, // supportsImages
5, // maxImageFileSize
20, // maxTotalImageSize
)
})
})

View file

@ -22,6 +22,15 @@ import { getCommand, type Command } from "../../services/command/commands"
import { t } from "../../i18n"
import {
isSupportedImageFormat,
validateImageForProcessing,
processImageFile,
ImageMemoryTracker,
DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
} from "../tools/helpers/imageHelpers"
function getUrlErrorMessage(error: unknown): string {
const errorMessage = error instanceof Error ? error.message : String(error)
@ -87,9 +96,14 @@ export async function parseMentions(
includeDiagnosticMessages: boolean = true,
maxDiagnosticMessages: number = 50,
maxReadFileLine?: number,
): Promise<string> {
supportsImages: boolean = false,
maxImageFileSize: number = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
maxTotalImageSize: number = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
): Promise<{ text: string; images: string[] }> {
const mentions: Set<string> = new Set()
const validCommands: Map<string, Command> = new Map()
const imageDataUrls: string[] = []
const imageMemoryTracker = new ImageMemoryTracker()
// First pass: check which command mentions exist and cache the results
const commandMatches = Array.from(text.matchAll(commandRegexGlobal))
@ -129,6 +143,11 @@ export async function parseMentions(
return `'${mention}' (see below for site content)`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1)
// Check if it's an image file
const fileExtension = path.extname(mentionPath).toLowerCase()
if (isSupportedImageFormat(fileExtension)) {
return `'${mentionPath}' (see below for image)`
}
return mentionPath.endsWith("/")
? `'${mentionPath}' (see below for folder content)`
: `'${mentionPath}' (see below for file content)`
@ -187,27 +206,72 @@ export async function parseMentions(
parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1)
try {
const content = await getFileOrFolderContent(
mentionPath,
cwd,
rooIgnoreController,
showRooIgnoredFiles,
maxReadFileLine,
)
if (mention.endsWith("/")) {
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
if (fileContextTracker) {
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
const fileExtension = path.extname(mentionPath).toLowerCase()
// Check if it's an image file
if (isSupportedImageFormat(fileExtension)) {
try {
const unescapedPath = unescapeSpaces(mentionPath)
const absPath = path.resolve(cwd, unescapedPath)
// Validate access
if (rooIgnoreController && !rooIgnoreController.validateAccess(absPath)) {
parsedText += `\n\n<image_content path="${mentionPath}">\n(Image ${mentionPath} is ignored by .rooignore)\n</image_content>`
} else {
// Validate image for processing
const validationResult = await validateImageForProcessing(
absPath,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
imageMemoryTracker.getTotalMemoryUsed(),
)
if (!validationResult.isValid) {
parsedText += `\n\n<image_content path="${mentionPath}">\n${validationResult.notice}\n</image_content>`
} else {
// Process the image
const imageResult = await processImageFile(absPath)
imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB)
// Add image data URL to the array
imageDataUrls.push(imageResult.dataUrl)
// Add reference in text
parsedText += `\n\n<image_content path="${mentionPath}">\n${imageResult.notice}\n</image_content>`
if (fileContextTracker) {
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
}
}
}
} catch (error) {
parsedText += `\n\n<image_content path="${mentionPath}">\nError fetching image: ${error.message}\n</image_content>`
}
} catch (error) {
if (mention.endsWith("/")) {
parsedText += `\n\n<folder_content path="${mentionPath}">\nError fetching content: ${error.message}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\nError fetching content: ${error.message}\n</file_content>`
} else {
// Handle regular files and folders
try {
const content = await getFileOrFolderContent(
mentionPath,
cwd,
rooIgnoreController,
showRooIgnoredFiles,
maxReadFileLine,
)
if (mention.endsWith("/")) {
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
if (fileContextTracker) {
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
}
}
} catch (error) {
if (mention.endsWith("/")) {
parsedText += `\n\n<folder_content path="${mentionPath}">\nError fetching content: ${error.message}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\nError fetching content: ${error.message}\n</file_content>`
}
}
}
} else if (mention === "problems") {
@ -263,7 +327,7 @@ export async function parseMentions(
}
}
return parsedText
return { text: parsedText, images: imageDataUrls }
}
async function getFileOrFolderContent(

View file

@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { parseMentions } from "./index"
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB } from "../tools/helpers/imageHelpers"
/**
* Process mentions in user content, specifically within task and feedback tags
@ -16,6 +17,9 @@ export async function processUserContentMentions({
includeDiagnosticMessages = true,
maxDiagnosticMessages = 50,
maxReadFileLine,
supportsImages = false,
maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB,
maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB,
}: {
userContent: Anthropic.Messages.ContentBlockParam[]
cwd: string
@ -26,6 +30,9 @@ export async function processUserContentMentions({
includeDiagnosticMessages?: boolean
maxDiagnosticMessages?: number
maxReadFileLine?: number
supportsImages?: boolean
maxImageFileSize?: number
maxTotalImageSize?: number
}) {
// Process userContent array, which contains various block types:
// TextBlockParam, ImageBlockParam, ToolUseBlockParam, and ToolResultBlockParam.
@ -47,10 +54,55 @@ export async function processUserContentMentions({
if (block.type === "text") {
if (shouldProcessMentions(block.text)) {
return {
...block,
text: await parseMentions(
block.text,
const result = await parseMentions(
block.text,
cwd,
urlContentFetcher,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
)
// If there are images, we need to add them as separate image blocks
const blocks: Anthropic.Messages.ContentBlockParam[] = [
{
...block,
text: result.text,
},
]
// Add image blocks for each image found
for (const imageDataUrl of result.images) {
blocks.push({
type: "image",
source: {
type: "base64",
media_type: imageDataUrl.substring(5, imageDataUrl.indexOf(";")) as
| "image/jpeg"
| "image/png"
| "image/gif"
| "image/webp",
data: imageDataUrl.substring(imageDataUrl.indexOf(",") + 1),
},
})
}
// Return array if we have images, otherwise single block
return result.images.length > 0 ? blocks : blocks[0]
}
return block
} else if (block.type === "tool_result") {
if (typeof block.content === "string") {
if (shouldProcessMentions(block.content)) {
const result = await parseMentions(
block.content,
cwd,
urlContentFetcher,
fileContextTracker,
@ -59,27 +111,15 @@ export async function processUserContentMentions({
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
),
}
}
supportsImages,
maxImageFileSize,
maxTotalImageSize,
)
return block
} else if (block.type === "tool_result") {
if (typeof block.content === "string") {
if (shouldProcessMentions(block.content)) {
// For tool_result, we can only return text content, not images
return {
...block,
content: await parseMentions(
block.content,
cwd,
urlContentFetcher,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
),
content: result.text,
}
}
@ -88,19 +128,25 @@ export async function processUserContentMentions({
const parsedContent = await Promise.all(
block.content.map(async (contentBlock) => {
if (contentBlock.type === "text" && shouldProcessMentions(contentBlock.text)) {
const result = await parseMentions(
contentBlock.text,
cwd,
urlContentFetcher,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
)
// For tool_result content blocks, we can only return text
return {
...contentBlock,
text: await parseMentions(
contentBlock.text,
cwd,
urlContentFetcher,
fileContextTracker,
rooIgnoreController,
showRooIgnoredFiles,
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
),
text: result.text,
}
}
@ -116,5 +162,8 @@ export async function processUserContentMentions({
return block
}),
)
).then((results) => {
// Flatten any arrays that were returned (when images were added)
return results.flat()
})
}

View file

@ -1429,8 +1429,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
includeDiagnosticMessages = true,
maxDiagnosticMessages = 50,
maxReadFileLine = -1,
maxImageFileSize = 5,
maxTotalImageSize = 20,
} = (await this.providerRef.deref()?.getState()) ?? {}
// Check if the model supports images
const modelInfo = this.api.getModel().info
const supportsImages = modelInfo.supportsImages ?? false
const parsedUserContent = await processUserContentMentions({
userContent,
cwd: this.cwd,
@ -1441,6 +1447,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
includeDiagnosticMessages,
maxDiagnosticMessages,
maxReadFileLine,
supportsImages,
maxImageFileSize,
maxTotalImageSize,
})
const environmentDetails = await getEnvironmentDetails(this, includeFileDetails)