From 05b2e2764a8bafcd80f47b458edd322721b83209 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Fri, 27 Jun 2025 06:19:10 +0700 Subject: [PATCH 01/21] feat(tools): add image support to read_file tool - Add support for reading and displaying image files (PNG, JPG, JPEG, GIF, WebP, SVG, BMP, ICO, TIFF) - Implement readImageAsDataUrl function to convert images to base64 data URLs with proper MIME types - Return multi-part responses containing both XML metadata and image data - Add dimension extraction for PNG files when possible - Add comprehensive test coverage for image reading functionality including format detection, error handling, and edge cases - Maintain backward compatibility for non-image binary files --- src/core/tools/__tests__/readFileTool.spec.ts | 323 +++++++++++++++++- src/core/tools/readFileTool.ts | 110 +++++- 2 files changed, 425 insertions(+), 8 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 44be1d3b924..e33684b980f 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -20,17 +20,20 @@ vi.mock("path", async () => { } }) -vi.mock("fs/promises", () => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - writeFile: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue("{}"), -})) +// Already mocked above with hoisted fsPromises vi.mock("isbinaryfile") vi.mock("../../../integrations/misc/line-counter") vi.mock("../../../integrations/misc/read-lines") +// Mock fs/promises readFile for image tests +const fsPromises = vi.hoisted(() => ({ + readFile: vi.fn(), + stat: vi.fn().mockResolvedValue({ size: 1024 }), +})) +vi.mock("fs/promises", () => fsPromises) + // Mock input content for tests let mockInputContent = "" @@ -48,6 +51,53 @@ const addLineNumbersMock = vi.fn().mockImplementation((text, startLine = 1) => { const extractTextFromFileMock = vi.fn() const getSupportedBinaryFormatsMock = vi.fn(() => [".pdf", ".docx", ".ipynb"]) +// Mock formatResponse - use vi.hoisted to ensure mocks are available before vi.mock +const { toolResultMock, imageBlocksMock } = vi.hoisted(() => { + const toolResultMock = vi.fn((text: string, images?: string[]) => { + if (images && images.length > 0) { + return [ + { type: "text", text }, + ...images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }), + ] + } + return text + }) + const imageBlocksMock = vi.fn((images?: string[]) => { + return images + ? images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }) + : [] + }) + return { toolResultMock, imageBlocksMock } +}) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolDenied: vi.fn(() => "The user denied this operation."), + toolDeniedWithFeedback: vi.fn( + (feedback?: string) => + `The user denied this operation and provided the following feedback:\n\n${feedback}\n`, + ), + toolApprovedWithFeedback: vi.fn( + (feedback?: string) => + `The user approved this operation and provided the following context:\n\n${feedback}\n`, + ), + rooIgnoreError: vi.fn( + (path: string) => + `Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`, + ), + toolResult: toolResultMock, + imageBlocks: imageBlocksMock, + }, +})) + vi.mock("../../ignore/RooIgnoreController", () => ({ RooIgnoreController: class { initialize() { @@ -520,3 +570,266 @@ describe("read_file tool XML output structure", () => { }) }) }) + +describe("read_file tool with image support", () => { + const testImagePath = "test/image.png" + const absoluteImagePath = "/test/image.png" + const base64ImageData = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + const imageBuffer = Buffer.from(base64ImageData, "base64") + + const mockedCountFileLines = vi.mocked(countFileLines) + const mockedIsBinaryFile = vi.mocked(isBinaryFile) + const mockedPathResolve = vi.mocked(path.resolve) + const mockedFsReadFile = vi.mocked(fsPromises.readFile) + const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) + + const mockCline: any = {} + let mockProvider: any + let toolResult: ToolResponse | undefined + + beforeEach(() => { + vi.clearAllMocks() + + mockedPathResolve.mockReturnValue(absoluteImagePath) + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + mockedFsReadFile.mockResolvedValue(imageBuffer) + + mockProvider = { + getState: vi.fn().mockResolvedValue({ maxReadFileLine: -1 }), + deref: vi.fn().mockReturnThis(), + } + + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + + toolResult = undefined + }) + + async function executeReadImageTool(imagePath: string = testImagePath): Promise { + const argsContent = `${imagePath}` + const toolUse: ReadFileToolUse = { + type: "tool_use", + name: "read_file", + params: { args: argsContent }, + partial: false, + } + + await readFileTool( + mockCline, + toolUse, + mockCline.ask, + vi.fn(), + (result: ToolResponse) => { + toolResult = result + }, + (_: ToolParamName, content?: string) => content ?? "", + ) + + return toolResult + } + + describe("Image Format Detection", () => { + it.each([ + [".png", "image.png", "image/png"], + [".jpg", "photo.jpg", "image/jpeg"], + [".jpeg", "picture.jpeg", "image/jpeg"], + [".gif", "animation.gif", "image/gif"], + [".bmp", "bitmap.bmp", "image/bmp"], + [".svg", "vector.svg", "image/svg+xml"], + [".webp", "modern.webp", "image/webp"], + [".ico", "favicon.ico", "image/x-icon"], + [".avif", "new-format.avif", "image/avif"], + ])("should detect %s as an image format", async (ext, filename, expectedMimeType) => { + // Setup + const imagePath = `test/${filename}` + const absolutePath = `/test/${filename}` + mockedPathResolve.mockReturnValue(absolutePath) + + // Execute + const result = await executeReadImageTool(imagePath) + + // Verify result is a multi-part response + expect(Array.isArray(result)).toBe(true) + const textPart = (result as any[]).find((p) => p.type === "text")?.text + const imagePart = (result as any[]).find((p) => p.type === "image") + + // Verify text part + expect(textPart).toContain(`${imagePath}`) + expect(textPart).not.toContain("") + expect(textPart).toContain(`Image file`) + + // Verify image part + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe(expectedMimeType) + expect(imagePart.source.data).toBe(base64ImageData) + }) + }) + + describe("Image Reading Functionality", () => { + it("should read image file and return a multi-part response", async () => { + // Execute + const result = await executeReadImageTool() + + // Verify result is a multi-part response + expect(Array.isArray(result)).toBe(true) + const textPart = (result as any[]).find((p) => p.type === "text")?.text + const imagePart = (result as any[]).find((p) => p.type === "image") + + // Verify text part + expect(textPart).toContain(`${testImagePath}`) + expect(textPart).not.toContain(``) + expect(textPart).toContain(`Image file`) + + // Verify image part + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe("image/png") + expect(imagePart.source.data).toBe(base64ImageData) + }) + + it("should call formatResponse.toolResult with text and image data", async () => { + // Execute + await executeReadImageTool() + + // Verify toolResultMock was called correctly + expect(toolResultMock).toHaveBeenCalledTimes(1) + const callArgs = toolResultMock.mock.calls[0] + const textArg = callArgs[0] + const imagesArg = callArgs[1] + + expect(textArg).toContain(`${testImagePath}`) + expect(imagesArg).toBeDefined() + expect(imagesArg).toBeInstanceOf(Array) + expect(imagesArg!.length).toBe(1) + expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`) + }) + + it("should handle large image files", async () => { + // Setup - simulate a large image + const largeBase64 = "A".repeat(1000000) // 1MB of base64 data + const largeBuffer = Buffer.from(largeBase64, "base64") + mockedFsReadFile.mockResolvedValue(largeBuffer) + + // Execute + const result = await executeReadImageTool() + + // Verify it still works with large data + expect(Array.isArray(result)).toBe(true) + const imagePart = (result as any[]).find((p) => p.type === "image") + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe("image/png") + expect(imagePart.source.data).toBe(largeBase64) + }) + + it("should handle errors when reading image files", async () => { + // Setup - simulate read error + mockedFsReadFile.mockRejectedValue(new Error("Failed to read image")) + + // Execute + const result = await executeReadImageTool() + + // Verify error handling + expect(result).toContain("Error reading image file: Failed to read image") + expect(mockCline.handleError).toHaveBeenCalled() + }) + }) + + describe("Binary File Handling", () => { + it("should not treat non-image binary files as images", async () => { + // Setup + const binaryPath = "test/document.pdf" + const absolutePath = "/test/document.pdf" + mockedPathResolve.mockReturnValue(absolutePath) + mockedExtractTextFromFile.mockResolvedValue("PDF content extracted") + + // Execute + const result = await executeReadImageTool(binaryPath) + + // Verify it uses extractTextFromFile instead + expect(result).not.toContain("") + expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absolutePath) + }) + + it("should handle unknown binary formats", async () => { + // Setup + const binaryPath = "test/unknown.bin" + const absolutePath = "/test/unknown.bin" + mockedPathResolve.mockReturnValue(absolutePath) + mockedExtractTextFromFile.mockResolvedValue("") + + // Execute + const result = await executeReadImageTool(binaryPath) + + // Verify + expect(result).not.toContain("") + expect(result).toContain(' { + it("should handle case-insensitive image extensions", async () => { + // Test uppercase extensions + const uppercasePath = "test/IMAGE.PNG" + const absolutePath = "/test/IMAGE.PNG" + mockedPathResolve.mockReturnValue(absolutePath) + + // Execute + const result = await executeReadImageTool(uppercasePath) + + // Verify + expect(Array.isArray(result)).toBe(true) + const imagePart = (result as any[]).find((p) => p.type === "image") + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe("image/png") + }) + + it("should handle files with multiple dots in name", async () => { + // Setup + const complexPath = "test/my.photo.backup.png" + const absolutePath = "/test/my.photo.backup.png" + mockedPathResolve.mockReturnValue(absolutePath) + + // Execute + const result = await executeReadImageTool(complexPath) + + // Verify + expect(Array.isArray(result)).toBe(true) + const imagePart = (result as any[]).find((p) => p.type === "image") + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe("image/png") + }) + + it("should handle empty image files", async () => { + // Setup - empty buffer + mockedFsReadFile.mockResolvedValue(Buffer.from("")) + + // Execute + const result = await executeReadImageTool() + + // Verify - should still create valid data URL + expect(Array.isArray(result)).toBe(true) + const imagePart = (result as any[]).find((p) => p.type === "image") + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe("image/png") + expect(imagePart.source.data).toBe("") + }) + }) +}) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 6de8dd56421..5ff34b21e51 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -14,6 +14,49 @@ import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { parseXml } from "../../utils/xml" +import * as fs from "fs/promises" + +/** + * Supported image formats that can be displayed + */ +const SUPPORTED_IMAGE_FORMATS = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".ico", + ".tiff", + ".tif", +] as const + +/** + * Reads an image file and returns it as a base64 data URL + */ +async function readImageAsDataUrl(filePath: string): Promise { + const fileBuffer = await fs.readFile(filePath) + const base64 = fileBuffer.toString("base64") + const ext = path.extname(filePath).toLowerCase() + + // Map extensions to MIME types + const mimeTypes: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".tiff": "image/tiff", + ".tif": "image/tiff", + } + + const mimeType = mimeTypes[ext] || "image/png" + return `data:${mimeType};base64,${base64}` +} export function getReadFileToolDescription(blockName: string, blockParams: any): string { // Handle both single path and multiple files via args @@ -66,6 +109,7 @@ interface FileResult { notice?: string lineRanges?: LineRange[] xmlContent?: string // Final XML content for this file + imageDataUrl?: string // Image data URL for image files feedbackText?: string // User feedback text from approval/denial feedbackImages?: any[] // User feedback images from approval/denial } @@ -440,6 +484,55 @@ export async function readFileTool( const fileExtension = path.extname(relPath).toLowerCase() const supportedBinaryFormats = getSupportedBinaryFormats() + // Check if it's a supported image format + if (SUPPORTED_IMAGE_FORMATS.includes(fileExtension as any)) { + try { + const imageDataUrl = await readImageAsDataUrl(fullPath) + const imageStats = await fs.stat(fullPath) + const imageSizeInKB = Math.round(imageStats.size / 1024) + + // For images, get dimensions if possible + let dimensionsInfo = "" + if (fileExtension === ".png") { + // Simple PNG dimension extraction (first 24 bytes contain width/height) + const buffer = await fs.readFile(fullPath) + if (buffer.length >= 24) { + const width = buffer.readUInt32BE(16) + const height = buffer.readUInt32BE(20) + if (width && height) { + dimensionsInfo = `${width}x${height} pixels` + } + } + } + + // Track file read + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + // Store image data URL separately - NOT in XML + const noticeText = dimensionsInfo + ? `Image file (${dimensionsInfo}, ${imageSizeInKB} KB)` + : `Image file (${imageSizeInKB} KB)` + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${noticeText}\n`, + imageDataUrl: imageDataUrl, + }) + continue + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + updateFileResult(relPath, { + status: "error", + error: `Error reading image file: ${errorMsg}`, + xmlContent: `${relPath}Error reading image file: ${errorMsg}`, + }) + await handleError( + `reading image file ${relPath}`, + error instanceof Error ? error : new Error(errorMsg), + ) + continue + } + } + if (!supportedBinaryFormats.includes(fileExtension)) { updateFileResult(relPath, { notice: "Binary file", @@ -546,6 +639,11 @@ export async function readFileTool( const xmlResults = fileResults.filter((result) => result.xmlContent).map((result) => result.xmlContent) const filesXml = `\n${xmlResults.join("\n")}\n` + // Collect all image data URLs from file results + const fileImageUrls = fileResults + .filter((result) => result.imageDataUrl) + .map((result) => result.imageDataUrl as string) + // Process all feedback in a unified way without branching let statusMessage = "" let feedbackImages: any[] = [] @@ -573,20 +671,26 @@ export async function readFileTool( } } + // Combine all images: feedback images first, then file images + const allImages = [...feedbackImages, ...fileImageUrls] + // Push the result with appropriate formatting if (statusMessage) { - const result = formatResponse.toolResult(statusMessage, feedbackImages) + const result = formatResponse.toolResult(statusMessage, allImages) // Handle different return types from toolResult if (typeof result === "string") { pushToolResult(`${result}\n${filesXml}`) } else { - // For block-based results, we need to convert the filesXml to a text block and append it + // For block-based results, append the files XML as a text block const textBlock = { type: "text" as const, text: filesXml } pushToolResult([...result, textBlock]) } + } else if (allImages.length > 0) { + // If we have images but no status message, create blocks + pushToolResult([{ type: "text" as const, text: filesXml }, ...formatResponse.imageBlocks(allImages)]) } else { - // No status message, just push the files XML + // No images or status message, just push the files XML pushToolResult(filesXml) } } catch (error) { From 7f47b14ff86a2f06d3d6c1d0a313a0689a426656 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 1 Jul 2025 22:02:11 +0700 Subject: [PATCH 02/21] feat: add support for AVIF image format in readFileTool --- src/core/tools/__tests__/readFileTool.spec.ts | 34 ++++++++++++--- src/core/tools/readFileTool.ts | 42 +++++++++++++------ 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index e33684b980f..572f302347d 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -38,7 +38,11 @@ vi.mock("fs/promises", () => fsPromises) let mockInputContent = "" // First create all the mocks -vi.mock("../../../integrations/misc/extract-text") +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn(), + addLineNumbers: vi.fn(), + getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]), +})) vi.mock("../../../services/tree-sitter") // Then create the mock functions @@ -743,12 +747,32 @@ describe("read_file tool with image support", () => { // Setup - simulate read error mockedFsReadFile.mockRejectedValue(new Error("Failed to read image")) - // Execute - const result = await executeReadImageTool() + // Create a spy for handleError + const handleErrorSpy = vi.fn() + + // Execute with the spy + const argsContent = `${testImagePath}` + const toolUse: ReadFileToolUse = { + type: "tool_use", + name: "read_file", + params: { args: argsContent }, + partial: false, + } + + await readFileTool( + mockCline, + toolUse, + mockCline.ask, + handleErrorSpy, // Use our spy here + (result: ToolResponse) => { + toolResult = result + }, + (_: ToolParamName, content?: string) => content ?? "", + ) // Verify error handling - expect(result).toContain("Error reading image file: Failed to read image") - expect(mockCline.handleError).toHaveBeenCalled() + expect(toolResult).toContain("Error reading image file: Failed to read image") + expect(handleErrorSpy).toHaveBeenCalled() }) }) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 5ff34b21e51..82ef172dd75 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -30,6 +30,7 @@ const SUPPORTED_IMAGE_FORMATS = [ ".ico", ".tiff", ".tif", + ".avif", ] as const /** @@ -52,6 +53,7 @@ async function readImageAsDataUrl(filePath: string): Promise { ".ico": "image/x-icon", ".tiff": "image/tiff", ".tif": "image/tiff", + ".avif": "image/avif", } const mimeType = mimeTypes[ext] || "image/png" @@ -533,14 +535,19 @@ export async function readFileTool( } } - if (!supportedBinaryFormats.includes(fileExtension)) { + // Check if it's a supported binary format that can be processed + if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { + // For supported binary formats (.pdf, .docx, .ipynb), continue to extractTextFromFile + // Fall through to the normal extractTextFromFile processing below + } else { + // Handle unknown binary format + const fileFormat = fileExtension.slice(1) || "bin" // Remove the dot, fallback to "bin" updateFileResult(relPath, { - notice: "Binary file", - xmlContent: `${relPath}\nBinary file\n`, + notice: `Binary file format: ${fileFormat}`, + xmlContent: `${relPath}\nBinary file - content not displayed\n`, }) continue } - // For supported binary formats (.pdf, .docx, .ipynb), continue to extractTextFromFile } // Handle range reads (bypass maxReadFileLine) @@ -675,20 +682,29 @@ export async function readFileTool( const allImages = [...feedbackImages, ...fileImageUrls] // Push the result with appropriate formatting - if (statusMessage) { - const result = formatResponse.toolResult(statusMessage, allImages) + if (statusMessage || allImages.length > 0) { + // Always use formatResponse.toolResult when we have a status message or images + const result = formatResponse.toolResult( + statusMessage || filesXml, + allImages.length > 0 ? allImages : undefined, + ) // Handle different return types from toolResult if (typeof result === "string") { - pushToolResult(`${result}\n${filesXml}`) + if (statusMessage) { + pushToolResult(`${result}\n${filesXml}`) + } else { + pushToolResult(result) + } } else { - // For block-based results, append the files XML as a text block - const textBlock = { type: "text" as const, text: filesXml } - pushToolResult([...result, textBlock]) + // For block-based results, append the files XML as a text block if not already included + if (statusMessage) { + const textBlock = { type: "text" as const, text: filesXml } + pushToolResult([...result, textBlock]) + } else { + pushToolResult(result) + } } - } else if (allImages.length > 0) { - // If we have images but no status message, create blocks - pushToolResult([{ type: "text" as const, text: filesXml }, ...formatResponse.imageBlocks(allImages)]) } else { // No images or status message, just push the files XML pushToolResult(filesXml) From 323276d84454c5ae96321fe142c29b3a0e0d802a Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 1 Jul 2025 22:16:17 +0700 Subject: [PATCH 03/21] feat: add image size limit check in readFileTool --- src/core/tools/readFileTool.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 82ef172dd75..390f1750733 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -16,6 +16,11 @@ import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { parseXml } from "../../utils/xml" import * as fs from "fs/promises" +/** + * Maximum allowed image file size in bytes (5MB) + */ +const MAX_IMAGE_FILE_SIZE_BYTES = 5 * 1024 * 1024 + /** * Supported image formats that can be displayed */ @@ -489,8 +494,23 @@ export async function readFileTool( // Check if it's a supported image format if (SUPPORTED_IMAGE_FORMATS.includes(fileExtension as any)) { try { - const imageDataUrl = await readImageAsDataUrl(fullPath) const imageStats = await fs.stat(fullPath) + + // Check if image file exceeds size limit + if (imageStats.size > MAX_IMAGE_FILE_SIZE_BYTES) { + const imageSizeInMB = (imageStats.size / (1024 * 1024)).toFixed(1) + const notice = `Image file is too large (${imageSizeInMB} MB). The maximum allowed size is 5 MB.` + + // Track file read + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${notice}\n`, + }) + continue + } + + const imageDataUrl = await readImageAsDataUrl(fullPath) const imageSizeInKB = Math.round(imageStats.size / 1024) // For images, get dimensions if possible From 0b3915853531bc8e0f95a815d6bf90e0de67ea24 Mon Sep 17 00:00:00 2001 From: sam hoang Date: Tue, 1 Jul 2025 22:46:43 +0700 Subject: [PATCH 04/21] test: enhance image support tests for path normalization --- src/core/tools/__tests__/readFileTool.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 572f302347d..68c92e5671a 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -789,7 +789,10 @@ describe("read_file tool with image support", () => { // Verify it uses extractTextFromFile instead expect(result).not.toContain("") - expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absolutePath) + // Make the test platform-agnostic by checking the call was made (path normalization can vary) + expect(mockedExtractTextFromFile).toHaveBeenCalledTimes(1) + const callArgs = mockedExtractTextFromFile.mock.calls[0] + expect(callArgs[0]).toMatch(/[\\\/]test[\\\/]document\.pdf$/) }) it("should handle unknown binary formats", async () => { From 6badb513f4a92cf14e355e452fd825c3dd49f27f Mon Sep 17 00:00:00 2001 From: Sam Hoang Van Date: Tue, 1 Jul 2025 22:58:23 +0700 Subject: [PATCH 05/21] fix pr comment --- src/core/tools/__tests__/readFileTool.spec.ts | 36 +++++++++++++++++++ src/core/tools/readFileTool.ts | 15 ++++---- src/i18n/locales/ca/tools.json | 3 +- src/i18n/locales/de/tools.json | 3 +- src/i18n/locales/en/tools.json | 3 +- src/i18n/locales/es/tools.json | 3 +- src/i18n/locales/fr/tools.json | 3 +- src/i18n/locales/hi/tools.json | 3 +- src/i18n/locales/id/tools.json | 3 +- src/i18n/locales/it/tools.json | 3 +- src/i18n/locales/ja/tools.json | 3 +- src/i18n/locales/ko/tools.json | 3 +- src/i18n/locales/nl/tools.json | 3 +- src/i18n/locales/pl/tools.json | 3 +- src/i18n/locales/pt-BR/tools.json | 3 +- src/i18n/locales/ru/tools.json | 3 +- src/i18n/locales/tr/tools.json | 3 +- src/i18n/locales/vi/tools.json | 3 +- src/i18n/locales/zh-CN/tools.json | 3 +- src/i18n/locales/zh-TW/tools.json | 3 +- 20 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 68c92e5671a..cbd9879fb54 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -726,6 +726,42 @@ describe("read_file tool with image support", () => { expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`) }) + it("should extract and display PNG dimensions correctly", async () => { + // Setup - Create a proper PNG buffer with known dimensions (100x200 pixels) + const pngSignature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) // PNG signature + const ihdrLength = Buffer.from([0x00, 0x00, 0x00, 0x0D]) // IHDR chunk length (13 bytes) + const ihdrType = Buffer.from("IHDR", "ascii") // IHDR chunk type + const width = Buffer.alloc(4) + width.writeUInt32BE(100, 0) // Width: 100 pixels + const height = Buffer.alloc(4) + height.writeUInt32BE(200, 0) // Height: 200 pixels + const ihdrData = Buffer.from([0x08, 0x02, 0x00, 0x00, 0x00]) // Bit depth, color type, compression, filter, interlace + const crc = Buffer.from([0x00, 0x00, 0x00, 0x00]) // Dummy CRC + + const pngBuffer = Buffer.concat([pngSignature, ihdrLength, ihdrType, width, height, ihdrData, crc]) + const pngBase64 = pngBuffer.toString("base64") + + mockedFsReadFile.mockResolvedValue(pngBuffer) + + // Execute + const result = await executeReadImageTool() + + // Verify result is a multi-part response + expect(Array.isArray(result)).toBe(true) + const textPart = (result as any[]).find((p) => p.type === "text")?.text + const imagePart = (result as any[]).find((p) => p.type === "image") + + // Verify text part contains dimensions + expect(textPart).toContain(`${testImagePath}`) + expect(textPart).toContain("100x200 pixels") // Should include the dimensions + expect(textPart).toContain(`Image file`) + + // Verify image part + expect(imagePart).toBeDefined() + expect(imagePart.source.media_type).toBe("image/png") + expect(imagePart.source.data).toBe(pngBase64) + }) + it("should handle large image files", async () => { // Setup - simulate a large image const largeBase64 = "A".repeat(1000000) // 1MB of base64 data diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 390f1750733..0adadc27f89 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -39,9 +39,9 @@ const SUPPORTED_IMAGE_FORMATS = [ ] as const /** - * Reads an image file and returns it as a base64 data URL + * Reads an image file and returns both the data URL and buffer */ -async function readImageAsDataUrl(filePath: string): Promise { +async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{ dataUrl: string; buffer: Buffer }> { const fileBuffer = await fs.readFile(filePath) const base64 = fileBuffer.toString("base64") const ext = path.extname(filePath).toLowerCase() @@ -62,7 +62,9 @@ async function readImageAsDataUrl(filePath: string): Promise { } const mimeType = mimeTypes[ext] || "image/png" - return `data:${mimeType};base64,${base64}` + const dataUrl = `data:${mimeType};base64,${base64}` + + return { dataUrl, buffer: fileBuffer } } export function getReadFileToolDescription(blockName: string, blockParams: any): string { @@ -492,14 +494,14 @@ export async function readFileTool( const supportedBinaryFormats = getSupportedBinaryFormats() // Check if it's a supported image format - if (SUPPORTED_IMAGE_FORMATS.includes(fileExtension as any)) { + if (SUPPORTED_IMAGE_FORMATS.includes(fileExtension as (typeof SUPPORTED_IMAGE_FORMATS)[number])) { try { const imageStats = await fs.stat(fullPath) // Check if image file exceeds size limit if (imageStats.size > MAX_IMAGE_FILE_SIZE_BYTES) { const imageSizeInMB = (imageStats.size / (1024 * 1024)).toFixed(1) - const notice = `Image file is too large (${imageSizeInMB} MB). The maximum allowed size is 5 MB.` + const notice = t("tools:readFile.imageTooLarge", { size: imageSizeInMB, max: 5 }) // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) @@ -510,14 +512,13 @@ export async function readFileTool( continue } - const imageDataUrl = await readImageAsDataUrl(fullPath) + const { dataUrl: imageDataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath) const imageSizeInKB = Math.round(imageStats.size / 1024) // For images, get dimensions if possible let dimensionsInfo = "" if (fileExtension === ".png") { // Simple PNG dimension extraction (first 24 bytes contain width/height) - const buffer = await fs.readFile(fullPath) if (buffer.length >= 24) { const width = buffer.readUInt32BE(16) const height = buffer.readUInt32BE(20) diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 5b3a228bdec..96b97bfa7b4 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (línies {{start}}-{{end}})", "definitionsOnly": " (només definicions)", - "maxLines": " (màxim {{max}} línies)" + "maxLines": " (màxim {{max}} línies)", + "imageTooLarge": "El fitxer d'imatge és massa gran ({{size}} MB). La mida màxima permesa és {{max}} MB." }, "toolRepetitionLimitReached": "Roo sembla estar atrapat en un bucle, intentant la mateixa acció ({{toolName}}) repetidament. Això podria indicar un problema amb la seva estratègia actual. Considera reformular la tasca, proporcionar instruccions més específiques o guiar-lo cap a un enfocament diferent.", "codebaseSearch": { diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index eb1afbc0821..19b700ee147 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (Zeilen {{start}}-{{end}})", "definitionsOnly": " (nur Definitionen)", - "maxLines": " (maximal {{max}} Zeilen)" + "maxLines": " (maximal {{max}} Zeilen)", + "imageTooLarge": "Die Bilddatei ist zu groß ({{size}} MB). Die maximal erlaubte Größe beträgt {{max}} MB." }, "toolRepetitionLimitReached": "Roo scheint in einer Schleife festzustecken und versucht wiederholt dieselbe Aktion ({{toolName}}). Dies könnte auf ein Problem mit der aktuellen Strategie hindeuten. Überlege dir, die Aufgabe umzuformulieren, genauere Anweisungen zu geben oder Roo zu einem anderen Ansatz zu führen.", "codebaseSearch": { diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index 0265a843985..fe9b6c299f1 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (lines {{start}}-{{end}})", "definitionsOnly": " (definitions only)", - "maxLines": " (max {{max}} lines)" + "maxLines": " (max {{max}} lines)", + "imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB." }, "toolRepetitionLimitReached": "Roo appears to be stuck in a loop, attempting the same action ({{toolName}}) repeatedly. This might indicate a problem with its current strategy. Consider rephrasing the task, providing more specific instructions, or guiding it towards a different approach.", "codebaseSearch": { diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index 303f5365ed0..410e7e1148a 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (líneas {{start}}-{{end}})", "definitionsOnly": " (solo definiciones)", - "maxLines": " (máximo {{max}} líneas)" + "maxLines": " (máximo {{max}} líneas)", + "imageTooLarge": "El archivo de imagen es demasiado grande ({{size}} MB). El tamaño máximo permitido es {{max}} MB." }, "toolRepetitionLimitReached": "Roo parece estar atrapado en un bucle, intentando la misma acción ({{toolName}}) repetidamente. Esto podría indicar un problema con su estrategia actual. Considera reformular la tarea, proporcionar instrucciones más específicas o guiarlo hacia un enfoque diferente.", "codebaseSearch": { diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index a6c71aca333..6e3f3f05ceb 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (lignes {{start}}-{{end}})", "definitionsOnly": " (définitions uniquement)", - "maxLines": " (max {{max}} lignes)" + "maxLines": " (max {{max}} lignes)", + "imageTooLarge": "Le fichier image est trop volumineux ({{size}} MB). La taille maximale autorisée est {{max}} MB." }, "toolRepetitionLimitReached": "Roo semble être bloqué dans une boucle, tentant la même action ({{toolName}}) de façon répétée. Cela pourrait indiquer un problème avec sa stratégie actuelle. Envisage de reformuler la tâche, de fournir des instructions plus spécifiques ou de le guider vers une approche différente.", "codebaseSearch": { diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index 0cb4aeb14ec..24df270e663 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (पंक्तियाँ {{start}}-{{end}})", "definitionsOnly": " (केवल परिभाषाएँ)", - "maxLines": " (अधिकतम {{max}} पंक्तियाँ)" + "maxLines": " (अधिकतम {{max}} पंक्तियाँ)", + "imageTooLarge": "छवि फ़ाइल बहुत बड़ी है ({{size}} MB)। अधिकतम अनुमतित आकार {{max}} MB है।" }, "toolRepetitionLimitReached": "Roo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।", "codebaseSearch": { diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 2e3c4f0c22e..6745bfd4743 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (baris {{start}}-{{end}})", "definitionsOnly": " (hanya definisi)", - "maxLines": " (maks {{max}} baris)" + "maxLines": " (maks {{max}} baris)", + "imageTooLarge": "File gambar terlalu besar ({{size}} MB). Ukuran maksimum yang diizinkan adalah {{max}} MB." }, "toolRepetitionLimitReached": "Roo tampaknya terjebak dalam loop, mencoba aksi yang sama ({{toolName}}) berulang kali. Ini mungkin menunjukkan masalah dengan strategi saat ini. Pertimbangkan untuk mengubah frasa tugas, memberikan instruksi yang lebih spesifik, atau mengarahkannya ke pendekatan yang berbeda.", "codebaseSearch": { diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index ffae474f1db..cfb631db7bb 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (righe {{start}}-{{end}})", "definitionsOnly": " (solo definizioni)", - "maxLines": " (max {{max}} righe)" + "maxLines": " (max {{max}} righe)", + "imageTooLarge": "Il file immagine è troppo grande ({{size}} MB). La dimensione massima consentita è {{max}} MB." }, "toolRepetitionLimitReached": "Roo sembra essere bloccato in un ciclo, tentando ripetutamente la stessa azione ({{toolName}}). Questo potrebbe indicare un problema con la sua strategia attuale. Considera di riformulare l'attività, fornire istruzioni più specifiche o guidarlo verso un approccio diverso.", "codebaseSearch": { diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index 04a5fcc0856..c70b7785943 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " ({{start}}-{{end}}行目)", "definitionsOnly": " (定義のみ)", - "maxLines": " (最大{{max}}行)" + "maxLines": " (最大{{max}}行)", + "imageTooLarge": "画像ファイルが大きすぎます({{size}} MB)。最大許可サイズは {{max}} MB です。" }, "toolRepetitionLimitReached": "Rooが同じ操作({{toolName}})を繰り返し試みるループに陥っているようです。これは現在の方法に問題がある可能性を示しています。タスクの言い換え、より具体的な指示の提供、または別のアプローチへの誘導を検討してください。", "codebaseSearch": { diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index e43a541794a..6126aad34aa 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " ({{start}}-{{end}}행)", "definitionsOnly": " (정의만)", - "maxLines": " (최대 {{max}}행)" + "maxLines": " (최대 {{max}}행)", + "imageTooLarge": "이미지 파일이 너무 큽니다 ({{size}} MB). 최대 허용 크기는 {{max}} MB입니다." }, "toolRepetitionLimitReached": "Roo가 같은 동작({{toolName}})을 반복적으로 시도하면서 루프에 갇힌 것 같습니다. 이는 현재 전략에 문제가 있을 수 있음을 나타냅니다. 작업을 다시 표현하거나, 더 구체적인 지침을 제공하거나, 다른 접근 방식으로 안내해 보세요.", "codebaseSearch": { diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index 56a8cdbc466..b36c727e078 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (regels {{start}}-{{end}})", "definitionsOnly": " (alleen definities)", - "maxLines": " (max {{max}} regels)" + "maxLines": " (max {{max}} regels)", + "imageTooLarge": "Afbeeldingsbestand is te groot ({{size}} MB). De maximaal toegestane grootte is {{max}} MB." }, "toolRepetitionLimitReached": "Roo lijkt vast te zitten in een lus, waarbij hij herhaaldelijk dezelfde actie ({{toolName}}) probeert. Dit kan duiden op een probleem met de huidige strategie. Overweeg de taak te herformuleren, specifiekere instructies te geven of Roo naar een andere aanpak te leiden.", "codebaseSearch": { diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index 62568826aae..0ddfa11f831 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (linie {{start}}-{{end}})", "definitionsOnly": " (tylko definicje)", - "maxLines": " (maks. {{max}} linii)" + "maxLines": " (maks. {{max}} linii)", + "imageTooLarge": "Plik obrazu jest zbyt duży ({{size}} MB). Maksymalny dozwolony rozmiar to {{max}} MB." }, "toolRepetitionLimitReached": "Wygląda na to, że Roo utknął w pętli, wielokrotnie próbując wykonać tę samą akcję ({{toolName}}). Może to wskazywać na problem z jego obecną strategią. Rozważ przeformułowanie zadania, podanie bardziej szczegółowych instrukcji lub nakierowanie go na inne podejście.", "codebaseSearch": { diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index f74e0f8196e..3f4c21e0f51 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (linhas {{start}}-{{end}})", "definitionsOnly": " (apenas definições)", - "maxLines": " (máx. {{max}} linhas)" + "maxLines": " (máx. {{max}} linhas)", + "imageTooLarge": "Arquivo de imagem é muito grande ({{size}} MB). O tamanho máximo permitido é {{max}} MB." }, "toolRepetitionLimitReached": "Roo parece estar preso em um loop, tentando a mesma ação ({{toolName}}) repetidamente. Isso pode indicar um problema com sua estratégia atual. Considere reformular a tarefa, fornecer instruções mais específicas ou guiá-lo para uma abordagem diferente.", "codebaseSearch": { diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index 1e59d10499c..7fff609459c 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (строки {{start}}-{{end}})", "definitionsOnly": " (только определения)", - "maxLines": " (макс. {{max}} строк)" + "maxLines": " (макс. {{max}} строк)", + "imageTooLarge": "Файл изображения слишком большой ({{size}} МБ). Максимально допустимый размер {{max}} МБ." }, "toolRepetitionLimitReached": "Похоже, что Roo застрял в цикле, многократно пытаясь выполнить одно и то же действие ({{toolName}}). Это может указывать на проблему с его текущей стратегией. Попробуйте переформулировать задачу, предоставить более конкретные инструкции или направить его к другому подходу.", "codebaseSearch": { diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index e4c73cdc4b2..3eccc1bb358 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (satır {{start}}-{{end}})", "definitionsOnly": " (sadece tanımlar)", - "maxLines": " (maks. {{max}} satır)" + "maxLines": " (maks. {{max}} satır)", + "imageTooLarge": "Görüntü dosyası çok büyük ({{size}} MB). İzin verilen maksimum boyut {{max}} MB." }, "toolRepetitionLimitReached": "Roo bir döngüye takılmış gibi görünüyor, aynı eylemi ({{toolName}}) tekrar tekrar deniyor. Bu, mevcut stratejisinde bir sorun olduğunu gösterebilir. Görevi yeniden ifade etmeyi, daha spesifik talimatlar vermeyi veya onu farklı bir yaklaşıma yönlendirmeyi düşünün.", "codebaseSearch": { diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 9811ee12c92..91385942998 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (dòng {{start}}-{{end}})", "definitionsOnly": " (chỉ định nghĩa)", - "maxLines": " (tối đa {{max}} dòng)" + "maxLines": " (tối đa {{max}} dòng)", + "imageTooLarge": "Tệp hình ảnh quá lớn ({{size}} MB). Kích thước tối đa cho phép là {{max}} MB." }, "toolRepetitionLimitReached": "Roo dường như đang bị mắc kẹt trong một vòng lặp, liên tục cố gắng thực hiện cùng một hành động ({{toolName}}). Điều này có thể cho thấy vấn đề với chiến lược hiện tại. Hãy cân nhắc việc diễn đạt lại nhiệm vụ, cung cấp hướng dẫn cụ thể hơn, hoặc hướng Roo theo một cách tiếp cận khác.", "codebaseSearch": { diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index 13641b8d43b..d59f89ddabb 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (仅定义)", - "maxLines": " (最多 {{max}} 行)" + "maxLines": " (最多 {{max}} 行)", + "imageTooLarge": "图片文件过大 ({{size}} MB)。允许的最大大小为 {{max}} MB。" }, "toolRepetitionLimitReached": "Roo 似乎陷入循环,反复尝试同一操作 ({{toolName}})。这可能表明当前策略存在问题。请考虑重新描述任务、提供更具体的指示或引导其尝试不同的方法。", "codebaseSearch": { diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index a726e3c9192..0d985e7dfb6 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -2,7 +2,8 @@ "readFile": { "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (僅定義)", - "maxLines": " (最多 {{max}} 行)" + "maxLines": " (最多 {{max}} 行)", + "imageTooLarge": "圖片檔案過大 ({{size}} MB)。允許的最大大小為 {{max}} MB。" }, "toolRepetitionLimitReached": "Roo 似乎陷入循環,反覆嘗試同一操作 ({{toolName}})。這可能表明目前策略存在問題。請考慮重新描述工作、提供更具體的指示或引導其嘗試不同的方法。", "codebaseSearch": { From 28e0b28c3e08d1a493646421fa1bb92ace07ab95 Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Thu, 3 Jul 2025 16:06:26 +0700 Subject: [PATCH 06/21] add setting ui for max image file size --- packages/types/src/global-settings.ts | 1 + src/core/tools/readFileTool.ts | 10 +++--- src/core/webview/ClineProvider.ts | 3 ++ .../webview/__tests__/ClineProvider.spec.ts | 1 + src/core/webview/webviewMessageHandler.ts | 4 +++ src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 1 + .../settings/ContextManagementSettings.tsx | 31 +++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 3 ++ .../src/context/ExtensionStateContext.tsx | 4 +++ .../__tests__/ExtensionStateContext.spec.tsx | 1 + webview-ui/src/i18n/locales/ca/settings.json | 5 +++ webview-ui/src/i18n/locales/de/settings.json | 5 +++ webview-ui/src/i18n/locales/en/settings.json | 5 +++ webview-ui/src/i18n/locales/es/settings.json | 5 +++ webview-ui/src/i18n/locales/fr/settings.json | 5 +++ webview-ui/src/i18n/locales/hi/settings.json | 5 +++ webview-ui/src/i18n/locales/id/settings.json | 5 +++ webview-ui/src/i18n/locales/it/settings.json | 5 +++ webview-ui/src/i18n/locales/ja/settings.json | 5 +++ webview-ui/src/i18n/locales/ko/settings.json | 5 +++ webview-ui/src/i18n/locales/nl/settings.json | 5 +++ webview-ui/src/i18n/locales/pl/settings.json | 5 +++ .../src/i18n/locales/pt-BR/settings.json | 5 +++ webview-ui/src/i18n/locales/ru/settings.json | 5 +++ webview-ui/src/i18n/locales/tr/settings.json | 5 +++ webview-ui/src/i18n/locales/vi/settings.json | 5 +++ .../src/i18n/locales/zh-CN/settings.json | 5 +++ .../src/i18n/locales/zh-TW/settings.json | 5 +++ 29 files changed, 145 insertions(+), 5 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index d5e76ecceac..734cc36c9d4 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -101,6 +101,7 @@ export const globalSettingsSchema = z.object({ maxWorkspaceFiles: z.number().optional(), showRooIgnoredFiles: z.boolean().optional(), maxReadFileLine: z.number().optional(), + maxImageFileSize: z.number().optional(), terminalOutputLineLimit: z.number().optional(), terminalOutputCharacterLimit: z.number().optional(), diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 0adadc27f89..c51f9649ee9 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -17,9 +17,9 @@ import { parseXml } from "../../utils/xml" import * as fs from "fs/promises" /** - * Maximum allowed image file size in bytes (5MB) + * Default maximum allowed image file size in bytes (5MB) */ -const MAX_IMAGE_FILE_SIZE_BYTES = 5 * 1024 * 1024 +const DEFAULT_MAX_IMAGE_FILE_SIZE_MB = 5 /** * Supported image formats that can be displayed @@ -482,7 +482,7 @@ export async function readFileTool( const relPath = fileResult.path const fullPath = path.resolve(cline.cwd, relPath) - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} + const { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB } = (await cline.providerRef.deref()?.getState()) ?? {} // Process approved files try { @@ -499,9 +499,9 @@ export async function readFileTool( const imageStats = await fs.stat(fullPath) // Check if image file exceeds size limit - if (imageStats.size > MAX_IMAGE_FILE_SIZE_BYTES) { + if (imageStats.size > maxImageFileSize * 1024 * 1024) { const imageSizeInMB = (imageStats.size / (1024 * 1024)).toFixed(1) - const notice = t("tools:readFile.imageTooLarge", { size: imageSizeInMB, max: 5 }) + const notice = t("tools:readFile.imageTooLarge", { size: imageSizeInMB, max: maxImageFileSize }) // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 905e657b37e..2efb6d96e21 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1425,6 +1425,7 @@ export class ClineProvider showRooIgnoredFiles, language, maxReadFileLine, + maxImageFileSize, terminalCompressProgressBar, historyPreviewCollapsed, cloudUserInfo, @@ -1532,6 +1533,7 @@ export class ClineProvider language: language ?? formatLanguage(vscode.env.language), renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? -1, + maxImageFileSize: maxImageFileSize ?? 5, maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, terminalCompressProgressBar: terminalCompressProgressBar ?? true, @@ -1702,6 +1704,7 @@ export class ClineProvider telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true, maxReadFileLine: stateValues.maxReadFileLine ?? -1, + maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, cloudUserInfo, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 344b0988165..b3991d29735 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -533,6 +533,7 @@ describe("ClineProvider", () => { showRooIgnoredFiles: true, renderContext: "sidebar", maxReadFileLine: 500, + maxImageFileSize: 5, cloudUserInfo: null, organizationAllowList: ORGANIZATION_ALLOW_ALL, autoCondenseContext: true, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c739c2ade8d..2e1d8486426 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1265,6 +1265,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("maxReadFileLine", message.value) await provider.postStateToWebview() break + case "maxImageFileSize": + await updateGlobalState("maxImageFileSize", message.value) + await provider.postStateToWebview() + break case "maxConcurrentFileReads": const valueToSave = message.value // Capture the value intended for saving await updateGlobalState("maxConcurrentFileReads", valueToSave) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 000762e317a..49151a7e96b 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -278,6 +278,7 @@ export type ExtensionState = Pick< maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500) showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings maxReadFileLine: number // Maximum number of lines to read from a file before truncating + maxImageFileSize: number // Maximum size of image files to process in MB experiments: Experiments // Map of experiment IDs to their enabled state diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 795e2765222..2ea42eb75ee 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -162,6 +162,7 @@ export interface WebviewMessage { | "remoteBrowserEnabled" | "language" | "maxReadFileLine" + | "maxImageFileSize" | "maxConcurrentFileReads" | "includeDiagnosticMessages" | "maxDiagnosticMessages" diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 4530fdb1ba1..b21ad8258ae 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -20,6 +20,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxWorkspaceFiles: number showRooIgnoredFiles?: boolean maxReadFileLine?: number + maxImageFileSize?: number maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean @@ -32,6 +33,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxWorkspaceFiles" | "showRooIgnoredFiles" | "maxReadFileLine" + | "maxImageFileSize" | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" @@ -49,6 +51,7 @@ export const ContextManagementSettings = ({ showRooIgnoredFiles, setCachedStateField, maxReadFileLine, + maxImageFileSize, maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, @@ -206,6 +209,34 @@ export const ContextManagementSettings = ({ +
+
+ {t("settings:contextManagement.maxImageFileSize.label")} +
+ { + const newValue = parseInt(e.target.value, 10) + if (!isNaN(newValue) && newValue >= 1 && newValue <= 100) { + setCachedStateField("maxImageFileSize", newValue) + } + }} + onClick={(e) => e.currentTarget.select()} + data-testid="max-image-file-size-input" + /> + {t("settings:contextManagement.maxImageFileSize.mb")} +
+
+
+ {t("settings:contextManagement.maxImageFileSize.description")} +
+
+
(({ onDone, t showRooIgnoredFiles, remoteBrowserEnabled, maxReadFileLine, + maxImageFileSize, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, @@ -321,6 +322,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "maxWorkspaceFiles", value: maxWorkspaceFiles ?? 200 }) vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles }) vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 }) + vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 }) vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 }) vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages }) vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 }) @@ -667,6 +669,7 @@ const SettingsView = forwardRef(({ onDone, t maxWorkspaceFiles={maxWorkspaceFiles ?? 200} showRooIgnoredFiles={showRooIgnoredFiles} maxReadFileLine={maxReadFileLine} + maxImageFileSize={maxImageFileSize} maxConcurrentFileReads={maxConcurrentFileReads} profileThresholds={profileThresholds} includeDiagnosticMessages={includeDiagnosticMessages} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ff1ce31c53c..3d117aa7f6b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -120,6 +120,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAwsUsePromptCache: (value: boolean) => void maxReadFileLine: number setMaxReadFileLine: (value: number) => void + maxImageFileSize: number + setMaxImageFileSize: (value: number) => void machineId?: string pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void @@ -208,6 +210,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior). renderContext: "sidebar", maxReadFileLine: -1, // Default max read file line limit + maxImageFileSize: 5, // Default max image file size in MB pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting maxConcurrentFileReads: 5, // Default concurrent file reads @@ -448,6 +451,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })), setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })), setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), + setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })), setTerminalCompressProgressBar: (value) => setState((prevState) => ({ ...prevState, terminalCompressProgressBar: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 1e5867d3fc3..23f39409410 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -209,6 +209,7 @@ describe("mergeExtensionState", () => { sharingEnabled: false, profileThresholds: {}, hasOpenedModeSelector: false, // Add the new required property + maxImageFileSize: 5 } const prevState: ExtensionState = { diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index ab929d724df..8f7db4cac2e 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -526,6 +526,11 @@ "profileDescription": "Llindar personalitzat només per a aquest perfil (substitueix el per defecte global)", "inheritDescription": "Aquest perfil hereta el llindar per defecte global ({{threshold}}%)", "usesGlobal": "(utilitza global {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Mida màxima d'arxiu d'imatge", + "mb": "MB", + "description": "Mida màxima (en MB) per a arxius d'imatge que poden ser processats per l'eina de lectura d'arxius." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 766bc891e4e..1937cb053c4 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -526,6 +526,11 @@ "profileDescription": "Benutzerdefinierter Schwellenwert nur für dieses Profil (überschreibt globalen Standard)", "inheritDescription": "Dieses Profil erbt den globalen Standard-Schwellenwert ({{threshold}}%)", "usesGlobal": "(verwendet global {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Maximale Bilddateigröße", + "mb": "MB", + "description": "Maximale Größe (in MB) für Bilddateien, die vom read file Tool verarbeitet werden können." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index cfd5b042868..c8ad99c62d9 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -502,6 +502,11 @@ "lines": "lines", "always_full_read": "Always read entire file" }, + "maxImageFileSize": { + "label": "Max image file size", + "mb": "MB", + "description": "Maximum size (in MB) for image files that can be processed by the read file tool." + }, "diagnostics": { "includeMessages": { "label": "Automatically include diagnostics in context", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f5368639090..e54d770991b 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -502,6 +502,11 @@ "label": "Límite de lecturas simultáneas", "description": "Número máximo de archivos que la herramienta 'read_file' puede procesar simultáneamente. Valores más altos pueden acelerar la lectura de múltiples archivos pequeños pero aumentan el uso de memoria." }, + "maxImageFileSize": { + "label": "Tamaño máximo de archivo de imagen", + "mb": "MB", + "description": "Tamaño máximo (en MB) para archivos de imagen que pueden ser procesados por la herramienta de lectura de archivos." + }, "diagnostics": { "includeMessages": { "label": "Incluir automáticamente diagnósticos en el contexto", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 0e12c58d386..54a1092c2fc 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -502,6 +502,11 @@ "label": "Limite de lectures simultanées", "description": "Nombre maximum de fichiers que l'outil 'read_file' peut traiter simultanément. Des valeurs plus élevées peuvent accélérer la lecture de plusieurs petits fichiers mais augmentent l'utilisation de la mémoire." }, + "maxImageFileSize": { + "label": "Taille maximale des fichiers d'image", + "mb": "MB", + "description": "Taille maximale (en MB) pour les fichiers d'image qui peuvent être traités par l'outil de lecture de fichier." + }, "diagnostics": { "includeMessages": { "label": "Inclure automatiquement les diagnostics dans le contexte", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index d2cfa971ffe..77252093ae3 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -527,6 +527,11 @@ "profileDescription": "केवल इस प्रोफ़ाइल के लिए कस्टम सीमा (वैश्विक डिफ़ॉल्ट को ओवरराइड करता है)", "inheritDescription": "यह प्रोफ़ाइल वैश्विक डिफ़ॉल्ट सीमा को इनहेरिट करता है ({{threshold}}%)", "usesGlobal": "(वैश्विक {{threshold}}% का उपयोग करता है)" + }, + "maxImageFileSize": { + "label": "अधिकतम छवि फ़ाइल आकार", + "mb": "MB", + "description": "छवि फ़ाइलों के लिए अधिकतम आकार (MB में) जो read file tool द्वारा प्रसंस्कृत किया जा सकता है।" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 3f362f650de..8c6fd70432d 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -531,6 +531,11 @@ "description": "Roo membaca sejumlah baris ini ketika model menghilangkan nilai start/end. Jika angka ini kurang dari total file, Roo menghasilkan indeks nomor baris dari definisi kode. Kasus khusus: -1 menginstruksikan Roo untuk membaca seluruh file (tanpa indexing), dan 0 menginstruksikannya untuk tidak membaca baris dan hanya menyediakan indeks baris untuk konteks minimal. Nilai yang lebih rendah meminimalkan penggunaan konteks awal, memungkinkan pembacaan rentang baris yang tepat selanjutnya. Permintaan start/end eksplisit tidak dibatasi oleh pengaturan ini.", "lines": "baris", "always_full_read": "Selalu baca seluruh file" + }, + "maxImageFileSize": { + "label": "Ukuran file gambar maksimum", + "mb": "MB", + "description": "Ukuran maksimum (dalam MB) untuk file gambar yang dapat diproses oleh alat baca file." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 14deda20ea4..54b29140eaf 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -527,6 +527,11 @@ "profileDescription": "Soglia personalizzata solo per questo profilo (sovrascrive il predefinito globale)", "inheritDescription": "Questo profilo eredita la soglia predefinita globale ({{threshold}}%)", "usesGlobal": "(usa globale {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Dimensione massima file immagine", + "mb": "MB", + "description": "Dimensione massima (in MB) per i file immagine che possono essere elaborati dallo strumento di lettura file." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index d7864e40db5..e77b8f9d23c 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -527,6 +527,11 @@ "profileDescription": "このプロファイルのみのカスタムしきい値(グローバルデフォルトを上書き)", "inheritDescription": "このプロファイルはグローバルデフォルトしきい値を継承します({{threshold}}%)", "usesGlobal": "(グローバル {{threshold}}% を使用)" + }, + "maxImageFileSize": { + "label": "最大画像ファイルサイズ", + "mb": "MB", + "description": "read fileツールで処理できる画像ファイルの最大サイズ(MB単位)。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index adbe216463e..d2ee15b67ff 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -527,6 +527,11 @@ "profileDescription": "이 프로필만을 위한 사용자 정의 임계값 (글로벌 기본값 재정의)", "inheritDescription": "이 프로필은 글로벌 기본 임계값을 상속합니다 ({{threshold}}%)", "usesGlobal": "(글로벌 {{threshold}}% 사용)" + }, + "maxImageFileSize": { + "label": "최대 이미지 파일 크기", + "mb": "MB", + "description": "read file 도구로 처리할 수 있는 이미지 파일의 최대 크기(MB 단위)입니다." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 6085be8fb17..3dd0806cbd2 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -502,6 +502,11 @@ "label": "Limiet gelijktijdige bestandslezingen", "description": "Maximum aantal bestanden dat de 'read_file' tool tegelijkertijd kan verwerken. Hogere waarden kunnen het lezen van meerdere kleine bestanden versnellen maar verhogen het geheugengebruik." }, + "maxImageFileSize": { + "label": "Maximum afbeeldingsbestandsgrootte", + "mb": "MB", + "description": "Maximale grootte (in MB) voor afbeeldingsbestanden die kunnen worden verwerkt door de read file tool." + }, "diagnostics": { "includeMessages": { "label": "Automatisch diagnostiek opnemen in context", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 9107fd959ef..37586b2ebcc 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -527,6 +527,11 @@ "profileDescription": "Niestandardowy próg tylko dla tego profilu (zastępuje globalny domyślny)", "inheritDescription": "Ten profil dziedziczy globalny domyślny próg ({{threshold}}%)", "usesGlobal": "(używa globalnego {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Maksymalny rozmiar pliku obrazu", + "mb": "MB", + "description": "Maksymalny rozmiar (w MB) plików obrazów, które mogą być przetwarzane przez narzędzie do czytania plików." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 8137d917ae1..46cd6cfef36 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -527,6 +527,11 @@ "profileDescription": "Limite personalizado apenas para este perfil (substitui o padrão global)", "inheritDescription": "Este perfil herda o limite padrão global ({{threshold}}%)", "usesGlobal": "(usa global {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Tamanho máximo do arquivo de imagem", + "mb": "MB", + "description": "Tamanho máximo (em MB) para arquivos de imagem que podem ser processados pela ferramenta de leitura de arquivos." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index e93f24e9d7b..b9163e910c0 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -527,6 +527,11 @@ "profileDescription": "Пользовательский порог только для этого профиля (переопределяет глобальный по умолчанию)", "inheritDescription": "Этот профиль наследует глобальный порог по умолчанию ({{threshold}}%)", "usesGlobal": "(использует глобальный {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Максимальный размер файла изображения", + "mb": "MB", + "description": "Максимальный размер (в МБ) для файлов изображений, которые могут быть обработаны инструментом чтения файлов." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 450738183bb..db0e45a8353 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -527,6 +527,11 @@ "profileDescription": "Sadece bu profil için özel eşik (küresel varsayılanı geçersiz kılar)", "inheritDescription": "Bu profil küresel varsayılan eşiği miras alır ({{threshold}}%)", "usesGlobal": "(küresel {{threshold}}% kullanır)" + }, + "maxImageFileSize": { + "label": "Maksimum görüntü dosyası boyutu", + "mb": "MB", + "description": "Dosya okuma aracı tarafından işlenebilecek görüntü dosyaları için maksimum boyut (MB cinsinden)." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 87e99ce6f57..2116950fd59 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -527,6 +527,11 @@ "profileDescription": "Ngưỡng tùy chỉnh chỉ cho hồ sơ này (ghi đè mặc định toàn cục)", "inheritDescription": "Hồ sơ này kế thừa ngưỡng mặc định toàn cục ({{threshold}}%)", "usesGlobal": "(sử dụng toàn cục {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "Kích thước tối đa của tệp hình ảnh", + "mb": "MB", + "description": "Kích thước tối đa (tính bằng MB) cho các tệp hình ảnh có thể được xử lý bởi công cụ đọc tệp." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index e94c857e658..b11202e2716 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -527,6 +527,11 @@ "profileDescription": "仅此配置文件的自定义阈值(覆盖全局默认)", "inheritDescription": "此配置文件继承全局默认阈值({{threshold}}%)", "usesGlobal": "(使用全局 {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "最大图像文件大小", + "mb": "MB", + "description": "read file工具可以处理的图像文件的最大大小(以MB为单位)。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index fa10cf30288..023ade21ff9 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -527,6 +527,11 @@ "profileDescription": "僅此檔案的自訂閾值(覆蓋全域預設)", "inheritDescription": "此檔案繼承全域預設閾值({{threshold}}%)", "usesGlobal": "(使用全域 {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "最大圖像檔案大小", + "mb": "MB", + "description": "read file工具可以處理的圖像檔案的最大大小(以MB為單位)。" } }, "terminal": { From c165fa4d6c60fc7d49b5deb2d39d2e914587d1af Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Sat, 5 Jul 2025 23:20:56 +0700 Subject: [PATCH 07/21] handle model don support image --- src/core/tools/__tests__/readFileTool.spec.ts | 81 +++++++++++++++++++ src/core/tools/readFileTool.ts | 8 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index cbd9879fb54..5f825d8c408 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -181,6 +181,13 @@ describe("read_file tool with maxReadFileLine setting", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + // Setup default API handler that supports images + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: true } + }) + } + toolResult = undefined }) @@ -437,6 +444,13 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolError = vi.fn().mockReturnValue(undefined) mockCline.didRejectTool = false + // Mock the API handler - required for image support check + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: true } + }) + } + toolResult = undefined }) @@ -625,6 +639,13 @@ describe("read_file tool with image support", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + // Setup default API handler that supports images + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: true } + }) + } + toolResult = undefined }) @@ -779,6 +800,66 @@ describe("read_file tool with image support", () => { expect(imagePart.source.data).toBe(largeBase64) }) + it("should exclude images when model does not support images", async () => { + // Setup - mock API handler that doesn't support images + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: false } + }) + } + + // Execute + const result = await executeReadImageTool() + + // When images are not supported, the tool should return just XML (not call formatResponse.toolResult) + expect(toolResultMock).not.toHaveBeenCalled() + expect(typeof result).toBe("string") + expect(result).toContain(`${testImagePath}`) + expect(result).toContain(`Image file`) + }) + + it("should include images when model supports images", async () => { + // Setup - mock API handler that supports images + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: true } + }) + } + + // Execute + const result = await executeReadImageTool() + + // Verify toolResultMock was called with images + expect(toolResultMock).toHaveBeenCalledTimes(1) + const callArgs = toolResultMock.mock.calls[0] + const textArg = callArgs[0] + const imagesArg = callArgs[1] + + expect(textArg).toContain(`${testImagePath}`) + expect(imagesArg).toBeDefined() // Images should be included + expect(imagesArg).toBeInstanceOf(Array) + expect(imagesArg!.length).toBe(1) + expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`) + }) + + it("should handle undefined supportsImages gracefully", async () => { + // Setup - mock API handler with undefined supportsImages + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: undefined } + }) + } + + // Execute + const result = await executeReadImageTool() + + // When supportsImages is undefined, should default to false and return just XML + expect(toolResultMock).not.toHaveBeenCalled() + expect(typeof result).toBe("string") + expect(result).toContain(`${testImagePath}`) + expect(result).toContain(`Image file`) + }) + it("should handle errors when reading image files", async () => { // Setup - simulate read error mockedFsReadFile.mockRejectedValue(new Error("Failed to read image")) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index c51f9649ee9..d25e8de00b3 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -702,12 +702,16 @@ export async function readFileTool( // Combine all images: feedback images first, then file images const allImages = [...feedbackImages, ...fileImageUrls] + // Check if the current model supports images before including them + const supportsImages = cline.api.getModel().info.supportsImages ?? false + const imagesToInclude = supportsImages ? allImages : [] + // Push the result with appropriate formatting - if (statusMessage || allImages.length > 0) { + if (statusMessage || imagesToInclude.length > 0) { // Always use formatResponse.toolResult when we have a status message or images const result = formatResponse.toolResult( statusMessage || filesXml, - allImages.length > 0 ? allImages : undefined, + imagesToInclude.length > 0 ? imagesToInclude : undefined, ) // Handle different return types from toolResult From 822a84f36a2a8436de4c219c5a9ead0b03c7aebd Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Sun, 6 Jul 2025 09:49:08 +0700 Subject: [PATCH 08/21] add i18n --- src/core/tools/__tests__/readFileTool.spec.ts | 26 +++++++++++++++++++ src/core/tools/readFileTool.ts | 4 +-- src/i18n/locales/ca/tools.json | 4 ++- src/i18n/locales/de/tools.json | 4 ++- src/i18n/locales/en/tools.json | 4 ++- src/i18n/locales/es/tools.json | 4 ++- src/i18n/locales/fr/tools.json | 4 ++- src/i18n/locales/hi/tools.json | 4 ++- src/i18n/locales/id/tools.json | 4 ++- src/i18n/locales/it/tools.json | 4 ++- src/i18n/locales/ja/tools.json | 4 ++- src/i18n/locales/ko/tools.json | 4 ++- src/i18n/locales/nl/tools.json | 4 ++- src/i18n/locales/pl/tools.json | 4 ++- src/i18n/locales/pt-BR/tools.json | 4 ++- src/i18n/locales/ru/tools.json | 4 ++- src/i18n/locales/tr/tools.json | 4 ++- src/i18n/locales/vi/tools.json | 4 ++- src/i18n/locales/zh-CN/tools.json | 4 ++- src/i18n/locales/zh-TW/tools.json | 4 ++- 20 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 5f825d8c408..b326e1663b8 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -117,6 +117,32 @@ vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockReturnValue(true), })) +// Mock i18n translation function +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string, params?: Record) => { + // Map translation keys to English text + const translations: Record = { + "tools:readFile.imageWithDimensions": "Image file ({{dimensions}}, {{size}} KB)", + "tools:readFile.imageWithSize": "Image file ({{size}} KB)", + "tools:readFile.imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", + "tools:readFile.linesRange": " (lines {{start}}-{{end}})", + "tools:readFile.definitionsOnly": " (definitions only)", + "tools:readFile.maxLines": " (max {{max}} lines)", + } + + let result = translations[key] || key + + // Simple template replacement + if (params) { + Object.entries(params).forEach(([param, value]) => { + result = result.replace(new RegExp(`{{${param}}}`, 'g'), String(value)) + }) + } + + return result + }), +})) + describe("read_file tool with maxReadFileLine setting", () => { // Test data const testFilePath = "test/file.txt" diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index d25e8de00b3..aa6b3c06c7a 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -533,8 +533,8 @@ export async function readFileTool( // Store image data URL separately - NOT in XML const noticeText = dimensionsInfo - ? `Image file (${dimensionsInfo}, ${imageSizeInKB} KB)` - : `Image file (${imageSizeInKB} KB)` + ? t("tools:readFile.imageWithDimensions", { dimensions: dimensionsInfo, size: imageSizeInKB }) + : t("tools:readFile.imageWithSize", { size: imageSizeInKB }) updateFileResult(relPath, { xmlContent: `${relPath}\n${noticeText}\n`, diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 96b97bfa7b4..a3ec48ec421 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -3,7 +3,9 @@ "linesRange": " (línies {{start}}-{{end}})", "definitionsOnly": " (només definicions)", "maxLines": " (màxim {{max}} línies)", - "imageTooLarge": "El fitxer d'imatge és massa gran ({{size}} MB). La mida màxima permesa és {{max}} MB." + "imageTooLarge": "El fitxer d'imatge és massa gran ({{size}} MB). La mida màxima permesa és {{max}} MB.", + "imageWithDimensions": "Fitxer d'imatge ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Fitxer d'imatge ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo sembla estar atrapat en un bucle, intentant la mateixa acció ({{toolName}}) repetidament. Això podria indicar un problema amb la seva estratègia actual. Considera reformular la tasca, proporcionar instruccions més específiques o guiar-lo cap a un enfocament diferent.", "codebaseSearch": { diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index 19b700ee147..9dd72ddf6db 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -3,7 +3,9 @@ "linesRange": " (Zeilen {{start}}-{{end}})", "definitionsOnly": " (nur Definitionen)", "maxLines": " (maximal {{max}} Zeilen)", - "imageTooLarge": "Die Bilddatei ist zu groß ({{size}} MB). Die maximal erlaubte Größe beträgt {{max}} MB." + "imageTooLarge": "Die Bilddatei ist zu groß ({{size}} MB). Die maximal erlaubte Größe beträgt {{max}} MB.", + "imageWithDimensions": "Bilddatei ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Bilddatei ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo scheint in einer Schleife festzustecken und versucht wiederholt dieselbe Aktion ({{toolName}}). Dies könnte auf ein Problem mit der aktuellen Strategie hindeuten. Überlege dir, die Aufgabe umzuformulieren, genauere Anweisungen zu geben oder Roo zu einem anderen Ansatz zu führen.", "codebaseSearch": { diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index fe9b6c299f1..188e4dbcf2e 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -3,7 +3,9 @@ "linesRange": " (lines {{start}}-{{end}})", "definitionsOnly": " (definitions only)", "maxLines": " (max {{max}} lines)", - "imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB." + "imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", + "imageWithDimensions": "Image file ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Image file ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo appears to be stuck in a loop, attempting the same action ({{toolName}}) repeatedly. This might indicate a problem with its current strategy. Consider rephrasing the task, providing more specific instructions, or guiding it towards a different approach.", "codebaseSearch": { diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index 410e7e1148a..f1cb00f9a59 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -3,7 +3,9 @@ "linesRange": " (líneas {{start}}-{{end}})", "definitionsOnly": " (solo definiciones)", "maxLines": " (máximo {{max}} líneas)", - "imageTooLarge": "El archivo de imagen es demasiado grande ({{size}} MB). El tamaño máximo permitido es {{max}} MB." + "imageTooLarge": "El archivo de imagen es demasiado grande ({{size}} MB). El tamaño máximo permitido es {{max}} MB.", + "imageWithDimensions": "Archivo de imagen ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Archivo de imagen ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo parece estar atrapado en un bucle, intentando la misma acción ({{toolName}}) repetidamente. Esto podría indicar un problema con su estrategia actual. Considera reformular la tarea, proporcionar instrucciones más específicas o guiarlo hacia un enfoque diferente.", "codebaseSearch": { diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index 6e3f3f05ceb..c9ff4d14c37 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -3,7 +3,9 @@ "linesRange": " (lignes {{start}}-{{end}})", "definitionsOnly": " (définitions uniquement)", "maxLines": " (max {{max}} lignes)", - "imageTooLarge": "Le fichier image est trop volumineux ({{size}} MB). La taille maximale autorisée est {{max}} MB." + "imageTooLarge": "Le fichier image est trop volumineux ({{size}} MB). La taille maximale autorisée est {{max}} MB.", + "imageWithDimensions": "Fichier image ({{dimensions}}, {{size}} Ko)", + "imageWithSize": "Fichier image ({{size}} Ko)" }, "toolRepetitionLimitReached": "Roo semble être bloqué dans une boucle, tentant la même action ({{toolName}}) de façon répétée. Cela pourrait indiquer un problème avec sa stratégie actuelle. Envisage de reformuler la tâche, de fournir des instructions plus spécifiques ou de le guider vers une approche différente.", "codebaseSearch": { diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index 24df270e663..0d262a2d084 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -3,7 +3,9 @@ "linesRange": " (पंक्तियाँ {{start}}-{{end}})", "definitionsOnly": " (केवल परिभाषाएँ)", "maxLines": " (अधिकतम {{max}} पंक्तियाँ)", - "imageTooLarge": "छवि फ़ाइल बहुत बड़ी है ({{size}} MB)। अधिकतम अनुमतित आकार {{max}} MB है।" + "imageTooLarge": "छवि फ़ाइल बहुत बड़ी है ({{size}} MB)। अधिकतम अनुमतित आकार {{max}} MB है।", + "imageWithDimensions": "छवि फ़ाइल ({{dimensions}}, {{size}} KB)", + "imageWithSize": "छवि फ़ाइल ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।", "codebaseSearch": { diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 6745bfd4743..692b2e1ac36 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -3,7 +3,9 @@ "linesRange": " (baris {{start}}-{{end}})", "definitionsOnly": " (hanya definisi)", "maxLines": " (maks {{max}} baris)", - "imageTooLarge": "File gambar terlalu besar ({{size}} MB). Ukuran maksimum yang diizinkan adalah {{max}} MB." + "imageTooLarge": "File gambar terlalu besar ({{size}} MB). Ukuran maksimum yang diizinkan adalah {{max}} MB.", + "imageWithDimensions": "File gambar ({{dimensions}}, {{size}} KB)", + "imageWithSize": "File gambar ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo tampaknya terjebak dalam loop, mencoba aksi yang sama ({{toolName}}) berulang kali. Ini mungkin menunjukkan masalah dengan strategi saat ini. Pertimbangkan untuk mengubah frasa tugas, memberikan instruksi yang lebih spesifik, atau mengarahkannya ke pendekatan yang berbeda.", "codebaseSearch": { diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index cfb631db7bb..73cedd4520f 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -3,7 +3,9 @@ "linesRange": " (righe {{start}}-{{end}})", "definitionsOnly": " (solo definizioni)", "maxLines": " (max {{max}} righe)", - "imageTooLarge": "Il file immagine è troppo grande ({{size}} MB). La dimensione massima consentita è {{max}} MB." + "imageTooLarge": "Il file immagine è troppo grande ({{size}} MB). La dimensione massima consentita è {{max}} MB.", + "imageWithDimensions": "File immagine ({{dimensions}}, {{size}} KB)", + "imageWithSize": "File immagine ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo sembra essere bloccato in un ciclo, tentando ripetutamente la stessa azione ({{toolName}}). Questo potrebbe indicare un problema con la sua strategia attuale. Considera di riformulare l'attività, fornire istruzioni più specifiche o guidarlo verso un approccio diverso.", "codebaseSearch": { diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index c70b7785943..d1af6c4ce71 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -3,7 +3,9 @@ "linesRange": " ({{start}}-{{end}}行目)", "definitionsOnly": " (定義のみ)", "maxLines": " (最大{{max}}行)", - "imageTooLarge": "画像ファイルが大きすぎます({{size}} MB)。最大許可サイズは {{max}} MB です。" + "imageTooLarge": "画像ファイルが大きすぎます({{size}} MB)。最大許可サイズは {{max}} MB です。", + "imageWithDimensions": "画像ファイル({{dimensions}}、{{size}} KB)", + "imageWithSize": "画像ファイル({{size}} KB)" }, "toolRepetitionLimitReached": "Rooが同じ操作({{toolName}})を繰り返し試みるループに陥っているようです。これは現在の方法に問題がある可能性を示しています。タスクの言い換え、より具体的な指示の提供、または別のアプローチへの誘導を検討してください。", "codebaseSearch": { diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index 6126aad34aa..f99a562bbcf 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -3,7 +3,9 @@ "linesRange": " ({{start}}-{{end}}행)", "definitionsOnly": " (정의만)", "maxLines": " (최대 {{max}}행)", - "imageTooLarge": "이미지 파일이 너무 큽니다 ({{size}} MB). 최대 허용 크기는 {{max}} MB입니다." + "imageTooLarge": "이미지 파일이 너무 큽니다 ({{size}} MB). 최대 허용 크기는 {{max}} MB입니다.", + "imageWithDimensions": "이미지 파일 ({{dimensions}}, {{size}} KB)", + "imageWithSize": "이미지 파일 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo가 같은 동작({{toolName}})을 반복적으로 시도하면서 루프에 갇힌 것 같습니다. 이는 현재 전략에 문제가 있을 수 있음을 나타냅니다. 작업을 다시 표현하거나, 더 구체적인 지침을 제공하거나, 다른 접근 방식으로 안내해 보세요.", "codebaseSearch": { diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index b36c727e078..18db1b6d9d5 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -3,7 +3,9 @@ "linesRange": " (regels {{start}}-{{end}})", "definitionsOnly": " (alleen definities)", "maxLines": " (max {{max}} regels)", - "imageTooLarge": "Afbeeldingsbestand is te groot ({{size}} MB). De maximaal toegestane grootte is {{max}} MB." + "imageTooLarge": "Afbeeldingsbestand is te groot ({{size}} MB). De maximaal toegestane grootte is {{max}} MB.", + "imageWithDimensions": "Afbeeldingsbestand ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Afbeeldingsbestand ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo lijkt vast te zitten in een lus, waarbij hij herhaaldelijk dezelfde actie ({{toolName}}) probeert. Dit kan duiden op een probleem met de huidige strategie. Overweeg de taak te herformuleren, specifiekere instructies te geven of Roo naar een andere aanpak te leiden.", "codebaseSearch": { diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index 0ddfa11f831..ab998b2d0d8 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -3,7 +3,9 @@ "linesRange": " (linie {{start}}-{{end}})", "definitionsOnly": " (tylko definicje)", "maxLines": " (maks. {{max}} linii)", - "imageTooLarge": "Plik obrazu jest zbyt duży ({{size}} MB). Maksymalny dozwolony rozmiar to {{max}} MB." + "imageTooLarge": "Plik obrazu jest zbyt duży ({{size}} MB). Maksymalny dozwolony rozmiar to {{max}} MB.", + "imageWithDimensions": "Plik obrazu ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Plik obrazu ({{size}} KB)" }, "toolRepetitionLimitReached": "Wygląda na to, że Roo utknął w pętli, wielokrotnie próbując wykonać tę samą akcję ({{toolName}}). Może to wskazywać na problem z jego obecną strategią. Rozważ przeformułowanie zadania, podanie bardziej szczegółowych instrukcji lub nakierowanie go na inne podejście.", "codebaseSearch": { diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 3f4c21e0f51..6057723cca8 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -3,7 +3,9 @@ "linesRange": " (linhas {{start}}-{{end}})", "definitionsOnly": " (apenas definições)", "maxLines": " (máx. {{max}} linhas)", - "imageTooLarge": "Arquivo de imagem é muito grande ({{size}} MB). O tamanho máximo permitido é {{max}} MB." + "imageTooLarge": "Arquivo de imagem é muito grande ({{size}} MB). O tamanho máximo permitido é {{max}} MB.", + "imageWithDimensions": "Arquivo de imagem ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Arquivo de imagem ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo parece estar preso em um loop, tentando a mesma ação ({{toolName}}) repetidamente. Isso pode indicar um problema com sua estratégia atual. Considere reformular a tarefa, fornecer instruções mais específicas ou guiá-lo para uma abordagem diferente.", "codebaseSearch": { diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index 7fff609459c..c1dddffcaec 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -3,7 +3,9 @@ "linesRange": " (строки {{start}}-{{end}})", "definitionsOnly": " (только определения)", "maxLines": " (макс. {{max}} строк)", - "imageTooLarge": "Файл изображения слишком большой ({{size}} МБ). Максимально допустимый размер {{max}} МБ." + "imageTooLarge": "Файл изображения слишком большой ({{size}} МБ). Максимально допустимый размер {{max}} МБ.", + "imageWithDimensions": "Файл изображения ({{dimensions}}, {{size}} КБ)", + "imageWithSize": "Файл изображения ({{size}} КБ)" }, "toolRepetitionLimitReached": "Похоже, что Roo застрял в цикле, многократно пытаясь выполнить одно и то же действие ({{toolName}}). Это может указывать на проблему с его текущей стратегией. Попробуйте переформулировать задачу, предоставить более конкретные инструкции или направить его к другому подходу.", "codebaseSearch": { diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index 3eccc1bb358..1462bc221c9 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -3,7 +3,9 @@ "linesRange": " (satır {{start}}-{{end}})", "definitionsOnly": " (sadece tanımlar)", "maxLines": " (maks. {{max}} satır)", - "imageTooLarge": "Görüntü dosyası çok büyük ({{size}} MB). İzin verilen maksimum boyut {{max}} MB." + "imageTooLarge": "Görüntü dosyası çok büyük ({{size}} MB). İzin verilen maksimum boyut {{max}} MB.", + "imageWithDimensions": "Görüntü dosyası ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Görüntü dosyası ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo bir döngüye takılmış gibi görünüyor, aynı eylemi ({{toolName}}) tekrar tekrar deniyor. Bu, mevcut stratejisinde bir sorun olduğunu gösterebilir. Görevi yeniden ifade etmeyi, daha spesifik talimatlar vermeyi veya onu farklı bir yaklaşıma yönlendirmeyi düşünün.", "codebaseSearch": { diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 91385942998..62ba7187ce0 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -3,7 +3,9 @@ "linesRange": " (dòng {{start}}-{{end}})", "definitionsOnly": " (chỉ định nghĩa)", "maxLines": " (tối đa {{max}} dòng)", - "imageTooLarge": "Tệp hình ảnh quá lớn ({{size}} MB). Kích thước tối đa cho phép là {{max}} MB." + "imageTooLarge": "Tệp hình ảnh quá lớn ({{size}} MB). Kích thước tối đa cho phép là {{max}} MB.", + "imageWithDimensions": "Tệp hình ảnh ({{dimensions}}, {{size}} KB)", + "imageWithSize": "Tệp hình ảnh ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo dường như đang bị mắc kẹt trong một vòng lặp, liên tục cố gắng thực hiện cùng một hành động ({{toolName}}). Điều này có thể cho thấy vấn đề với chiến lược hiện tại. Hãy cân nhắc việc diễn đạt lại nhiệm vụ, cung cấp hướng dẫn cụ thể hơn, hoặc hướng Roo theo một cách tiếp cận khác.", "codebaseSearch": { diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index d59f89ddabb..a7526cf8d17 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -3,7 +3,9 @@ "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (仅定义)", "maxLines": " (最多 {{max}} 行)", - "imageTooLarge": "图片文件过大 ({{size}} MB)。允许的最大大小为 {{max}} MB。" + "imageTooLarge": "图片文件过大 ({{size}} MB)。允许的最大大小为 {{max}} MB。", + "imageWithDimensions": "图片文件 ({{dimensions}}, {{size}} KB)", + "imageWithSize": "图片文件 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo 似乎陷入循环,反复尝试同一操作 ({{toolName}})。这可能表明当前策略存在问题。请考虑重新描述任务、提供更具体的指示或引导其尝试不同的方法。", "codebaseSearch": { diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index 0d985e7dfb6..87c3fdee7f2 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -3,7 +3,9 @@ "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (僅定義)", "maxLines": " (最多 {{max}} 行)", - "imageTooLarge": "圖片檔案過大 ({{size}} MB)。允許的最大大小為 {{max}} MB。" + "imageTooLarge": "圖片檔案過大 ({{size}} MB)。允許的最大大小為 {{max}} MB。", + "imageWithDimensions": "圖片檔案 ({{dimensions}}, {{size}} KB)", + "imageWithSize": "圖片檔案 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo 似乎陷入循環,反覆嘗試同一操作 ({{toolName}})。這可能表明目前策略存在問題。請考慮重新描述工作、提供更具體的指示或引導其嘗試不同的方法。", "codebaseSearch": { From 7ec9ae60928b7b90910e38d6a03308dc2f79f675 Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Sun, 6 Jul 2025 18:05:39 +0700 Subject: [PATCH 09/21] add max memory for total file --- packages/types/src/global-settings.ts | 1 + src/core/tools/__tests__/readFileTool.spec.ts | 701 +++++++++++++++--- src/core/tools/readFileTool.ts | 51 +- src/core/webview/ClineProvider.ts | 3 + .../webview/__tests__/ClineProvider.spec.ts | 1 + src/core/webview/webviewMessageHandler.ts | 4 + src/shared/ExtensionMessage.ts | 1 + src/shared/WebviewMessage.ts | 1 + .../settings/ContextManagementSettings.tsx | 31 + .../src/components/settings/SettingsView.tsx | 3 + .../src/context/ExtensionStateContext.tsx | 4 + .../__tests__/ExtensionStateContext.spec.tsx | 3 +- webview-ui/src/i18n/locales/ca/settings.json | 5 + webview-ui/src/i18n/locales/de/settings.json | 5 + webview-ui/src/i18n/locales/en/settings.json | 5 + webview-ui/src/i18n/locales/es/settings.json | 5 + webview-ui/src/i18n/locales/fr/settings.json | 5 + webview-ui/src/i18n/locales/hi/settings.json | 5 + webview-ui/src/i18n/locales/id/settings.json | 5 + webview-ui/src/i18n/locales/it/settings.json | 5 + webview-ui/src/i18n/locales/ja/settings.json | 5 + webview-ui/src/i18n/locales/ko/settings.json | 5 + webview-ui/src/i18n/locales/nl/settings.json | 5 + webview-ui/src/i18n/locales/pl/settings.json | 5 + .../src/i18n/locales/pt-BR/settings.json | 5 + webview-ui/src/i18n/locales/ru/settings.json | 5 + webview-ui/src/i18n/locales/tr/settings.json | 5 + webview-ui/src/i18n/locales/vi/settings.json | 5 + .../src/i18n/locales/zh-CN/settings.json | 5 + .../src/i18n/locales/zh-TW/settings.json | 5 + 30 files changed, 768 insertions(+), 126 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 734cc36c9d4..d49185c355e 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -102,6 +102,7 @@ export const globalSettingsSchema = z.object({ showRooIgnoredFiles: z.boolean().optional(), maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), + maxTotalImageMemory: z.number().optional(), terminalOutputLineLimit: z.number().optional(), terminalOutputCharacterLimit: z.number().optional(), diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index b326e1663b8..9afe35c3b64 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -117,6 +117,37 @@ vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockReturnValue(true), })) +// Global beforeEach to ensure clean mock state between all test suites +beforeEach(() => { + // NOTE: Removed vi.clearAllMocks() to prevent interference with setImageSupport calls + // Instead, individual suites clear their specific mocks to maintain isolation + + // Explicitly reset the hoisted mock implementations to prevent cross-suite pollution + toolResultMock.mockImplementation((text: string, images?: string[]) => { + if (images && images.length > 0) { + return [ + { type: "text", text }, + ...images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }), + ] + } + return text + }) + + imageBlocksMock.mockImplementation((images?: string[]) => { + return images + ? images.map((img) => { + const [header, data] = img.split(",") + const media_type = header.match(/:(.*?);/)?.[1] || "image/png" + return { type: "image", source: { type: "base64", media_type, data } } + }) + : [] + }) +}) + // Mock i18n translation function vi.mock("../../../i18n", () => ({ t: vi.fn((key: string, params?: Record) => { @@ -143,6 +174,52 @@ vi.mock("../../../i18n", () => ({ }), })) +// Shared mock setup function to ensure consistent state across all test suites +function createMockCline(): any { + const mockProvider = { + getState: vi.fn(), + deref: vi.fn().mockReturnThis(), + } + + const mockCline: any = { + cwd: "/", + task: "Test", + providerRef: mockProvider, + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + presentAssistantMessage: vi.fn(), + handleError: vi.fn().mockResolvedValue(undefined), + pushToolResult: vi.fn(), + removeClosingTag: vi.fn((tag, content) => content), + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + }, + recordToolUsage: vi.fn().mockReturnValue(undefined), + recordToolError: vi.fn().mockReturnValue(undefined), + didRejectTool: false, + // CRITICAL: Always ensure image support is enabled + api: { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages: true } + }) + } + } + + return { mockCline, mockProvider } +} + +// Helper function to set image support without affecting shared state +function setImageSupport(mockCline: any, supportsImages: boolean | undefined): void { + mockCline.api = { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages } + }) + } +} + describe("read_file tool with maxReadFileLine setting", () => { // Test data const testFilePath = "test/file.txt" @@ -160,12 +237,27 @@ describe("read_file tool with maxReadFileLine setting", () => { const mockedIsBinaryFile = vi.mocked(isBinaryFile) const mockedPathResolve = vi.mocked(path.resolve) - const mockCline: any = {} + let mockCline: any let mockProvider: any let toolResult: ToolResponse | undefined beforeEach(() => { - vi.clearAllMocks() + // Clear specific mocks (not all mocks to preserve shared state) + mockedCountFileLines.mockClear() + mockedExtractTextFromFile.mockClear() + mockedIsBinaryFile.mockClear() + mockedPathResolve.mockClear() + addLineNumbersMock.mockClear() + extractTextFromFileMock.mockClear() + toolResultMock.mockClear() + + // Use shared mock setup function + const mocks = createMockCline() + mockCline = mocks.mockCline + mockProvider = mocks.mockProvider + + // Explicitly disable image support for text file tests to prevent cross-suite pollution + setImageSupport(mockCline, false) mockedPathResolve.mockReturnValue(absoluteFilePath) mockedIsBinaryFile.mockResolvedValue(false) @@ -182,38 +274,6 @@ describe("read_file tool with maxReadFileLine setting", () => { return Promise.resolve(addLineNumbersMock(mockInputContent)) }) - mockProvider = { - getState: vi.fn(), - deref: vi.fn().mockReturnThis(), - } - - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.removeClosingTag = vi.fn((tag, content) => content) - - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - - // Setup default API handler that supports images - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages: true } - }) - } - toolResult = undefined }) @@ -235,7 +295,7 @@ describe("read_file tool with maxReadFileLine setting", () => { const maxReadFileLine = options.maxReadFileLine ?? 500 const totalLines = options.totalLines ?? 5 - mockProvider.getState.mockResolvedValue({ maxReadFileLine }) + mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageMemory: 20 }) mockedCountFileLines.mockResolvedValue(totalLines) // Reset the spy before each test @@ -428,12 +488,32 @@ describe("read_file tool XML output structure", () => { const mockedIsBinaryFile = vi.mocked(isBinaryFile) const mockedPathResolve = vi.mocked(path.resolve) - const mockCline: any = {} + let mockCline: any let mockProvider: any let toolResult: ToolResponse | undefined beforeEach(() => { - vi.clearAllMocks() + // Clear specific mocks (not all mocks to preserve shared state) + mockedCountFileLines.mockClear() + mockedExtractTextFromFile.mockClear() + mockedIsBinaryFile.mockClear() + mockedPathResolve.mockClear() + addLineNumbersMock.mockClear() + extractTextFromFileMock.mockClear() + toolResultMock.mockClear() + + // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination + fsPromises.stat.mockClear() + fsPromises.stat.mockResolvedValue({ size: 1024 }) + fsPromises.readFile.mockClear() + + // Use shared mock setup function + const mocks = createMockCline() + mockCline = mocks.mockCline + mockProvider = mocks.mockProvider + + // Explicitly enable image support for this test suite (contains image memory tests) + setImageSupport(mockCline, true) mockedPathResolve.mockReturnValue(absoluteFilePath) mockedIsBinaryFile.mockResolvedValue(false) @@ -446,37 +526,11 @@ describe("read_file tool XML output structure", () => { mockInputContent = fileContent // Setup mock provider with default maxReadFileLine - mockProvider = { - getState: vi.fn().mockResolvedValue({ maxReadFileLine: -1 }), // Default to full file read - deref: vi.fn().mockReturnThis(), - } + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Default to full file read - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() + // Add additional properties needed for XML tests mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing required parameter") - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - mockCline.didRejectTool = false - - // Mock the API handler - required for image support check - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages: true } - }) - } - toolResult = undefined }) @@ -497,7 +551,7 @@ describe("read_file tool XML output structure", () => { const isBinary = options.isBinary ?? false const validateAccess = options.validateAccess ?? true - mockProvider.getState.mockResolvedValue({ maxReadFileLine }) + mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageMemory: 20 }) mockedCountFileLines.mockResolvedValue(totalLines) mockedIsBinaryFile.mockResolvedValue(isBinary) mockCline.rooIgnoreController.validateAccess = vi.fn().mockReturnValue(validateAccess) @@ -536,7 +590,7 @@ describe("read_file tool XML output structure", () => { addLineNumbersMock(mockInputContent) return Promise.resolve(numberedContent) }) - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory // Execute const result = await executeReadFileTool() @@ -565,7 +619,7 @@ describe("read_file tool XML output structure", () => { // Setup mockedCountFileLines.mockResolvedValue(0) mockedExtractTextFromFile.mockResolvedValue("") - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory // Execute const result = await executeReadFileTool({}, { totalLines: 0 }) @@ -575,6 +629,424 @@ describe("read_file tool XML output structure", () => { `\n${testFilePath}\nFile is empty\n\n`, ) }) + + describe("Total Image Memory Limit", () => { + const testImages = [ + { path: "test/image1.png", sizeKB: 5120 }, // 5MB + { path: "test/image2.jpg", sizeKB: 10240 }, // 10MB + { path: "test/image3.gif", sizeKB: 8192 }, // 8MB + ] + + beforeEach(() => { + // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination within this suite + fsPromises.stat.mockClear() + fsPromises.readFile.mockClear() + }) + + async function executeReadMultipleImagesTool(imagePaths: string[]): Promise { + // Ensure image support is enabled before calling the tool + setImageSupport(mockCline, true) + + // Create args content for multiple files + const filesXml = imagePaths.map(path => `${path}`).join('') + const argsContent = filesXml + + const toolUse: ReadFileToolUse = { + type: "tool_use", + name: "read_file", + params: { args: argsContent }, + partial: false, + } + + let localResult: ToolResponse | undefined + await readFileTool( + mockCline, + toolUse, + mockCline.ask, + vi.fn(), + (result: ToolResponse) => { + localResult = result + }, + (_: ToolParamName, content?: string) => content ?? "", + ) + + return localResult + } + + it("should allow multiple images under the total memory limit", async () => { + // Setup required mocks (don't clear all mocks - preserve API setup) + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + + // Setup mockProvider + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory + + // Setup mockCline properties (preserve existing API) + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + setImageSupport(mockCline, true) + + // Setup - images that fit within 20MB limit + const smallImages = [ + { path: "test/small1.png", sizeKB: 2048 }, // 2MB + { path: "test/small2.jpg", sizeKB: 3072 }, // 3MB + { path: "test/small3.gif", sizeKB: 4096 }, // 4MB + ] // Total: 9MB (under 20MB limit) + + // Mock file stats for each image + fsPromises.stat = vi.fn().mockImplementation((filePath) => { + const fileName = filePath.split('/').pop() + const image = smallImages.find(img => img.path.includes(fileName.split('.')[0])) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + }) + + // Mock path.resolve for each image + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute + const result = await executeReadMultipleImagesTool(smallImages.map(img => img.path)) + + // Verify all images were processed (should be a multi-part response) + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + + // Should have text part and 3 image parts + const textPart = parts.find(p => p.type === "text")?.text + const imageParts = parts.filter(p => p.type === "image") + + expect(textPart).toBeDefined() + expect(imageParts).toHaveLength(3) + + // Verify no memory limit notices + expect(textPart).not.toContain("Total image memory would exceed") + }) + + it("should skip images that would exceed the total memory limit", async () => { + // Setup required mocks (don't clear all mocks) + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + + // Setup mockProvider + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 15, maxTotalImageMemory: 20 }) // Allow up to 15MB per image and 20MB total memory + + // Setup mockCline properties + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + setImageSupport(mockCline, true) + + // Setup - images where later ones would exceed 20MB total limit + // Each must be under 5MB per-file limit (5120KB) + const largeImages = [ + { path: "test/large1.png", sizeKB: 5017 }, // ~4.9MB + { path: "test/large2.jpg", sizeKB: 5017 }, // ~4.9MB + { path: "test/large3.gif", sizeKB: 5017 }, // ~4.9MB + { path: "test/large4.png", sizeKB: 5017 }, // ~4.9MB + { path: "test/large5.jpg", sizeKB: 5017 }, // ~4.9MB - This should be skipped (total would be ~24.5MB > 20MB) + ] + + // Mock file stats for each image + fsPromises.stat = vi.fn().mockImplementation((filePath) => { + const fileName = path.basename(filePath) + const baseName = path.parse(fileName).name + const image = largeImages.find(img => img.path.includes(baseName)) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + }) + + // Mock path.resolve for each image + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute + const result = await executeReadMultipleImagesTool(largeImages.map(img => img.path)) + + // Verify result structure - should be a mix of successful images and skipped notices + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + + const textPart = parts.find(p => p.type === "text")?.text + const imageParts = parts.filter(p => p.type === "image") + + expect(textPart).toBeDefined() + + // Debug: Show what we actually got vs expected + if (imageParts.length !== 4) { + throw new Error(`Expected 4 images, got ${imageParts.length}. Full result: ${JSON.stringify(result, null, 2)}. Text part: ${textPart}`) + } + expect(imageParts).toHaveLength(4) // First 4 images should be included (~19.6MB total) + + // Verify memory limit notice for the fifth image + expect(textPart).toContain("Total image memory would exceed 20MB limit") + expect(textPart).toContain("current: 19.6MB") // 4 * 4.9MB + expect(textPart).toContain("this file: 4.9MB") + }) + + it("should track memory usage correctly across multiple images", async () => { + // Setup mocks (don't clear all mocks) + + // Setup required mocks + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + + // Setup mockProvider + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 15, maxTotalImageMemory: 20 }) // Allow up to 15MB per image and 20MB total memory + + // Setup mockCline properties + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + setImageSupport(mockCline, true) + + // Setup - images that exactly reach the limit + const exactLimitImages = [ + { path: "test/exact1.png", sizeKB: 10240 }, // 10MB + { path: "test/exact2.jpg", sizeKB: 10240 }, // 10MB - Total exactly 20MB + { path: "test/exact3.gif", sizeKB: 1024 }, // 1MB - This should be skipped + ] + + // Mock file stats with simpler logic + fsPromises.stat = vi.fn().mockImplementation((filePath) => { + if (filePath.includes('exact1')) { + return Promise.resolve({ size: 10240 * 1024 }) // 10MB + } else if (filePath.includes('exact2')) { + return Promise.resolve({ size: 10240 * 1024 }) // 10MB + } else if (filePath.includes('exact3')) { + return Promise.resolve({ size: 1024 * 1024 }) // 1MB + } + return Promise.resolve({ size: 1024 * 1024 }) // Default 1MB + }) + + // Mock path.resolve + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute + const result = await executeReadMultipleImagesTool(exactLimitImages.map(img => img.path)) + + // Verify + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + + const textPart = parts.find(p => p.type === "text")?.text + const imageParts = parts.filter(p => p.type === "image") + + expect(imageParts).toHaveLength(2) // First 2 images should fit + expect(textPart).toContain("Total image memory would exceed 20MB limit") + expect(textPart).toContain("current: 20.0MB") // Should show exactly 20MB used + }) + + it("should handle individual image size limit and total memory limit together", async () => { + // Setup mocks (don't clear all mocks) + + // Setup required mocks + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + + // Setup mockProvider + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory + + // Setup mockCline properties (complete setup) + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + setImageSupport(mockCline, true) + + // Setup - mix of images with individual size violations and total memory issues + const mixedImages = [ + { path: "test/ok.png", sizeKB: 3072 }, // 3MB - OK + { path: "test/too-big.jpg", sizeKB: 6144 }, // 6MB - Exceeds individual 5MB limit + { path: "test/ok2.gif", sizeKB: 4096 }, // 4MB - OK individually but might exceed total + ] + + // Mock file stats + fsPromises.stat = vi.fn().mockImplementation((filePath) => { + const fileName = path.basename(filePath) + const baseName = path.parse(fileName).name + const image = mixedImages.find(img => img.path.includes(baseName)) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + }) + + // Mock provider state with 5MB individual limit + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 5, + maxTotalImageMemory: 20 + }) + + // Mock path.resolve + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute + const result = await executeReadMultipleImagesTool(mixedImages.map(img => img.path)) + + // Verify + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + + const textPart = parts.find(p => p.type === "text")?.text + const imageParts = parts.filter(p => p.type === "image") + + // Should have 2 images (ok.png and ok2.gif) + expect(imageParts).toHaveLength(2) + + // Should show individual size limit violation + expect(textPart).toContain("Image file is too large (6.0 MB). The maximum allowed size is 5 MB.") + }) + + it("should reset total memory tracking for each tool invocation", async () => { + // Setup mocks (don't clear all mocks) + + // Setup required mocks for first batch + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + + // Setup mockProvider + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) + + // Setup mockCline properties (complete setup) + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + setImageSupport(mockCline, true) + + // Setup - first call with images that use memory + const firstBatch = [{ path: "test/first.png", sizeKB: 10240 }] // 10MB + + fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024 }) + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute first batch + await executeReadMultipleImagesTool(firstBatch.map(img => img.path)) + + // Setup second batch (don't clear all mocks) + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) + + // Reset path resolving for second batch + mockedPathResolve.mockClear() + + // Re-setup mockCline properties for second batch (complete setup) + mockCline.cwd = "/" + mockCline.task = "Test" + mockCline.providerRef = mockProvider + mockCline.rooIgnoreController = { + validateAccess: vi.fn().mockReturnValue(true), + } + mockCline.say = vi.fn().mockResolvedValue(undefined) + mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.presentAssistantMessage = vi.fn() + mockCline.handleError = vi.fn().mockResolvedValue(undefined) + mockCline.pushToolResult = vi.fn() + mockCline.removeClosingTag = vi.fn((tag, content) => content) + mockCline.fileContextTracker = { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } + mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) + mockCline.recordToolError = vi.fn().mockReturnValue(undefined) + setImageSupport(mockCline, true) + + const secondBatch = [{ path: "test/second.png", sizeKB: 15360 }] // 15MB + + // Clear and reset file system mocks for second batch + fsPromises.stat.mockClear() + fsPromises.readFile.mockClear() + mockedIsBinaryFile.mockClear() + mockedCountFileLines.mockClear() + + // Reset mocks for second batch + fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 }) + fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute second batch + const result = await executeReadMultipleImagesTool(secondBatch.map(img => img.path)) + + // Verify second batch is processed successfully (memory tracking was reset) + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + const imageParts = parts.filter(p => p.type === "image") + + expect(imageParts).toHaveLength(1) // Second image should be processed + }) + }) }) describe("Error Handling Tests", () => { @@ -628,49 +1100,38 @@ describe("read_file tool with image support", () => { const mockedFsReadFile = vi.mocked(fsPromises.readFile) const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) - const mockCline: any = {} - let mockProvider: any + let localMockCline: any + let localMockProvider: any let toolResult: ToolResponse | undefined beforeEach(() => { - vi.clearAllMocks() + // Clear specific mocks (not all mocks to preserve shared state) + mockedPathResolve.mockClear() + mockedIsBinaryFile.mockClear() + mockedCountFileLines.mockClear() + mockedFsReadFile.mockClear() + mockedExtractTextFromFile.mockClear() + toolResultMock.mockClear() + + // CRITICAL: Reset fsPromises.stat to prevent cross-test contamination + fsPromises.stat.mockClear() + fsPromises.stat.mockResolvedValue({ size: 1024 }) + + // Use shared mock setup function with local variables + const mocks = createMockCline() + localMockCline = mocks.mockCline + localMockProvider = mocks.mockProvider + + // CRITICAL: Explicitly ensure image support is enabled for all tests in this suite + setImageSupport(localMockCline, true) mockedPathResolve.mockReturnValue(absoluteImagePath) mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) mockedFsReadFile.mockResolvedValue(imageBuffer) - mockProvider = { - getState: vi.fn().mockResolvedValue({ maxReadFileLine: -1 }), - deref: vi.fn().mockReturnThis(), - } - - mockCline.cwd = "/" - mockCline.task = "Test" - mockCline.providerRef = mockProvider - mockCline.rooIgnoreController = { - validateAccess: vi.fn().mockReturnValue(true), - } - mockCline.say = vi.fn().mockResolvedValue(undefined) - mockCline.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) - mockCline.presentAssistantMessage = vi.fn() - mockCline.handleError = vi.fn().mockResolvedValue(undefined) - mockCline.pushToolResult = vi.fn() - mockCline.removeClosingTag = vi.fn((tag, content) => content) - - mockCline.fileContextTracker = { - trackFileContext: vi.fn().mockResolvedValue(undefined), - } - - mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) - mockCline.recordToolError = vi.fn().mockReturnValue(undefined) - - // Setup default API handler that supports images - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages: true } - }) - } + // Setup mock provider with default maxReadFileLine + localMockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) toolResult = undefined }) @@ -684,10 +1145,14 @@ describe("read_file tool with image support", () => { partial: false, } + // Debug: Check if mock is working + console.log("Mock API:", localMockCline.api) + console.log("Supports images:", localMockCline.api?.getModel?.()?.info?.supportsImages) + await readFileTool( - mockCline, + localMockCline, toolUse, - mockCline.ask, + localMockCline.ask, vi.fn(), (result: ToolResponse) => { toolResult = result @@ -695,6 +1160,9 @@ describe("read_file tool with image support", () => { (_: ToolParamName, content?: string) => content ?? "", ) + console.log("Result type:", Array.isArray(toolResult) ? "array" : typeof toolResult) + console.log("Result:", toolResult) + return toolResult } @@ -715,6 +1183,9 @@ describe("read_file tool with image support", () => { const absolutePath = `/test/${filename}` mockedPathResolve.mockReturnValue(absolutePath) + // Ensure API mock supports images + setImageSupport(localMockCline, true) + // Execute const result = await executeReadImageTool(imagePath) @@ -828,11 +1299,7 @@ describe("read_file tool with image support", () => { it("should exclude images when model does not support images", async () => { // Setup - mock API handler that doesn't support images - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages: false } - }) - } + setImageSupport(localMockCline, false) // Execute const result = await executeReadImageTool() @@ -846,11 +1313,7 @@ describe("read_file tool with image support", () => { it("should include images when model supports images", async () => { // Setup - mock API handler that supports images - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages: true } - }) - } + setImageSupport(localMockCline, true) // Execute const result = await executeReadImageTool() @@ -870,11 +1333,7 @@ describe("read_file tool with image support", () => { it("should handle undefined supportsImages gracefully", async () => { // Setup - mock API handler with undefined supportsImages - mockCline.api = { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages: undefined } - }) - } + setImageSupport(localMockCline, undefined) // Execute const result = await executeReadImageTool() @@ -903,9 +1362,9 @@ describe("read_file tool with image support", () => { } await readFileTool( - mockCline, + localMockCline, toolUse, - mockCline.ask, + localMockCline.ask, handleErrorSpy, // Use our spy here (result: ToolResponse) => { toolResult = result diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index aa6b3c06c7a..fa55008d07e 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -21,6 +21,12 @@ import * as fs from "fs/promises" */ const DEFAULT_MAX_IMAGE_FILE_SIZE_MB = 5 +/** + * Default maximum total memory usage for all images in a single read operation (20MB) + * This prevents memory issues when reading multiple large images simultaneously + */ +const DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB = 20 + /** * Supported image formats that can be displayed */ @@ -136,6 +142,10 @@ export async function readFileTool( const legacyStartLineStr: string | undefined = block.params.start_line const legacyEndLineStr: string | undefined = block.params.end_line + // Check if the current model supports images at the beginning + const modelInfo = cline.api.getModel().info + const supportsImages = modelInfo.supportsImages ?? false + // Handle partial message first if (block.partial) { let filePath = "" @@ -473,6 +483,11 @@ export async function readFileTool( } } + // Track total image memory usage across all files + let totalImageMemoryUsed = 0 + const state = await cline.providerRef.deref()?.getState() + const { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageMemory = DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB } = state ?? {} + // Then process only approved files for (const fileResult of fileResults) { // Skip files that weren't approved @@ -482,7 +497,6 @@ export async function readFileTool( const relPath = fileResult.path const fullPath = path.resolve(cline.cwd, relPath) - const { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB } = (await cline.providerRef.deref()?.getState()) ?? {} // Process approved files try { @@ -495,10 +509,23 @@ export async function readFileTool( // Check if it's a supported image format if (SUPPORTED_IMAGE_FORMATS.includes(fileExtension as (typeof SUPPORTED_IMAGE_FORMATS)[number])) { + // Skip image processing if model doesn't support images + if (!supportsImages) { + const notice = "Image file detected but current model does not support images. Skipping image processing." + + // Track file read + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${notice}\n`, + }) + continue + } + try { const imageStats = await fs.stat(fullPath) - // Check if image file exceeds size limit + // Check if image file exceeds individual size limit if (imageStats.size > maxImageFileSize * 1024 * 1024) { const imageSizeInMB = (imageStats.size / (1024 * 1024)).toFixed(1) const notice = t("tools:readFile.imageTooLarge", { size: imageSizeInMB, max: maxImageFileSize }) @@ -512,6 +539,23 @@ export async function readFileTool( continue } + // Check if adding this image would exceed total memory limit + const imageSizeInMB = imageStats.size / (1024 * 1024) + if (totalImageMemoryUsed + imageSizeInMB > maxTotalImageMemory) { + const notice = `Image skipped to prevent memory issues. Total image memory would exceed ${maxTotalImageMemory}MB limit (current: ${totalImageMemoryUsed.toFixed(1)}MB, this file: ${imageSizeInMB.toFixed(1)}MB). Consider reading fewer images at once or reducing image sizes.` + + // Track file read + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${notice}\n`, + }) + continue + } + + // Track memory usage for this image + totalImageMemoryUsed += imageSizeInMB + const { dataUrl: imageDataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath) const imageSizeInKB = Math.round(imageStats.size / 1024) @@ -702,8 +746,7 @@ export async function readFileTool( // Combine all images: feedback images first, then file images const allImages = [...feedbackImages, ...fileImageUrls] - // Check if the current model supports images before including them - const supportsImages = cline.api.getModel().info.supportsImages ?? false + // Use the supportsImages check from the beginning of the function const imagesToInclude = supportsImages ? allImages : [] // Push the result with appropriate formatting diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2efb6d96e21..c55f68b1fa1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1426,6 +1426,7 @@ export class ClineProvider language, maxReadFileLine, maxImageFileSize, + maxTotalImageMemory, terminalCompressProgressBar, historyPreviewCollapsed, cloudUserInfo, @@ -1534,6 +1535,7 @@ export class ClineProvider renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, + maxTotalImageMemory: maxTotalImageMemory ?? 20, maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, terminalCompressProgressBar: terminalCompressProgressBar ?? true, @@ -1705,6 +1707,7 @@ export class ClineProvider showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true, maxReadFileLine: stateValues.maxReadFileLine ?? -1, maxImageFileSize: stateValues.maxImageFileSize ?? 5, + maxTotalImageMemory: stateValues.maxTotalImageMemory ?? 20, maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, cloudUserInfo, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b3991d29735..21232f66494 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -534,6 +534,7 @@ describe("ClineProvider", () => { renderContext: "sidebar", maxReadFileLine: 500, maxImageFileSize: 5, + maxTotalImageMemory: 20, cloudUserInfo: null, organizationAllowList: ORGANIZATION_ALLOW_ALL, autoCondenseContext: true, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2e1d8486426..cd309f363b2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1269,6 +1269,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("maxImageFileSize", message.value) await provider.postStateToWebview() break + case "maxTotalImageMemory": + await updateGlobalState("maxTotalImageMemory", message.value) + await provider.postStateToWebview() + break case "maxConcurrentFileReads": const valueToSave = message.value // Capture the value intended for saving await updateGlobalState("maxConcurrentFileReads", valueToSave) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 49151a7e96b..46dd8d66ad5 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -279,6 +279,7 @@ export type ExtensionState = Pick< showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings maxReadFileLine: number // Maximum number of lines to read from a file before truncating maxImageFileSize: number // Maximum size of image files to process in MB + maxTotalImageMemory: number // Maximum total memory for all images in a single read operation in MB experiments: Experiments // Map of experiment IDs to their enabled state diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 2ea42eb75ee..76add0a43f5 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -163,6 +163,7 @@ export interface WebviewMessage { | "language" | "maxReadFileLine" | "maxImageFileSize" + | "maxTotalImageMemory" | "maxConcurrentFileReads" | "includeDiagnosticMessages" | "maxDiagnosticMessages" diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index b21ad8258ae..7a207b68957 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -21,6 +21,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { showRooIgnoredFiles?: boolean maxReadFileLine?: number maxImageFileSize?: number + maxTotalImageMemory?: number maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean @@ -34,6 +35,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "showRooIgnoredFiles" | "maxReadFileLine" | "maxImageFileSize" + | "maxTotalImageMemory" | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" @@ -52,6 +54,7 @@ export const ContextManagementSettings = ({ setCachedStateField, maxReadFileLine, maxImageFileSize, + maxTotalImageMemory, maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, @@ -237,6 +240,34 @@ export const ContextManagementSettings = ({
+
+
+ {t("settings:contextManagement.maxTotalImageMemory.label")} +
+ { + const newValue = parseInt(e.target.value, 10) + if (!isNaN(newValue) && newValue >= 1 && newValue <= 500) { + setCachedStateField("maxTotalImageMemory", newValue) + } + }} + onClick={(e) => e.currentTarget.select()} + data-testid="max-total-image-memory-input" + /> + {t("settings:contextManagement.maxTotalImageMemory.mb")} +
+
+
+ {t("settings:contextManagement.maxTotalImageMemory.description")} +
+
+
(({ onDone, t remoteBrowserEnabled, maxReadFileLine, maxImageFileSize, + maxTotalImageMemory, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, @@ -323,6 +324,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles }) vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 }) vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 }) + vscode.postMessage({ type: "maxTotalImageMemory", value: maxTotalImageMemory ?? 20 }) vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 }) vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages }) vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 }) @@ -670,6 +672,7 @@ const SettingsView = forwardRef(({ onDone, t showRooIgnoredFiles={showRooIgnoredFiles} maxReadFileLine={maxReadFileLine} maxImageFileSize={maxImageFileSize} + maxTotalImageMemory={maxTotalImageMemory} maxConcurrentFileReads={maxConcurrentFileReads} profileThresholds={profileThresholds} includeDiagnosticMessages={includeDiagnosticMessages} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 3d117aa7f6b..16434c35d87 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -122,6 +122,8 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxReadFileLine: (value: number) => void maxImageFileSize: number setMaxImageFileSize: (value: number) => void + maxTotalImageMemory: number + setMaxTotalImageMemory: (value: number) => void machineId?: string pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void @@ -211,6 +213,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode renderContext: "sidebar", maxReadFileLine: -1, // Default max read file line limit maxImageFileSize: 5, // Default max image file size in MB + maxTotalImageMemory: 20, // Default max total image memory in MB pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting maxConcurrentFileReads: 5, // Default concurrent file reads @@ -452,6 +455,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })), setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), + setMaxTotalImageMemory: (value) => setState((prevState) => ({ ...prevState, maxTotalImageMemory: value })), setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })), setTerminalCompressProgressBar: (value) => setState((prevState) => ({ ...prevState, terminalCompressProgressBar: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 23f39409410..5b4874a6ffe 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -209,7 +209,8 @@ describe("mergeExtensionState", () => { sharingEnabled: false, profileThresholds: {}, hasOpenedModeSelector: false, // Add the new required property - maxImageFileSize: 5 + maxImageFileSize: 5, + maxTotalImageMemory: 20 } const prevState: ExtensionState = { diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 8f7db4cac2e..5ae9f49720d 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -531,6 +531,11 @@ "label": "Mida màxima d'arxiu d'imatge", "mb": "MB", "description": "Mida màxima (en MB) per a arxius d'imatge que poden ser processats per l'eina de lectura d'arxius." + }, + "maxTotalImageMemory": { + "label": "Memòria total màxima per a imatges", + "mb": "MB", + "description": "Memòria total màxima (en MB) per a totes les imatges en una sola operació de lectura. Prevé problemes de memòria en llegir múltiples imatges grans simultàniament." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 1937cb053c4..36cd9a339d2 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -531,6 +531,11 @@ "label": "Maximale Bilddateigröße", "mb": "MB", "description": "Maximale Größe (in MB) für Bilddateien, die vom read file Tool verarbeitet werden können." + }, + "maxTotalImageMemory": { + "label": "Maximaler Gesamtspeicher für Bilder", + "mb": "MB", + "description": "Maximaler Gesamtspeicher (in MB) für alle Bilder in einem einzelnen Lesevorgang. Verhindert Speicherprobleme beim gleichzeitigen Lesen mehrerer großer Bilder." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index c8ad99c62d9..028eecacb64 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -507,6 +507,11 @@ "mb": "MB", "description": "Maximum size (in MB) for image files that can be processed by the read file tool." }, + "maxTotalImageMemory": { + "label": "Max total image memory", + "mb": "MB", + "description": "Maximum total memory (in MB) for all images in a single read operation. Prevents memory issues when reading multiple large images simultaneously." + }, "diagnostics": { "includeMessages": { "label": "Automatically include diagnostics in context", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index e54d770991b..cccfbaa73c1 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -507,6 +507,11 @@ "mb": "MB", "description": "Tamaño máximo (en MB) para archivos de imagen que pueden ser procesados por la herramienta de lectura de archivos." }, + "maxTotalImageMemory": { + "label": "Memoria total máxima para imágenes", + "mb": "MB", + "description": "Memoria total máxima (en MB) para todas las imágenes en una sola operación de lectura. Previene problemas de memoria al leer múltiples imágenes grandes simultáneamente." + }, "diagnostics": { "includeMessages": { "label": "Incluir automáticamente diagnósticos en el contexto", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 54a1092c2fc..3f5b0e95302 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -507,6 +507,11 @@ "mb": "MB", "description": "Taille maximale (en MB) pour les fichiers d'image qui peuvent être traités par l'outil de lecture de fichier." }, + "maxTotalImageMemory": { + "label": "Mémoire totale maximale pour les images", + "mb": "MB", + "description": "Mémoire totale maximale (en MB) pour toutes les images dans une seule opération de lecture. Empêche les problèmes de mémoire lors de la lecture simultanée de plusieurs grandes images." + }, "diagnostics": { "includeMessages": { "label": "Inclure automatiquement les diagnostics dans le contexte", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 77252093ae3..69fc029eaa9 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -532,6 +532,11 @@ "label": "अधिकतम छवि फ़ाइल आकार", "mb": "MB", "description": "छवि फ़ाइलों के लिए अधिकतम आकार (MB में) जो read file tool द्वारा प्रसंस्कृत किया जा सकता है।" + }, + "maxTotalImageMemory": { + "label": "छवियों के लिए अधिकतम कुल मेमोरी", + "mb": "MB", + "description": "एक ही पठन ऑपरेशन में सभी छवियों के लिए अधिकतम कुल मेमोरी (MB में)। एक साथ कई बड़ी छवियों को पढ़ते समय मेमोरी समस्याओं को रोकता है।" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 8c6fd70432d..252a20c1cde 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -536,6 +536,11 @@ "label": "Ukuran file gambar maksimum", "mb": "MB", "description": "Ukuran maksimum (dalam MB) untuk file gambar yang dapat diproses oleh alat baca file." + }, + "maxTotalImageMemory": { + "label": "Total memori maksimum untuk gambar", + "mb": "MB", + "description": "Total memori maksimum (dalam MB) untuk semua gambar dalam satu operasi baca. Mencegah masalah memori saat membaca beberapa gambar besar secara bersamaan." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 54b29140eaf..eb6a6dfd5fd 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -532,6 +532,11 @@ "label": "Dimensione massima file immagine", "mb": "MB", "description": "Dimensione massima (in MB) per i file immagine che possono essere elaborati dallo strumento di lettura file." + }, + "maxTotalImageMemory": { + "label": "Memoria totale massima per le immagini", + "mb": "MB", + "description": "Memoria totale massima (in MB) per tutte le immagini in una singola operazione di lettura. Previene problemi di memoria durante la lettura simultanea di più immagini grandi." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index e77b8f9d23c..8e8d7dad4c1 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -532,6 +532,11 @@ "label": "最大画像ファイルサイズ", "mb": "MB", "description": "read fileツールで処理できる画像ファイルの最大サイズ(MB単位)。" + }, + "maxTotalImageMemory": { + "label": "画像の最大合計メモリ", + "mb": "MB", + "description": "単一の読み取り操作ですべての画像に使用できる最大合計メモリ(MB単位)。複数の大きな画像を同時に読み取る際のメモリ問題を防ぎます。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index d2ee15b67ff..550e35320e3 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -532,6 +532,11 @@ "label": "최대 이미지 파일 크기", "mb": "MB", "description": "read file 도구로 처리할 수 있는 이미지 파일의 최대 크기(MB 단위)입니다." + }, + "maxTotalImageMemory": { + "label": "이미지 최대 총 메모리", + "mb": "MB", + "description": "단일 읽기 작업에서 모든 이미지에 대한 최대 총 메모리(MB 단위). 여러 대용량 이미지를 동시에 읽을 때 메모리 문제를 방지합니다." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 3dd0806cbd2..9cf0434274a 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -507,6 +507,11 @@ "mb": "MB", "description": "Maximale grootte (in MB) voor afbeeldingsbestanden die kunnen worden verwerkt door de read file tool." }, + "maxTotalImageMemory": { + "label": "Maximaal totaal afbeeldingsgeheugen", + "mb": "MB", + "description": "Maximaal totaal geheugen (in MB) voor alle afbeeldingen in één leesbewerking. Voorkomt geheugenproblemen bij het gelijktijdig lezen van meerdere grote afbeeldingen." + }, "diagnostics": { "includeMessages": { "label": "Automatisch diagnostiek opnemen in context", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 37586b2ebcc..bfacaea3b3a 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -532,6 +532,11 @@ "label": "Maksymalny rozmiar pliku obrazu", "mb": "MB", "description": "Maksymalny rozmiar (w MB) plików obrazów, które mogą być przetwarzane przez narzędzie do czytania plików." + }, + "maxTotalImageMemory": { + "label": "Maksymalna całkowita pamięć dla obrazów", + "mb": "MB", + "description": "Maksymalna całkowita pamięć (w MB) dla wszystkich obrazów w jednej operacji odczytu. Zapobiega problemom z pamięcią podczas jednoczesnego odczytu wielu dużych obrazów." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 46cd6cfef36..52c6720373e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -532,6 +532,11 @@ "label": "Tamanho máximo do arquivo de imagem", "mb": "MB", "description": "Tamanho máximo (em MB) para arquivos de imagem que podem ser processados pela ferramenta de leitura de arquivos." + }, + "maxTotalImageMemory": { + "label": "Memória total máxima para imagens", + "mb": "MB", + "description": "Memória total máxima (em MB) para todas as imagens em uma única operação de leitura. Evita problemas de memória ao ler múltiplas imagens grandes simultaneamente." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index b9163e910c0..2927c30f94c 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -532,6 +532,11 @@ "label": "Максимальный размер файла изображения", "mb": "MB", "description": "Максимальный размер (в МБ) для файлов изображений, которые могут быть обработаны инструментом чтения файлов." + }, + "maxTotalImageMemory": { + "label": "Максимальная общая память для изображений", + "mb": "MB", + "description": "Максимальная общая память (в МБ) для всех изображений в одной операции чтения. Предотвращает проблемы с памятью при одновременном чтении нескольких больших изображений." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index db0e45a8353..d3c225c6464 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -532,6 +532,11 @@ "label": "Maksimum görüntü dosyası boyutu", "mb": "MB", "description": "Dosya okuma aracı tarafından işlenebilecek görüntü dosyaları için maksimum boyut (MB cinsinden)." + }, + "maxTotalImageMemory": { + "label": "Görüntüler için maksimum toplam bellek", + "mb": "MB", + "description": "Tek bir okuma işlemindeki tüm görüntüler için maksimum toplam bellek (MB cinsinden). Birden çok büyük görüntüyü eş zamanlı okurken bellek sorunlarını önler." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 2116950fd59..56b0c98859d 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -532,6 +532,11 @@ "label": "Kích thước tối đa của tệp hình ảnh", "mb": "MB", "description": "Kích thước tối đa (tính bằng MB) cho các tệp hình ảnh có thể được xử lý bởi công cụ đọc tệp." + }, + "maxTotalImageMemory": { + "label": "Bộ nhớ tổng tối đa cho hình ảnh", + "mb": "MB", + "description": "Bộ nhớ tổng tối đa (tính bằng MB) cho tất cả hình ảnh trong một hoạt động đọc. Ngăn ngừa vấn đề bộ nhớ khi đọc đồng thời nhiều hình ảnh lớn." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index b11202e2716..264d2d49333 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -532,6 +532,11 @@ "label": "最大图像文件大小", "mb": "MB", "description": "read file工具可以处理的图像文件的最大大小(以MB为单位)。" + }, + "maxTotalImageMemory": { + "label": "图像最大总内存", + "mb": "MB", + "description": "单次读取操作中所有图像的最大总内存(以MB为单位)。防止同时读取多个大图像时出现内存问题。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 023ade21ff9..be55a5de414 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -532,6 +532,11 @@ "label": "最大圖像檔案大小", "mb": "MB", "description": "read file工具可以處理的圖像檔案的最大大小(以MB為單位)。" + }, + "maxTotalImageMemory": { + "label": "圖像最大總記憶體", + "mb": "MB", + "description": "單次讀取操作中所有圖像的最大總記憶體(以MB為單位)。防止同時讀取多個大圖像時出現記憶體問題。" } }, "terminal": { From ba397ced2312c67ef1f0c726a10117d9a605cb4c Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Sun, 20 Jul 2025 01:39:13 +0700 Subject: [PATCH 10/21] refactor: consolidate MIME type mapping for image formats --- src/core/tools/readFileTool.ts | 50 ++++++++++++---------------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index fa55008d07e..07b7753cda1 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -44,6 +44,20 @@ const SUPPORTED_IMAGE_FORMATS = [ ".avif", ] as const +const IMAGE_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".avif": "image/avif", +} + /** * Reads an image file and returns both the data URL and buffer */ @@ -52,24 +66,9 @@ async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{ dataUrl const base64 = fileBuffer.toString("base64") const ext = path.extname(filePath).toLowerCase() - // Map extensions to MIME types - const mimeTypes: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".tiff": "image/tiff", - ".tif": "image/tiff", - ".avif": "image/avif", - } - - const mimeType = mimeTypes[ext] || "image/png" + const mimeType = IMAGE_MIME_TYPES[ext] || "image/png" const dataUrl = `data:${mimeType};base64,${base64}` - + return { dataUrl, buffer: fileBuffer } } @@ -559,26 +558,11 @@ export async function readFileTool( const { dataUrl: imageDataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath) const imageSizeInKB = Math.round(imageStats.size / 1024) - // For images, get dimensions if possible - let dimensionsInfo = "" - if (fileExtension === ".png") { - // Simple PNG dimension extraction (first 24 bytes contain width/height) - if (buffer.length >= 24) { - const width = buffer.readUInt32BE(16) - const height = buffer.readUInt32BE(20) - if (width && height) { - dimensionsInfo = `${width}x${height} pixels` - } - } - } - // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) // Store image data URL separately - NOT in XML - const noticeText = dimensionsInfo - ? t("tools:readFile.imageWithDimensions", { dimensions: dimensionsInfo, size: imageSizeInKB }) - : t("tools:readFile.imageWithSize", { size: imageSizeInKB }) + const noticeText = t("tools:readFile.imageWithSize", { size: imageSizeInKB }) updateFileResult(relPath, { xmlContent: `${relPath}\n${noticeText}\n`, From 802fa5dcaffa0e13383cdd2182b7c22b12226d5b Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Sun, 20 Jul 2025 01:43:11 +0700 Subject: [PATCH 11/21] fix: re-check model image support before including images in the result --- src/core/tools/readFileTool.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 07b7753cda1..9ab2f9b26ec 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -730,8 +730,9 @@ export async function readFileTool( // Combine all images: feedback images first, then file images const allImages = [...feedbackImages, ...fileImageUrls] - // Use the supportsImages check from the beginning of the function - const imagesToInclude = supportsImages ? allImages : [] + // Re-check if the model supports images before including them, in case it changed during execution. + const finalModelSupportsImages = cline.api.getModel().info.supportsImages ?? false + const imagesToInclude = finalModelSupportsImages ? allImages : [] // Push the result with appropriate formatting if (statusMessage || imagesToInclude.length > 0) { From ebca4ba33274da5542db72460e05244d61ec1d2a Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Mon, 21 Jul 2025 16:22:00 +0700 Subject: [PATCH 12/21] fix test --- src/core/tools/__tests__/readFileTool.spec.ts | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 9afe35c3b64..e8f44d35af9 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -1244,42 +1244,6 @@ describe("read_file tool with image support", () => { expect(imagesArg![0]).toBe(`data:image/png;base64,${base64ImageData}`) }) - it("should extract and display PNG dimensions correctly", async () => { - // Setup - Create a proper PNG buffer with known dimensions (100x200 pixels) - const pngSignature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) // PNG signature - const ihdrLength = Buffer.from([0x00, 0x00, 0x00, 0x0D]) // IHDR chunk length (13 bytes) - const ihdrType = Buffer.from("IHDR", "ascii") // IHDR chunk type - const width = Buffer.alloc(4) - width.writeUInt32BE(100, 0) // Width: 100 pixels - const height = Buffer.alloc(4) - height.writeUInt32BE(200, 0) // Height: 200 pixels - const ihdrData = Buffer.from([0x08, 0x02, 0x00, 0x00, 0x00]) // Bit depth, color type, compression, filter, interlace - const crc = Buffer.from([0x00, 0x00, 0x00, 0x00]) // Dummy CRC - - const pngBuffer = Buffer.concat([pngSignature, ihdrLength, ihdrType, width, height, ihdrData, crc]) - const pngBase64 = pngBuffer.toString("base64") - - mockedFsReadFile.mockResolvedValue(pngBuffer) - - // Execute - const result = await executeReadImageTool() - - // Verify result is a multi-part response - expect(Array.isArray(result)).toBe(true) - const textPart = (result as any[]).find((p) => p.type === "text")?.text - const imagePart = (result as any[]).find((p) => p.type === "image") - - // Verify text part contains dimensions - expect(textPart).toContain(`${testImagePath}`) - expect(textPart).toContain("100x200 pixels") // Should include the dimensions - expect(textPart).toContain(`Image file`) - - // Verify image part - expect(imagePart).toBeDefined() - expect(imagePart.source.media_type).toBe("image/png") - expect(imagePart.source.data).toBe(pngBase64) - }) - it("should handle large image files", async () => { // Setup - simulate a large image const largeBase64 = "A".repeat(1000000) // 1MB of base64 data From af05c5c5f4509a8a3825d201108d73b8240072b4 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Tue, 22 Jul 2025 11:05:28 -0500 Subject: [PATCH 13/21] refactor: move image processing helpers to separate file - Extract image-related constants and functions to src/core/tools/helpers/imageHelpers.ts - Keep readFileTool.ts focused on the main file reading logic - Update imports in test file to use the new helper module - All tests passing after refactoring --- src/core/tools/__tests__/readFileTool.spec.ts | 311 +++++++++++------- src/core/tools/helpers/imageHelpers.ts | 65 ++++ src/core/tools/readFileTool.ts | 81 ++--- 3 files changed, 276 insertions(+), 181 deletions(-) create mode 100644 src/core/tools/helpers/imageHelpers.ts diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index e8f44d35af9..917abdd7c88 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -10,6 +10,7 @@ import { isBinaryFile } from "isbinaryfile" import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools" import { readFileTool } from "../readFileTool" import { formatResponse } from "../../prompts/responses" +import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB } from "../helpers/imageHelpers" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -121,7 +122,7 @@ vi.mock("../../../utils/fs", () => ({ beforeEach(() => { // NOTE: Removed vi.clearAllMocks() to prevent interference with setImageSupport calls // Instead, individual suites clear their specific mocks to maintain isolation - + // Explicitly reset the hoisted mock implementations to prevent cross-suite pollution toolResultMock.mockImplementation((text: string, images?: string[]) => { if (images && images.length > 0) { @@ -136,7 +137,7 @@ beforeEach(() => { } return text }) - + imageBlocksMock.mockImplementation((images?: string[]) => { return images ? images.map((img) => { @@ -155,21 +156,22 @@ vi.mock("../../../i18n", () => ({ const translations: Record = { "tools:readFile.imageWithDimensions": "Image file ({{dimensions}}, {{size}} KB)", "tools:readFile.imageWithSize": "Image file ({{size}} KB)", - "tools:readFile.imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", + "tools:readFile.imageTooLarge": + "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", "tools:readFile.linesRange": " (lines {{start}}-{{end}})", "tools:readFile.definitionsOnly": " (definitions only)", "tools:readFile.maxLines": " (max {{max}} lines)", } - + let result = translations[key] || key - + // Simple template replacement if (params) { Object.entries(params).forEach(([param, value]) => { - result = result.replace(new RegExp(`{{${param}}}`, 'g'), String(value)) + result = result.replace(new RegExp(`{{${param}}}`, "g"), String(value)) }) } - + return result }), })) @@ -203,9 +205,9 @@ function createMockCline(): any { // CRITICAL: Always ensure image support is enabled api: { getModel: vi.fn().mockReturnValue({ - info: { supportsImages: true } - }) - } + info: { supportsImages: true }, + }), + }, } return { mockCline, mockProvider } @@ -215,8 +217,8 @@ function createMockCline(): any { function setImageSupport(mockCline: any, supportsImages: boolean | undefined): void { mockCline.api = { getModel: vi.fn().mockReturnValue({ - info: { supportsImages } - }) + info: { supportsImages }, + }), } } @@ -590,7 +592,11 @@ describe("read_file tool XML output structure", () => { addLineNumbersMock(mockInputContent) return Promise.resolve(numberedContent) }) - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageMemory: 20, + }) // Allow up to 20MB per image and total memory // Execute const result = await executeReadFileTool() @@ -619,7 +625,11 @@ describe("read_file tool XML output structure", () => { // Setup mockedCountFileLines.mockResolvedValue(0) mockedExtractTextFromFile.mockResolvedValue("") - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageMemory: 20, + }) // Allow up to 20MB per image and total memory // Execute const result = await executeReadFileTool({}, { totalLines: 0 }) @@ -629,7 +639,7 @@ describe("read_file tool XML output structure", () => { `\n${testFilePath}\nFile is empty\n\n`, ) }) - + describe("Total Image Memory Limit", () => { const testImages = [ { path: "test/image1.png", sizeKB: 5120 }, // 5MB @@ -646,18 +656,18 @@ describe("read_file tool XML output structure", () => { async function executeReadMultipleImagesTool(imagePaths: string[]): Promise { // Ensure image support is enabled before calling the tool setImageSupport(mockCline, true) - + // Create args content for multiple files - const filesXml = imagePaths.map(path => `${path}`).join('') + const filesXml = imagePaths.map((path) => `${path}`).join("") const argsContent = filesXml - + const toolUse: ReadFileToolUse = { type: "tool_use", name: "read_file", params: { args: argsContent }, partial: false, } - + let localResult: ToolResponse | undefined await readFileTool( mockCline, @@ -669,19 +679,28 @@ describe("read_file tool XML output structure", () => { }, (_: ToolParamName, content?: string) => content ?? "", ) - + return localResult } - + it("should allow multiple images under the total memory limit", async () => { // Setup required mocks (don't clear all mocks - preserve API setup) mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) - + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) + // Setup mockProvider - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory - + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageMemory: 20, + }) // Allow up to 20MB per image and total memory + // Setup mockCline properties (preserve existing API) mockCline.cwd = "/" mockCline.task = "Test" @@ -701,51 +720,60 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) - + // Setup - images that fit within 20MB limit const smallImages = [ { path: "test/small1.png", sizeKB: 2048 }, // 2MB { path: "test/small2.jpg", sizeKB: 3072 }, // 3MB { path: "test/small3.gif", sizeKB: 4096 }, // 4MB ] // Total: 9MB (under 20MB limit) - + // Mock file stats for each image fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const fileName = filePath.split('/').pop() - const image = smallImages.find(img => img.path.includes(fileName.split('.')[0])) + const fileName = filePath.split("/").pop() + const image = smallImages.find((img) => img.path.includes(fileName.split(".")[0])) return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) }) - + // Mock path.resolve for each image mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - + // Execute - const result = await executeReadMultipleImagesTool(smallImages.map(img => img.path)) - + const result = await executeReadMultipleImagesTool(smallImages.map((img) => img.path)) + // Verify all images were processed (should be a multi-part response) expect(Array.isArray(result)).toBe(true) const parts = result as any[] - + // Should have text part and 3 image parts - const textPart = parts.find(p => p.type === "text")?.text - const imageParts = parts.filter(p => p.type === "image") - + const textPart = parts.find((p) => p.type === "text")?.text + const imageParts = parts.filter((p) => p.type === "image") + expect(textPart).toBeDefined() expect(imageParts).toHaveLength(3) - + // Verify no memory limit notices expect(textPart).not.toContain("Total image memory would exceed") }) - + it("should skip images that would exceed the total memory limit", async () => { // Setup required mocks (don't clear all mocks) mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) - + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) + // Setup mockProvider - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 15, maxTotalImageMemory: 20 }) // Allow up to 15MB per image and 20MB total memory - + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 15, + maxTotalImageMemory: 20, + }) // Allow up to 15MB per image and 20MB total memory + // Setup mockCline properties mockCline.cwd = "/" mockCline.task = "Test" @@ -765,7 +793,7 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) - + // Setup - images where later ones would exceed 20MB total limit // Each must be under 5MB per-file limit (5120KB) const largeImages = [ @@ -775,53 +803,64 @@ describe("read_file tool XML output structure", () => { { path: "test/large4.png", sizeKB: 5017 }, // ~4.9MB { path: "test/large5.jpg", sizeKB: 5017 }, // ~4.9MB - This should be skipped (total would be ~24.5MB > 20MB) ] - + // Mock file stats for each image fsPromises.stat = vi.fn().mockImplementation((filePath) => { const fileName = path.basename(filePath) const baseName = path.parse(fileName).name - const image = largeImages.find(img => img.path.includes(baseName)) + const image = largeImages.find((img) => img.path.includes(baseName)) return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) }) - + // Mock path.resolve for each image mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - + // Execute - const result = await executeReadMultipleImagesTool(largeImages.map(img => img.path)) - + const result = await executeReadMultipleImagesTool(largeImages.map((img) => img.path)) + // Verify result structure - should be a mix of successful images and skipped notices expect(Array.isArray(result)).toBe(true) const parts = result as any[] - - const textPart = parts.find(p => p.type === "text")?.text - const imageParts = parts.filter(p => p.type === "image") - + + const textPart = parts.find((p) => p.type === "text")?.text + const imageParts = parts.filter((p) => p.type === "image") + expect(textPart).toBeDefined() - + // Debug: Show what we actually got vs expected if (imageParts.length !== 4) { - throw new Error(`Expected 4 images, got ${imageParts.length}. Full result: ${JSON.stringify(result, null, 2)}. Text part: ${textPart}`) + throw new Error( + `Expected 4 images, got ${imageParts.length}. Full result: ${JSON.stringify(result, null, 2)}. Text part: ${textPart}`, + ) } expect(imageParts).toHaveLength(4) // First 4 images should be included (~19.6MB total) - + // Verify memory limit notice for the fifth image expect(textPart).toContain("Total image memory would exceed 20MB limit") expect(textPart).toContain("current: 19.6MB") // 4 * 4.9MB expect(textPart).toContain("this file: 4.9MB") }) - + it("should track memory usage correctly across multiple images", async () => { // Setup mocks (don't clear all mocks) - + // Setup required mocks mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) - + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) + // Setup mockProvider - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 15, maxTotalImageMemory: 20 }) // Allow up to 15MB per image and 20MB total memory - + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 15, + maxTotalImageMemory: 20, + }) // Allow up to 15MB per image and 20MB total memory + // Setup mockCline properties mockCline.cwd = "/" mockCline.task = "Test" @@ -841,55 +880,64 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) - + // Setup - images that exactly reach the limit const exactLimitImages = [ { path: "test/exact1.png", sizeKB: 10240 }, // 10MB { path: "test/exact2.jpg", sizeKB: 10240 }, // 10MB - Total exactly 20MB { path: "test/exact3.gif", sizeKB: 1024 }, // 1MB - This should be skipped ] - + // Mock file stats with simpler logic fsPromises.stat = vi.fn().mockImplementation((filePath) => { - if (filePath.includes('exact1')) { + if (filePath.includes("exact1")) { return Promise.resolve({ size: 10240 * 1024 }) // 10MB - } else if (filePath.includes('exact2')) { + } else if (filePath.includes("exact2")) { return Promise.resolve({ size: 10240 * 1024 }) // 10MB - } else if (filePath.includes('exact3')) { + } else if (filePath.includes("exact3")) { return Promise.resolve({ size: 1024 * 1024 }) // 1MB } return Promise.resolve({ size: 1024 * 1024 }) // Default 1MB }) - + // Mock path.resolve mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - + // Execute - const result = await executeReadMultipleImagesTool(exactLimitImages.map(img => img.path)) - + const result = await executeReadMultipleImagesTool(exactLimitImages.map((img) => img.path)) + // Verify expect(Array.isArray(result)).toBe(true) const parts = result as any[] - - const textPart = parts.find(p => p.type === "text")?.text - const imageParts = parts.filter(p => p.type === "image") - + + const textPart = parts.find((p) => p.type === "text")?.text + const imageParts = parts.filter((p) => p.type === "image") + expect(imageParts).toHaveLength(2) // First 2 images should fit expect(textPart).toContain("Total image memory would exceed 20MB limit") expect(textPart).toContain("current: 20.0MB") // Should show exactly 20MB used }) - + it("should handle individual image size limit and total memory limit together", async () => { // Setup mocks (don't clear all mocks) - + // Setup required mocks mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) - + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) + // Setup mockProvider - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Allow up to 20MB per image and total memory - + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageMemory: 20, + }) // Allow up to 20MB per image and total memory + // Setup mockCline properties (complete setup) mockCline.cwd = "/" mockCline.task = "Test" @@ -909,60 +957,69 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) - + // Setup - mix of images with individual size violations and total memory issues const mixedImages = [ { path: "test/ok.png", sizeKB: 3072 }, // 3MB - OK { path: "test/too-big.jpg", sizeKB: 6144 }, // 6MB - Exceeds individual 5MB limit { path: "test/ok2.gif", sizeKB: 4096 }, // 4MB - OK individually but might exceed total ] - + // Mock file stats fsPromises.stat = vi.fn().mockImplementation((filePath) => { const fileName = path.basename(filePath) const baseName = path.parse(fileName).name - const image = mixedImages.find(img => img.path.includes(baseName)) + const image = mixedImages.find((img) => img.path.includes(baseName)) return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) }) - + // Mock provider state with 5MB individual limit mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 5, - maxTotalImageMemory: 20 + maxTotalImageMemory: 20, }) - + // Mock path.resolve mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - + // Execute - const result = await executeReadMultipleImagesTool(mixedImages.map(img => img.path)) - + const result = await executeReadMultipleImagesTool(mixedImages.map((img) => img.path)) + // Verify expect(Array.isArray(result)).toBe(true) const parts = result as any[] - - const textPart = parts.find(p => p.type === "text")?.text - const imageParts = parts.filter(p => p.type === "image") - + + const textPart = parts.find((p) => p.type === "text")?.text + const imageParts = parts.filter((p) => p.type === "image") + // Should have 2 images (ok.png and ok2.gif) expect(imageParts).toHaveLength(2) - + // Should show individual size limit violation expect(textPart).toContain("Image file is too large (6.0 MB). The maximum allowed size is 5 MB.") }) - + it("should reset total memory tracking for each tool invocation", async () => { // Setup mocks (don't clear all mocks) - + // Setup required mocks for first batch mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) - + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) + // Setup mockProvider - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) - + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageMemory: 20, + }) + // Setup mockCline properties (complete setup) mockCline.cwd = "/" mockCline.task = "Test" @@ -982,25 +1039,34 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) - + // Setup - first call with images that use memory const firstBatch = [{ path: "test/first.png", sizeKB: 10240 }] // 10MB - + fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024 }) mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - + // Execute first batch - await executeReadMultipleImagesTool(firstBatch.map(img => img.path)) - + await executeReadMultipleImagesTool(firstBatch.map((img) => img.path)) + // Setup second batch (don't clear all mocks) mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) - + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageMemory: 20, + }) + // Reset path resolving for second batch mockedPathResolve.mockClear() - + // Re-setup mockCline properties for second batch (complete setup) mockCline.cwd = "/" mockCline.task = "Test" @@ -1020,30 +1086,35 @@ describe("read_file tool XML output structure", () => { mockCline.recordToolUsage = vi.fn().mockReturnValue(undefined) mockCline.recordToolError = vi.fn().mockReturnValue(undefined) setImageSupport(mockCline, true) - + const secondBatch = [{ path: "test/second.png", sizeKB: 15360 }] // 15MB - + // Clear and reset file system mocks for second batch fsPromises.stat.mockClear() fsPromises.readFile.mockClear() mockedIsBinaryFile.mockClear() mockedCountFileLines.mockClear() - + // Reset mocks for second batch fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 }) - fsPromises.readFile.mockResolvedValue(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64")) + fsPromises.readFile.mockResolvedValue( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ), + ) mockedIsBinaryFile.mockResolvedValue(true) mockedCountFileLines.mockResolvedValue(0) mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) - + // Execute second batch - const result = await executeReadMultipleImagesTool(secondBatch.map(img => img.path)) - + const result = await executeReadMultipleImagesTool(secondBatch.map((img) => img.path)) + // Verify second batch is processed successfully (memory tracking was reset) expect(Array.isArray(result)).toBe(true) const parts = result as any[] - const imageParts = parts.filter(p => p.type === "image") - + const imageParts = parts.filter((p) => p.type === "image") + expect(imageParts).toHaveLength(1) // Second image should be processed }) }) diff --git a/src/core/tools/helpers/imageHelpers.ts b/src/core/tools/helpers/imageHelpers.ts new file mode 100644 index 00000000000..dfe93ae5854 --- /dev/null +++ b/src/core/tools/helpers/imageHelpers.ts @@ -0,0 +1,65 @@ +import path from "path" +import * as fs from "fs/promises" + +/** + * Default maximum allowed image file size in bytes (5MB) + */ +export const DEFAULT_MAX_IMAGE_FILE_SIZE_MB = 5 + +/** + * Default maximum total memory usage for all images in a single read operation (20MB) + * This prevents memory issues when reading multiple large images simultaneously + */ +export const DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB = 20 + +/** + * Supported image formats that can be displayed + */ +export const SUPPORTED_IMAGE_FORMATS = [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".bmp", + ".ico", + ".tiff", + ".tif", + ".avif", +] as const + +export const IMAGE_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", + ".tiff": "image/tiff", + ".tif": "image/tiff", + ".avif": "image/avif", +} + +/** + * Reads an image file and returns both the data URL and buffer + */ +export async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{ dataUrl: string; buffer: Buffer }> { + const fileBuffer = await fs.readFile(filePath) + const base64 = fileBuffer.toString("base64") + const ext = path.extname(filePath).toLowerCase() + + const mimeType = IMAGE_MIME_TYPES[ext] || "image/png" + const dataUrl = `data:${mimeType};base64,${base64}` + + return { dataUrl, buffer: fileBuffer } +} + +/** + * Checks if a file extension is a supported image format + */ +export function isSupportedImageFormat(extension: string): boolean { + return SUPPORTED_IMAGE_FORMATS.includes(extension.toLowerCase() as (typeof SUPPORTED_IMAGE_FORMATS)[number]) +} diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 9ab2f9b26ec..b4ed3338d3b 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -15,62 +15,13 @@ import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from " import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { parseXml } from "../../utils/xml" import * as fs from "fs/promises" - -/** - * Default maximum allowed image file size in bytes (5MB) - */ -const DEFAULT_MAX_IMAGE_FILE_SIZE_MB = 5 - -/** - * Default maximum total memory usage for all images in a single read operation (20MB) - * This prevents memory issues when reading multiple large images simultaneously - */ -const DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB = 20 - -/** - * Supported image formats that can be displayed - */ -const SUPPORTED_IMAGE_FORMATS = [ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".svg", - ".bmp", - ".ico", - ".tiff", - ".tif", - ".avif", -] as const - -const IMAGE_MIME_TYPES: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".tiff": "image/tiff", - ".tif": "image/tiff", - ".avif": "image/avif", -} - -/** - * Reads an image file and returns both the data URL and buffer - */ -async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{ dataUrl: string; buffer: Buffer }> { - const fileBuffer = await fs.readFile(filePath) - const base64 = fileBuffer.toString("base64") - const ext = path.extname(filePath).toLowerCase() - - const mimeType = IMAGE_MIME_TYPES[ext] || "image/png" - const dataUrl = `data:${mimeType};base64,${base64}` - - return { dataUrl, buffer: fileBuffer } -} +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB, + SUPPORTED_IMAGE_FORMATS, + readImageAsDataUrlWithBuffer, + isSupportedImageFormat, +} from "./helpers/imageHelpers" export function getReadFileToolDescription(blockName: string, blockParams: any): string { // Handle both single path and multiple files via args @@ -485,7 +436,11 @@ export async function readFileTool( // Track total image memory usage across all files let totalImageMemoryUsed = 0 const state = await cline.providerRef.deref()?.getState() - const { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageMemory = DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB } = state ?? {} + const { + maxReadFileLine = -1, + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageMemory = DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB, + } = state ?? {} // Then process only approved files for (const fileResult of fileResults) { @@ -507,11 +462,12 @@ export async function readFileTool( const supportedBinaryFormats = getSupportedBinaryFormats() // Check if it's a supported image format - if (SUPPORTED_IMAGE_FORMATS.includes(fileExtension as (typeof SUPPORTED_IMAGE_FORMATS)[number])) { + if (isSupportedImageFormat(fileExtension)) { // Skip image processing if model doesn't support images if (!supportsImages) { - const notice = "Image file detected but current model does not support images. Skipping image processing." - + const notice = + "Image file detected but current model does not support images. Skipping image processing." + // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) @@ -527,7 +483,10 @@ export async function readFileTool( // Check if image file exceeds individual size limit if (imageStats.size > maxImageFileSize * 1024 * 1024) { const imageSizeInMB = (imageStats.size / (1024 * 1024)).toFixed(1) - const notice = t("tools:readFile.imageTooLarge", { size: imageSizeInMB, max: maxImageFileSize }) + const notice = t("tools:readFile.imageTooLarge", { + size: imageSizeInMB, + max: maxImageFileSize, + }) // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) From d018c30d1c4586776bff4a4a7a8377d0d43cad12 Mon Sep 17 00:00:00 2001 From: Sam Hoang Van Date: Wed, 23 Jul 2025 23:30:45 +0700 Subject: [PATCH 14/21] Update src/core/tools/readFileTool.ts Co-authored-by: Daniel <57051444+daniel-lxs@users.noreply.github.com> --- src/core/tools/readFileTool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index b4ed3338d3b..19c3941b42f 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -500,7 +500,7 @@ export async function readFileTool( // Check if adding this image would exceed total memory limit const imageSizeInMB = imageStats.size / (1024 * 1024) if (totalImageMemoryUsed + imageSizeInMB > maxTotalImageMemory) { - const notice = `Image skipped to prevent memory issues. Total image memory would exceed ${maxTotalImageMemory}MB limit (current: ${totalImageMemoryUsed.toFixed(1)}MB, this file: ${imageSizeInMB.toFixed(1)}MB). Consider reading fewer images at once or reducing image sizes.` + const notice = `Image skipped to avoid memory limit (${maxTotalImageMemory}MB). Current: ${totalImageMemoryUsed.toFixed(1)}MB + this file: ${imageSizeInMB.toFixed(1)}MB. Try fewer or smaller images.`; // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) From 1dc79cecb8f2649eed7053d918e94dd561c95f6c Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Fri, 25 Jul 2025 23:32:36 +0700 Subject: [PATCH 15/21] test: enhance image memory limit checks and add new test for skipping images --- src/core/tools/__tests__/readFileTool.spec.ts | 72 +++++++++++++++---- 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 917abdd7c88..a324892ba4b 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -489,6 +489,11 @@ describe("read_file tool XML output structure", () => { const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) const mockedIsBinaryFile = vi.mocked(isBinaryFile) const mockedPathResolve = vi.mocked(path.resolve) + const mockedFsReadFile = vi.mocked(fsPromises.readFile) + const imageBuffer = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ) let mockCline: any let mockProvider: any @@ -679,6 +684,11 @@ describe("read_file tool XML output structure", () => { }, (_: ToolParamName, content?: string) => content ?? "", ) + // In multi-image scenarios, the result is pushed to pushToolResult, not returned directly. + // We need to check the mock's calls to get the result. + if (mockCline.pushToolResult.mock.calls.length > 0) { + return mockCline.pushToolResult.mock.calls[0][0] + } return localResult } @@ -822,8 +832,8 @@ describe("read_file tool XML output structure", () => { expect(Array.isArray(result)).toBe(true) const parts = result as any[] - const textPart = parts.find((p) => p.type === "text")?.text - const imageParts = parts.filter((p) => p.type === "image") + const textPart = Array.isArray(result) ? result.find((p) => p.type === "text")?.text : result + const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : [] expect(textPart).toBeDefined() @@ -836,9 +846,9 @@ describe("read_file tool XML output structure", () => { expect(imageParts).toHaveLength(4) // First 4 images should be included (~19.6MB total) // Verify memory limit notice for the fifth image - expect(textPart).toContain("Total image memory would exceed 20MB limit") - expect(textPart).toContain("current: 19.6MB") // 4 * 4.9MB - expect(textPart).toContain("this file: 4.9MB") + expect(textPart).toContain( + "Image skipped to avoid memory limit (20MB). Current: 19.6MB + this file: 4.9MB. Try fewer or smaller images.", + ) }) it("should track memory usage correctly across multiple images", async () => { @@ -907,15 +917,13 @@ describe("read_file tool XML output structure", () => { const result = await executeReadMultipleImagesTool(exactLimitImages.map((img) => img.path)) // Verify - expect(Array.isArray(result)).toBe(true) - const parts = result as any[] - - const textPart = parts.find((p) => p.type === "text")?.text - const imageParts = parts.filter((p) => p.type === "image") + const textPart = Array.isArray(result) ? result.find((p) => p.type === "text")?.text : result + const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : [] expect(imageParts).toHaveLength(2) // First 2 images should fit - expect(textPart).toContain("Total image memory would exceed 20MB limit") - expect(textPart).toContain("current: 20.0MB") // Should show exactly 20MB used + expect(textPart).toContain( + "Image skipped to avoid memory limit (20MB). Current: 20.0MB + this file: 1.0MB. Try fewer or smaller images.", + ) }) it("should handle individual image size limit and total memory limit together", async () => { @@ -1000,6 +1008,46 @@ describe("read_file tool XML output structure", () => { expect(textPart).toContain("Image file is too large (6.0 MB). The maximum allowed size is 5 MB.") }) + it("should correctly calculate total memory and skip the last image", async () => { + // Setup + const testImages = [ + { path: "test/image1.png", sizeMB: 8 }, + { path: "test/image2.png", sizeMB: 8 }, + { path: "test/image3.png", sizeMB: 8 }, // This one should be skipped + ] + + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 10, // 10MB per image + maxTotalImageMemory: 20, // 20MB total + }) + + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + mockedFsReadFile.mockResolvedValue(imageBuffer) + + fsPromises.stat.mockImplementation(async (filePath) => { + const file = testImages.find((f) => filePath.toString().includes(f.path)) + if (file) { + return { size: file.sizeMB * 1024 * 1024 } + } + return { size: 1024 * 1024 } // Default 1MB + }) + + const imagePaths = testImages.map((img) => img.path) + const result = await executeReadMultipleImagesTool(imagePaths) + + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + const textPart = parts.find((p) => p.type === "text")?.text + const imageParts = parts.filter((p) => p.type === "image") + + expect(imageParts).toHaveLength(2) // First two images should be processed + expect(textPart).toContain("Image skipped to avoid memory limit (20MB)") + expect(textPart).toContain("Current: 16.0MB") + expect(textPart).toContain("this file: 8.0MB") + }) + it("should reset total memory tracking for each tool invocation", async () => { // Setup mocks (don't clear all mocks) From 0fbdb9e2872940b6f5834e2fbf04e3311d8d2400 Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Fri, 25 Jul 2025 23:58:26 +0700 Subject: [PATCH 16/21] remove unuse i18n --- src/core/tools/__tests__/readFileTool.spec.ts | 1 - src/i18n/locales/ca/tools.json | 1 - src/i18n/locales/de/tools.json | 1 - src/i18n/locales/en/tools.json | 1 - src/i18n/locales/es/tools.json | 1 - src/i18n/locales/fr/tools.json | 1 - src/i18n/locales/hi/tools.json | 1 - src/i18n/locales/id/tools.json | 1 - src/i18n/locales/it/tools.json | 1 - src/i18n/locales/ja/tools.json | 1 - src/i18n/locales/ko/tools.json | 1 - src/i18n/locales/nl/tools.json | 1 - src/i18n/locales/pl/tools.json | 1 - src/i18n/locales/pt-BR/tools.json | 1 - src/i18n/locales/ru/tools.json | 1 - src/i18n/locales/tr/tools.json | 1 - src/i18n/locales/vi/tools.json | 1 - src/i18n/locales/zh-CN/tools.json | 1 - src/i18n/locales/zh-TW/tools.json | 1 - 19 files changed, 19 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index a324892ba4b..b57797dfee8 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -154,7 +154,6 @@ vi.mock("../../../i18n", () => ({ t: vi.fn((key: string, params?: Record) => { // Map translation keys to English text const translations: Record = { - "tools:readFile.imageWithDimensions": "Image file ({{dimensions}}, {{size}} KB)", "tools:readFile.imageWithSize": "Image file ({{size}} KB)", "tools:readFile.imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index a3ec48ec421..0f10b6fc2a1 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (només definicions)", "maxLines": " (màxim {{max}} línies)", "imageTooLarge": "El fitxer d'imatge és massa gran ({{size}} MB). La mida màxima permesa és {{max}} MB.", - "imageWithDimensions": "Fitxer d'imatge ({{dimensions}}, {{size}} KB)", "imageWithSize": "Fitxer d'imatge ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo sembla estar atrapat en un bucle, intentant la mateixa acció ({{toolName}}) repetidament. Això podria indicar un problema amb la seva estratègia actual. Considera reformular la tasca, proporcionar instruccions més específiques o guiar-lo cap a un enfocament diferent.", diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index 9dd72ddf6db..ecf372a50bf 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (nur Definitionen)", "maxLines": " (maximal {{max}} Zeilen)", "imageTooLarge": "Die Bilddatei ist zu groß ({{size}} MB). Die maximal erlaubte Größe beträgt {{max}} MB.", - "imageWithDimensions": "Bilddatei ({{dimensions}}, {{size}} KB)", "imageWithSize": "Bilddatei ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo scheint in einer Schleife festzustecken und versucht wiederholt dieselbe Aktion ({{toolName}}). Dies könnte auf ein Problem mit der aktuellen Strategie hindeuten. Überlege dir, die Aufgabe umzuformulieren, genauere Anweisungen zu geben oder Roo zu einem anderen Ansatz zu führen.", diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index 188e4dbcf2e..5b88affae6f 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (definitions only)", "maxLines": " (max {{max}} lines)", "imageTooLarge": "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", - "imageWithDimensions": "Image file ({{dimensions}}, {{size}} KB)", "imageWithSize": "Image file ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo appears to be stuck in a loop, attempting the same action ({{toolName}}) repeatedly. This might indicate a problem with its current strategy. Consider rephrasing the task, providing more specific instructions, or guiding it towards a different approach.", diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index f1cb00f9a59..6fd1cc21222 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (solo definiciones)", "maxLines": " (máximo {{max}} líneas)", "imageTooLarge": "El archivo de imagen es demasiado grande ({{size}} MB). El tamaño máximo permitido es {{max}} MB.", - "imageWithDimensions": "Archivo de imagen ({{dimensions}}, {{size}} KB)", "imageWithSize": "Archivo de imagen ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo parece estar atrapado en un bucle, intentando la misma acción ({{toolName}}) repetidamente. Esto podría indicar un problema con su estrategia actual. Considera reformular la tarea, proporcionar instrucciones más específicas o guiarlo hacia un enfoque diferente.", diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index c9ff4d14c37..b6d7accebb9 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (définitions uniquement)", "maxLines": " (max {{max}} lignes)", "imageTooLarge": "Le fichier image est trop volumineux ({{size}} MB). La taille maximale autorisée est {{max}} MB.", - "imageWithDimensions": "Fichier image ({{dimensions}}, {{size}} Ko)", "imageWithSize": "Fichier image ({{size}} Ko)" }, "toolRepetitionLimitReached": "Roo semble être bloqué dans une boucle, tentant la même action ({{toolName}}) de façon répétée. Cela pourrait indiquer un problème avec sa stratégie actuelle. Envisage de reformuler la tâche, de fournir des instructions plus spécifiques ou de le guider vers une approche différente.", diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index 0d262a2d084..cbfbd7aef70 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (केवल परिभाषाएँ)", "maxLines": " (अधिकतम {{max}} पंक्तियाँ)", "imageTooLarge": "छवि फ़ाइल बहुत बड़ी है ({{size}} MB)। अधिकतम अनुमतित आकार {{max}} MB है।", - "imageWithDimensions": "छवि फ़ाइल ({{dimensions}}, {{size}} KB)", "imageWithSize": "छवि फ़ाइल ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।", diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 692b2e1ac36..3eb8854eff0 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (hanya definisi)", "maxLines": " (maks {{max}} baris)", "imageTooLarge": "File gambar terlalu besar ({{size}} MB). Ukuran maksimum yang diizinkan adalah {{max}} MB.", - "imageWithDimensions": "File gambar ({{dimensions}}, {{size}} KB)", "imageWithSize": "File gambar ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo tampaknya terjebak dalam loop, mencoba aksi yang sama ({{toolName}}) berulang kali. Ini mungkin menunjukkan masalah dengan strategi saat ini. Pertimbangkan untuk mengubah frasa tugas, memberikan instruksi yang lebih spesifik, atau mengarahkannya ke pendekatan yang berbeda.", diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index 73cedd4520f..35b114a7198 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (solo definizioni)", "maxLines": " (max {{max}} righe)", "imageTooLarge": "Il file immagine è troppo grande ({{size}} MB). La dimensione massima consentita è {{max}} MB.", - "imageWithDimensions": "File immagine ({{dimensions}}, {{size}} KB)", "imageWithSize": "File immagine ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo sembra essere bloccato in un ciclo, tentando ripetutamente la stessa azione ({{toolName}}). Questo potrebbe indicare un problema con la sua strategia attuale. Considera di riformulare l'attività, fornire istruzioni più specifiche o guidarlo verso un approccio diverso.", diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index d1af6c4ce71..257d5aa2013 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (定義のみ)", "maxLines": " (最大{{max}}行)", "imageTooLarge": "画像ファイルが大きすぎます({{size}} MB)。最大許可サイズは {{max}} MB です。", - "imageWithDimensions": "画像ファイル({{dimensions}}、{{size}} KB)", "imageWithSize": "画像ファイル({{size}} KB)" }, "toolRepetitionLimitReached": "Rooが同じ操作({{toolName}})を繰り返し試みるループに陥っているようです。これは現在の方法に問題がある可能性を示しています。タスクの言い換え、より具体的な指示の提供、または別のアプローチへの誘導を検討してください。", diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index f99a562bbcf..94b6d8c3770 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (정의만)", "maxLines": " (최대 {{max}}행)", "imageTooLarge": "이미지 파일이 너무 큽니다 ({{size}} MB). 최대 허용 크기는 {{max}} MB입니다.", - "imageWithDimensions": "이미지 파일 ({{dimensions}}, {{size}} KB)", "imageWithSize": "이미지 파일 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo가 같은 동작({{toolName}})을 반복적으로 시도하면서 루프에 갇힌 것 같습니다. 이는 현재 전략에 문제가 있을 수 있음을 나타냅니다. 작업을 다시 표현하거나, 더 구체적인 지침을 제공하거나, 다른 접근 방식으로 안내해 보세요.", diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index 18db1b6d9d5..449cd545837 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (alleen definities)", "maxLines": " (max {{max}} regels)", "imageTooLarge": "Afbeeldingsbestand is te groot ({{size}} MB). De maximaal toegestane grootte is {{max}} MB.", - "imageWithDimensions": "Afbeeldingsbestand ({{dimensions}}, {{size}} KB)", "imageWithSize": "Afbeeldingsbestand ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo lijkt vast te zitten in een lus, waarbij hij herhaaldelijk dezelfde actie ({{toolName}}) probeert. Dit kan duiden op een probleem met de huidige strategie. Overweeg de taak te herformuleren, specifiekere instructies te geven of Roo naar een andere aanpak te leiden.", diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index ab998b2d0d8..979b2f54ae0 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (tylko definicje)", "maxLines": " (maks. {{max}} linii)", "imageTooLarge": "Plik obrazu jest zbyt duży ({{size}} MB). Maksymalny dozwolony rozmiar to {{max}} MB.", - "imageWithDimensions": "Plik obrazu ({{dimensions}}, {{size}} KB)", "imageWithSize": "Plik obrazu ({{size}} KB)" }, "toolRepetitionLimitReached": "Wygląda na to, że Roo utknął w pętli, wielokrotnie próbując wykonać tę samą akcję ({{toolName}}). Może to wskazywać na problem z jego obecną strategią. Rozważ przeformułowanie zadania, podanie bardziej szczegółowych instrukcji lub nakierowanie go na inne podejście.", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 6057723cca8..4e3296fd4a6 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (apenas definições)", "maxLines": " (máx. {{max}} linhas)", "imageTooLarge": "Arquivo de imagem é muito grande ({{size}} MB). O tamanho máximo permitido é {{max}} MB.", - "imageWithDimensions": "Arquivo de imagem ({{dimensions}}, {{size}} KB)", "imageWithSize": "Arquivo de imagem ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo parece estar preso em um loop, tentando a mesma ação ({{toolName}}) repetidamente. Isso pode indicar um problema com sua estratégia atual. Considere reformular a tarefa, fornecer instruções mais específicas ou guiá-lo para uma abordagem diferente.", diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index c1dddffcaec..d74918f058e 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (только определения)", "maxLines": " (макс. {{max}} строк)", "imageTooLarge": "Файл изображения слишком большой ({{size}} МБ). Максимально допустимый размер {{max}} МБ.", - "imageWithDimensions": "Файл изображения ({{dimensions}}, {{size}} КБ)", "imageWithSize": "Файл изображения ({{size}} КБ)" }, "toolRepetitionLimitReached": "Похоже, что Roo застрял в цикле, многократно пытаясь выполнить одно и то же действие ({{toolName}}). Это может указывать на проблему с его текущей стратегией. Попробуйте переформулировать задачу, предоставить более конкретные инструкции или направить его к другому подходу.", diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index 1462bc221c9..5341a23cb1d 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (sadece tanımlar)", "maxLines": " (maks. {{max}} satır)", "imageTooLarge": "Görüntü dosyası çok büyük ({{size}} MB). İzin verilen maksimum boyut {{max}} MB.", - "imageWithDimensions": "Görüntü dosyası ({{dimensions}}, {{size}} KB)", "imageWithSize": "Görüntü dosyası ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo bir döngüye takılmış gibi görünüyor, aynı eylemi ({{toolName}}) tekrar tekrar deniyor. Bu, mevcut stratejisinde bir sorun olduğunu gösterebilir. Görevi yeniden ifade etmeyi, daha spesifik talimatlar vermeyi veya onu farklı bir yaklaşıma yönlendirmeyi düşünün.", diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index 62ba7187ce0..4c5080a1463 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (chỉ định nghĩa)", "maxLines": " (tối đa {{max}} dòng)", "imageTooLarge": "Tệp hình ảnh quá lớn ({{size}} MB). Kích thước tối đa cho phép là {{max}} MB.", - "imageWithDimensions": "Tệp hình ảnh ({{dimensions}}, {{size}} KB)", "imageWithSize": "Tệp hình ảnh ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo dường như đang bị mắc kẹt trong một vòng lặp, liên tục cố gắng thực hiện cùng một hành động ({{toolName}}). Điều này có thể cho thấy vấn đề với chiến lược hiện tại. Hãy cân nhắc việc diễn đạt lại nhiệm vụ, cung cấp hướng dẫn cụ thể hơn, hoặc hướng Roo theo một cách tiếp cận khác.", diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index a7526cf8d17..c0c93d84366 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (仅定义)", "maxLines": " (最多 {{max}} 行)", "imageTooLarge": "图片文件过大 ({{size}} MB)。允许的最大大小为 {{max}} MB。", - "imageWithDimensions": "图片文件 ({{dimensions}}, {{size}} KB)", "imageWithSize": "图片文件 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo 似乎陷入循环,反复尝试同一操作 ({{toolName}})。这可能表明当前策略存在问题。请考虑重新描述任务、提供更具体的指示或引导其尝试不同的方法。", diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index 87c3fdee7f2..b736448c20a 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -4,7 +4,6 @@ "definitionsOnly": " (僅定義)", "maxLines": " (最多 {{max}} 行)", "imageTooLarge": "圖片檔案過大 ({{size}} MB)。允許的最大大小為 {{max}} MB。", - "imageWithDimensions": "圖片檔案 ({{dimensions}}, {{size}} KB)", "imageWithSize": "圖片檔案 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo 似乎陷入循環,反覆嘗試同一操作 ({{toolName}})。這可能表明目前策略存在問題。請考慮重新描述工作、提供更具體的指示或引導其嘗試不同的方法。", From 3038800d9b3b9b6a162459120ddad4f27874da35 Mon Sep 17 00:00:00 2001 From: Sam Hoang Date: Sat, 26 Jul 2025 15:39:55 +0700 Subject: [PATCH 17/21] fix test fail --- src/core/tools/__tests__/readFileTool.spec.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index b57797dfee8..7db587a85d9 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -739,8 +739,8 @@ describe("read_file tool XML output structure", () => { // Mock file stats for each image fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const fileName = filePath.split("/").pop() - const image = smallImages.find((img) => img.path.includes(fileName.split(".")[0])) + const normalizedFilePath = path.normalize(filePath.toString()) + const image = smallImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) }) @@ -815,9 +815,8 @@ describe("read_file tool XML output structure", () => { // Mock file stats for each image fsPromises.stat = vi.fn().mockImplementation((filePath) => { - const fileName = path.basename(filePath) - const baseName = path.parse(fileName).name - const image = largeImages.find((img) => img.path.includes(baseName)) + const normalizedFilePath = path.normalize(filePath.toString()) + const image = largeImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) }) @@ -899,12 +898,10 @@ describe("read_file tool XML output structure", () => { // Mock file stats with simpler logic fsPromises.stat = vi.fn().mockImplementation((filePath) => { - if (filePath.includes("exact1")) { - return Promise.resolve({ size: 10240 * 1024 }) // 10MB - } else if (filePath.includes("exact2")) { - return Promise.resolve({ size: 10240 * 1024 }) // 10MB - } else if (filePath.includes("exact3")) { - return Promise.resolve({ size: 1024 * 1024 }) // 1MB + const normalizedFilePath = path.normalize(filePath.toString()) + const image = exactLimitImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) + if (image) { + return Promise.resolve({ size: image.sizeKB * 1024 }) } return Promise.resolve({ size: 1024 * 1024 }) // Default 1MB }) @@ -1026,7 +1023,8 @@ describe("read_file tool XML output structure", () => { mockedFsReadFile.mockResolvedValue(imageBuffer) fsPromises.stat.mockImplementation(async (filePath) => { - const file = testImages.find((f) => filePath.toString().includes(f.path)) + const normalizedFilePath = path.normalize(filePath.toString()) + const file = testImages.find((f) => normalizedFilePath.includes(path.normalize(f.path))) if (file) { return { size: file.sizeMB * 1024 * 1024 } } From 8b439625907ab9d813fbc3f3b755f589d46346b5 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Mon, 28 Jul 2025 11:30:33 -0500 Subject: [PATCH 18/21] feat: address PR review comments - Replace maxTotalImageMemory with maxTotalImageSize throughout codebase - Update all translation files with new terminology - Fix incorrect key names in pt-BR, zh-CN, and zh-TW translations - Improve clarity of size limit messages - Update tests to reflect new terminology --- packages/types/src/global-settings.ts | 2 +- src/core/tools/__tests__/readFileTool.spec.ts | 187 +++++++++++++++--- src/core/tools/helpers/imageHelpers.ts | 7 +- src/core/tools/readFileTool.ts | 16 +- src/core/webview/ClineProvider.ts | 6 +- src/core/webview/webviewMessageHandler.ts | 4 +- src/shared/ExtensionMessage.ts | 2 +- src/shared/WebviewMessage.ts | 2 +- .../settings/ContextManagementSettings.tsx | 18 +- .../src/components/settings/SettingsView.tsx | 6 +- .../src/context/ExtensionStateContext.tsx | 8 +- webview-ui/src/i18n/locales/ca/settings.json | 6 +- webview-ui/src/i18n/locales/de/settings.json | 6 +- webview-ui/src/i18n/locales/en/settings.json | 6 +- webview-ui/src/i18n/locales/es/settings.json | 6 +- webview-ui/src/i18n/locales/fr/settings.json | 6 +- webview-ui/src/i18n/locales/hi/settings.json | 6 +- webview-ui/src/i18n/locales/id/settings.json | 6 +- webview-ui/src/i18n/locales/it/settings.json | 6 +- webview-ui/src/i18n/locales/ja/settings.json | 6 +- webview-ui/src/i18n/locales/ko/settings.json | 6 +- webview-ui/src/i18n/locales/nl/settings.json | 6 +- webview-ui/src/i18n/locales/pl/settings.json | 6 +- .../src/i18n/locales/pt-BR/settings.json | 6 +- webview-ui/src/i18n/locales/ru/settings.json | 8 +- webview-ui/src/i18n/locales/tr/settings.json | 6 +- webview-ui/src/i18n/locales/vi/settings.json | 6 +- .../src/i18n/locales/zh-CN/settings.json | 6 +- .../src/i18n/locales/zh-TW/settings.json | 6 +- 29 files changed, 249 insertions(+), 119 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index d49185c355e..dc5a9e67449 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -102,7 +102,7 @@ export const globalSettingsSchema = z.object({ showRooIgnoredFiles: z.boolean().optional(), maxReadFileLine: z.number().optional(), maxImageFileSize: z.number().optional(), - maxTotalImageMemory: z.number().optional(), + maxTotalImageSize: z.number().optional(), terminalOutputLineLimit: z.number().optional(), terminalOutputCharacterLimit: z.number().optional(), diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 7db587a85d9..7ba822dce0f 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -10,7 +10,7 @@ import { isBinaryFile } from "isbinaryfile" import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools" import { readFileTool } from "../readFileTool" import { formatResponse } from "../../prompts/responses" -import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB } from "../helpers/imageHelpers" +import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB } from "../helpers/imageHelpers" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -156,7 +156,7 @@ vi.mock("../../../i18n", () => ({ const translations: Record = { "tools:readFile.imageWithSize": "Image file ({{size}} KB)", "tools:readFile.imageTooLarge": - "Image file is too large ({{size}} MB). The maximum allowed size is {{max}} MB.", + "Image file is too large ({{size}}). The maximum allowed size is {{max}} MB.", "tools:readFile.linesRange": " (lines {{start}}-{{end}})", "tools:readFile.definitionsOnly": " (definitions only)", "tools:readFile.maxLines": " (max {{max}} lines)", @@ -296,7 +296,7 @@ describe("read_file tool with maxReadFileLine setting", () => { const maxReadFileLine = options.maxReadFileLine ?? 500 const totalLines = options.totalLines ?? 5 - mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageMemory: 20 }) + mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageSize: 20 }) mockedCountFileLines.mockResolvedValue(totalLines) // Reset the spy before each test @@ -532,7 +532,7 @@ describe("read_file tool XML output structure", () => { mockInputContent = fileContent // Setup mock provider with default maxReadFileLine - mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageMemory: 20 }) // Default to full file read + mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, maxTotalImageSize: 20 }) // Default to full file read // Add additional properties needed for XML tests mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing required parameter") @@ -557,7 +557,7 @@ describe("read_file tool XML output structure", () => { const isBinary = options.isBinary ?? false const validateAccess = options.validateAccess ?? true - mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageMemory: 20 }) + mockProvider.getState.mockResolvedValue({ maxReadFileLine, maxImageFileSize: 20, maxTotalImageSize: 20 }) mockedCountFileLines.mockResolvedValue(totalLines) mockedIsBinaryFile.mockResolvedValue(isBinary) mockCline.rooIgnoreController.validateAccess = vi.fn().mockReturnValue(validateAccess) @@ -599,8 +599,8 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, - maxTotalImageMemory: 20, - }) // Allow up to 20MB per image and total memory + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size // Execute const result = await executeReadFileTool() @@ -632,8 +632,8 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, - maxTotalImageMemory: 20, - }) // Allow up to 20MB per image and total memory + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size // Execute const result = await executeReadFileTool({}, { totalLines: 0 }) @@ -651,6 +651,12 @@ describe("read_file tool XML output structure", () => { { path: "test/image3.gif", sizeKB: 8192 }, // 8MB ] + // Define imageBuffer for this test suite + const imageBuffer = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64", + ) + beforeEach(() => { // CRITICAL: Reset fsPromises mocks to prevent cross-test contamination within this suite fsPromises.stat.mockClear() @@ -707,8 +713,8 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, - maxTotalImageMemory: 20, - }) // Allow up to 20MB per image and total memory + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size // Setup mockCline properties (preserve existing API) mockCline.cwd = "/" @@ -780,8 +786,8 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 15, - maxTotalImageMemory: 20, - }) // Allow up to 15MB per image and 20MB total memory + maxTotalImageSize: 20, + }) // Allow up to 15MB per image and 20MB total size // Setup mockCline properties mockCline.cwd = "/" @@ -844,9 +850,9 @@ describe("read_file tool XML output structure", () => { expect(imageParts).toHaveLength(4) // First 4 images should be included (~19.6MB total) // Verify memory limit notice for the fifth image - expect(textPart).toContain( - "Image skipped to avoid memory limit (20MB). Current: 19.6MB + this file: 4.9MB. Try fewer or smaller images.", - ) + expect(textPart).toContain("Image skipped to avoid size limit (20MB)") + expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) + expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/) }) it("should track memory usage correctly across multiple images", async () => { @@ -866,8 +872,8 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 15, - maxTotalImageMemory: 20, - }) // Allow up to 15MB per image and 20MB total memory + maxTotalImageSize: 20, + }) // Allow up to 15MB per image and 20MB total size // Setup mockCline properties mockCline.cwd = "/" @@ -917,9 +923,9 @@ describe("read_file tool XML output structure", () => { const imageParts = Array.isArray(result) ? result.filter((p) => p.type === "image") : [] expect(imageParts).toHaveLength(2) // First 2 images should fit - expect(textPart).toContain( - "Image skipped to avoid memory limit (20MB). Current: 20.0MB + this file: 1.0MB. Try fewer or smaller images.", - ) + expect(textPart).toContain("Image skipped to avoid size limit (20MB)") + expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) + expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/) }) it("should handle individual image size limit and total memory limit together", async () => { @@ -939,8 +945,8 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, - maxTotalImageMemory: 20, - }) // Allow up to 20MB per image and total memory + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size // Setup mockCline properties (complete setup) mockCline.cwd = "/" @@ -981,7 +987,7 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 5, - maxTotalImageMemory: 20, + maxTotalImageSize: 20, }) // Mock path.resolve @@ -1001,7 +1007,9 @@ describe("read_file tool XML output structure", () => { expect(imageParts).toHaveLength(2) // Should show individual size limit violation - expect(textPart).toContain("Image file is too large (6.0 MB). The maximum allowed size is 5 MB.") + expect(textPart).toMatch( + /Image file is too large \(\d+(\.\d+)? MB\)\. The maximum allowed size is 5 MB\./, + ) }) it("should correctly calculate total memory and skip the last image", async () => { @@ -1015,7 +1023,7 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 10, // 10MB per image - maxTotalImageMemory: 20, // 20MB total + maxTotalImageSize: 20, // 20MB total }) mockedIsBinaryFile.mockResolvedValue(true) @@ -1040,9 +1048,9 @@ describe("read_file tool XML output structure", () => { const imageParts = parts.filter((p) => p.type === "image") expect(imageParts).toHaveLength(2) // First two images should be processed - expect(textPart).toContain("Image skipped to avoid memory limit (20MB)") - expect(textPart).toContain("Current: 16.0MB") - expect(textPart).toContain("this file: 8.0MB") + expect(textPart).toContain("Image skipped to avoid size limit (20MB)") + expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) + expect(textPart).toMatch(/this file: \d+(\.\d+)? MB/) }) it("should reset total memory tracking for each tool invocation", async () => { @@ -1062,7 +1070,7 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, - maxTotalImageMemory: 20, + maxTotalImageSize: 20, }) // Setup mockCline properties (complete setup) @@ -1106,7 +1114,7 @@ describe("read_file tool XML output structure", () => { mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1, maxImageFileSize: 20, - maxTotalImageMemory: 20, + maxTotalImageSize: 20, }) // Reset path resolving for second batch @@ -1162,6 +1170,123 @@ describe("read_file tool XML output structure", () => { expect(imageParts).toHaveLength(1) // Second image should be processed }) + + it("should handle a folder with many images just under the individual size limit", async () => { + // Setup - Create many images that are each just under the 5MB individual limit + // but together approach the 20MB total limit + const manyImages = [ + { path: "test/img1.png", sizeKB: 4900 }, // 4.78MB + { path: "test/img2.png", sizeKB: 4900 }, // 4.78MB + { path: "test/img3.png", sizeKB: 4900 }, // 4.78MB + { path: "test/img4.png", sizeKB: 4900 }, // 4.78MB + { path: "test/img5.png", sizeKB: 4900 }, // 4.78MB - This should be skipped (total would be ~23.9MB) + ] + + // Setup mocks + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(imageBuffer) + + // Setup provider with 5MB individual limit and 20MB total limit + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 5, + maxTotalImageSize: 20, + }) + + // Mock file stats for each image + fsPromises.stat = vi.fn().mockImplementation((filePath) => { + const normalizedFilePath = path.normalize(filePath.toString()) + const image = manyImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) + return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 }) + }) + + // Mock path.resolve + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute + const result = await executeReadMultipleImagesTool(manyImages.map((img) => img.path)) + + // Verify + expect(Array.isArray(result)).toBe(true) + const parts = result as any[] + const textPart = parts.find((p) => p.type === "text")?.text + const imageParts = parts.filter((p) => p.type === "image") + + // Should process first 4 images (total ~19.12MB, under 20MB limit) + expect(imageParts).toHaveLength(4) + + // Should show memory limit notice for the 5th image + expect(textPart).toContain("Image skipped to avoid size limit (20MB)") + expect(textPart).toContain("test/img5.png") + + // Verify memory tracking worked correctly + // The notice should show current memory usage around 20MB (4 * 4900KB ≈ 19.14MB, displayed as 20.1MB) + expect(textPart).toMatch(/Current: \d+(\.\d+)? MB/) + }) + + it("should reset memory tracking between separate tool invocations more explicitly", async () => { + // This test verifies that totalImageMemoryUsed is reset between calls + // by making two separate tool invocations and ensuring the second one + // starts with fresh memory tracking + + // Setup mocks + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + fsPromises.readFile.mockResolvedValue(imageBuffer) + + // Setup provider + mockProvider.getState.mockResolvedValue({ + maxReadFileLine: -1, + maxImageFileSize: 20, + maxTotalImageSize: 20, + }) + + // First invocation - use 15MB of memory + const firstBatch = [{ path: "test/large1.png", sizeKB: 15360 }] // 15MB + + fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 }) + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute first batch + const result1 = await executeReadMultipleImagesTool(firstBatch.map((img) => img.path)) + + // Verify first batch processed successfully + expect(Array.isArray(result1)).toBe(true) + const parts1 = result1 as any[] + const imageParts1 = parts1.filter((p) => p.type === "image") + expect(imageParts1).toHaveLength(1) + + // Second invocation - should start with 0 memory used, not 15MB + // If memory tracking wasn't reset, this 18MB image would be rejected + const secondBatch = [{ path: "test/large2.png", sizeKB: 18432 }] // 18MB + + // Reset mocks for second invocation + fsPromises.stat.mockClear() + fsPromises.readFile.mockClear() + mockedPathResolve.mockClear() + + fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024 }) + fsPromises.readFile.mockResolvedValue(imageBuffer) + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute second batch + const result2 = await executeReadMultipleImagesTool(secondBatch.map((img) => img.path)) + + // Verify second batch processed successfully + expect(Array.isArray(result2)).toBe(true) + const parts2 = result2 as any[] + const imageParts2 = parts2.filter((p) => p.type === "image") + const textPart2 = parts2.find((p) => p.type === "text")?.text + + // The 18MB image should be processed successfully because memory was reset + expect(imageParts2).toHaveLength(1) + + // Should NOT contain any memory limit notices + expect(textPart2).not.toContain("Image skipped to avoid memory limit") + + // This proves memory tracking was reset between invocations + }) }) }) diff --git a/src/core/tools/helpers/imageHelpers.ts b/src/core/tools/helpers/imageHelpers.ts index dfe93ae5854..d238e98f423 100644 --- a/src/core/tools/helpers/imageHelpers.ts +++ b/src/core/tools/helpers/imageHelpers.ts @@ -8,9 +8,12 @@ export const DEFAULT_MAX_IMAGE_FILE_SIZE_MB = 5 /** * Default maximum total memory usage for all images in a single read operation (20MB) - * This prevents memory issues when reading multiple large images simultaneously + * This is a cumulative limit - as each image is processed, its size is added to the total. + * If including another image would exceed this limit, it will be skipped with a notice. + * Example: With a 20MB limit, reading 3 images of 8MB, 7MB, and 10MB would process + * the first two (15MB total) but skip the third to stay under the limit. */ -export const DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB = 20 +export const DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB = 20 /** * Supported image formats that can be displayed diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index 19c3941b42f..e21f4f95307 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -1,5 +1,6 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" +import prettyBytes from "pretty-bytes" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" @@ -17,8 +18,7 @@ import { parseXml } from "../../utils/xml" import * as fs from "fs/promises" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB, - SUPPORTED_IMAGE_FORMATS, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, readImageAsDataUrlWithBuffer, isSupportedImageFormat, } from "./helpers/imageHelpers" @@ -439,7 +439,7 @@ export async function readFileTool( const { maxReadFileLine = -1, maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageMemory = DEFAULT_MAX_TOTAL_IMAGE_MEMORY_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, } = state ?? {} // Then process only approved files @@ -482,9 +482,9 @@ export async function readFileTool( // Check if image file exceeds individual size limit if (imageStats.size > maxImageFileSize * 1024 * 1024) { - const imageSizeInMB = (imageStats.size / (1024 * 1024)).toFixed(1) + const imageSizeFormatted = prettyBytes(imageStats.size) const notice = t("tools:readFile.imageTooLarge", { - size: imageSizeInMB, + size: imageSizeFormatted, max: maxImageFileSize, }) @@ -499,8 +499,10 @@ export async function readFileTool( // Check if adding this image would exceed total memory limit const imageSizeInMB = imageStats.size / (1024 * 1024) - if (totalImageMemoryUsed + imageSizeInMB > maxTotalImageMemory) { - const notice = `Image skipped to avoid memory limit (${maxTotalImageMemory}MB). Current: ${totalImageMemoryUsed.toFixed(1)}MB + this file: ${imageSizeInMB.toFixed(1)}MB. Try fewer or smaller images.`; + if (totalImageMemoryUsed + imageSizeInMB > maxTotalImageSize) { + const currentMemoryFormatted = prettyBytes(totalImageMemoryUsed * 1024 * 1024) + const fileMemoryFormatted = prettyBytes(imageStats.size) + const notice = `Image skipped to avoid size limit (${maxTotalImageSize}MB). Current: ${currentMemoryFormatted} + this file: ${fileMemoryFormatted}. Try fewer or smaller images.` // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c55f68b1fa1..60d198345b3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1426,7 +1426,7 @@ export class ClineProvider language, maxReadFileLine, maxImageFileSize, - maxTotalImageMemory, + maxTotalImageSize, terminalCompressProgressBar, historyPreviewCollapsed, cloudUserInfo, @@ -1535,7 +1535,7 @@ export class ClineProvider renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, - maxTotalImageMemory: maxTotalImageMemory ?? 20, + maxTotalImageSize: maxTotalImageSize ?? 20, maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, terminalCompressProgressBar: terminalCompressProgressBar ?? true, @@ -1707,7 +1707,7 @@ export class ClineProvider showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true, maxReadFileLine: stateValues.maxReadFileLine ?? -1, maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageMemory: stateValues.maxTotalImageMemory ?? 20, + maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, cloudUserInfo, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index cd309f363b2..93608e71313 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1269,8 +1269,8 @@ export const webviewMessageHandler = async ( await updateGlobalState("maxImageFileSize", message.value) await provider.postStateToWebview() break - case "maxTotalImageMemory": - await updateGlobalState("maxTotalImageMemory", message.value) + case "maxTotalImageSize": + await updateGlobalState("maxTotalImageSize", message.value) await provider.postStateToWebview() break case "maxConcurrentFileReads": diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 46dd8d66ad5..07d57d679f2 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -279,7 +279,7 @@ export type ExtensionState = Pick< showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings maxReadFileLine: number // Maximum number of lines to read from a file before truncating maxImageFileSize: number // Maximum size of image files to process in MB - maxTotalImageMemory: number // Maximum total memory for all images in a single read operation in MB + maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB experiments: Experiments // Map of experiment IDs to their enabled state diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 76add0a43f5..e8e8721b7ed 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -163,7 +163,7 @@ export interface WebviewMessage { | "language" | "maxReadFileLine" | "maxImageFileSize" - | "maxTotalImageMemory" + | "maxTotalImageSize" | "maxConcurrentFileReads" | "includeDiagnosticMessages" | "maxDiagnosticMessages" diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 7a207b68957..88484e1d63b 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -21,7 +21,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { showRooIgnoredFiles?: boolean maxReadFileLine?: number maxImageFileSize?: number - maxTotalImageMemory?: number + maxTotalImageSize?: number maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean @@ -35,7 +35,7 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "showRooIgnoredFiles" | "maxReadFileLine" | "maxImageFileSize" - | "maxTotalImageMemory" + | "maxTotalImageSize" | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" @@ -54,7 +54,7 @@ export const ContextManagementSettings = ({ setCachedStateField, maxReadFileLine, maxImageFileSize, - maxTotalImageMemory, + maxTotalImageSize, maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, @@ -242,29 +242,29 @@ export const ContextManagementSettings = ({
- {t("settings:contextManagement.maxTotalImageMemory.label")} + {t("settings:contextManagement.maxTotalImageSize.label")}
{ const newValue = parseInt(e.target.value, 10) if (!isNaN(newValue) && newValue >= 1 && newValue <= 500) { - setCachedStateField("maxTotalImageMemory", newValue) + setCachedStateField("maxTotalImageSize", newValue) } }} onClick={(e) => e.currentTarget.select()} - data-testid="max-total-image-memory-input" + data-testid="max-total-image-size-input" /> - {t("settings:contextManagement.maxTotalImageMemory.mb")} + {t("settings:contextManagement.maxTotalImageSize.mb")}
- {t("settings:contextManagement.maxTotalImageMemory.description")} + {t("settings:contextManagement.maxTotalImageSize.description")}
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 15f73a709d6..1854585377d 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -169,7 +169,7 @@ const SettingsView = forwardRef(({ onDone, t remoteBrowserEnabled, maxReadFileLine, maxImageFileSize, - maxTotalImageMemory, + maxTotalImageSize, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, @@ -324,7 +324,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles }) vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 }) vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 }) - vscode.postMessage({ type: "maxTotalImageMemory", value: maxTotalImageMemory ?? 20 }) + vscode.postMessage({ type: "maxTotalImageSize", value: maxTotalImageSize ?? 20 }) vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 }) vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages }) vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 }) @@ -672,7 +672,7 @@ const SettingsView = forwardRef(({ onDone, t showRooIgnoredFiles={showRooIgnoredFiles} maxReadFileLine={maxReadFileLine} maxImageFileSize={maxImageFileSize} - maxTotalImageMemory={maxTotalImageMemory} + maxTotalImageSize={maxTotalImageSize} maxConcurrentFileReads={maxConcurrentFileReads} profileThresholds={profileThresholds} includeDiagnosticMessages={includeDiagnosticMessages} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 16434c35d87..3d39613eda0 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -122,8 +122,8 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxReadFileLine: (value: number) => void maxImageFileSize: number setMaxImageFileSize: (value: number) => void - maxTotalImageMemory: number - setMaxTotalImageMemory: (value: number) => void + maxTotalImageSize: number + setMaxTotalImageSize: (value: number) => void machineId?: string pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void @@ -213,7 +213,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode renderContext: "sidebar", maxReadFileLine: -1, // Default max read file line limit maxImageFileSize: 5, // Default max image file size in MB - maxTotalImageMemory: 20, // Default max total image memory in MB + maxTotalImageSize: 20, // Default max total image size in MB pinnedApiConfigs: {}, // Empty object for pinned API configs terminalZshOhMy: false, // Default Oh My Zsh integration setting maxConcurrentFileReads: 5, // Default concurrent file reads @@ -455,7 +455,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })), setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })), setMaxImageFileSize: (value) => setState((prevState) => ({ ...prevState, maxImageFileSize: value })), - setMaxTotalImageMemory: (value) => setState((prevState) => ({ ...prevState, maxTotalImageMemory: value })), + setMaxTotalImageSize: (value) => setState((prevState) => ({ ...prevState, maxTotalImageSize: value })), setPinnedApiConfigs: (value) => setState((prevState) => ({ ...prevState, pinnedApiConfigs: value })), setTerminalCompressProgressBar: (value) => setState((prevState) => ({ ...prevState, terminalCompressProgressBar: value })), diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 5ae9f49720d..7264c857911 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -532,10 +532,10 @@ "mb": "MB", "description": "Mida màxima (en MB) per a arxius d'imatge que poden ser processats per l'eina de lectura d'arxius." }, - "maxTotalImageMemory": { - "label": "Memòria total màxima per a imatges", + "maxTotalImageSize": { + "label": "Mida total màxima d'imatges", "mb": "MB", - "description": "Memòria total màxima (en MB) per a totes les imatges en una sola operació de lectura. Prevé problemes de memòria en llegir múltiples imatges grans simultàniament." + "description": "Límit de mida acumulativa màxima (en MB) per a totes les imatges processades en una sola operació read_file. Quan es llegeixen múltiples imatges, la mida de cada imatge s'afegeix al total. Si incloure una altra imatge excediria aquest límit, serà omesa." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 36cd9a339d2..1ba4286772c 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -532,10 +532,10 @@ "mb": "MB", "description": "Maximale Größe (in MB) für Bilddateien, die vom read file Tool verarbeitet werden können." }, - "maxTotalImageMemory": { - "label": "Maximaler Gesamtspeicher für Bilder", + "maxTotalImageSize": { + "label": "Maximale Gesamtbildgröße", "mb": "MB", - "description": "Maximaler Gesamtspeicher (in MB) für alle Bilder in einem einzelnen Lesevorgang. Verhindert Speicherprobleme beim gleichzeitigen Lesen mehrerer großer Bilder." + "description": "Maximales kumulatives Größenlimit (in MB) für alle Bilder, die in einer einzelnen read_file-Operation verarbeitet werden. Beim Lesen mehrerer Bilder wird die Größe jedes Bildes zur Gesamtsumme addiert. Wenn das Einbeziehen eines weiteren Bildes dieses Limit überschreiten würde, wird es übersprungen." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 028eecacb64..8f40db0bff8 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -507,10 +507,10 @@ "mb": "MB", "description": "Maximum size (in MB) for image files that can be processed by the read file tool." }, - "maxTotalImageMemory": { - "label": "Max total image memory", + "maxTotalImageSize": { + "label": "Max total image size", "mb": "MB", - "description": "Maximum total memory (in MB) for all images in a single read operation. Prevents memory issues when reading multiple large images simultaneously." + "description": "Maximum cumulative size limit (in MB) for all images processed in a single read_file operation. When reading multiple images, each image's size is added to the total. If including another image would exceed this limit, it will be skipped." }, "diagnostics": { "includeMessages": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index cccfbaa73c1..e6c101285da 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -507,10 +507,10 @@ "mb": "MB", "description": "Tamaño máximo (en MB) para archivos de imagen que pueden ser procesados por la herramienta de lectura de archivos." }, - "maxTotalImageMemory": { - "label": "Memoria total máxima para imágenes", + "maxTotalImageSize": { + "label": "Tamaño total máximo de imágenes", "mb": "MB", - "description": "Memoria total máxima (en MB) para todas las imágenes en una sola operación de lectura. Previene problemas de memoria al leer múltiples imágenes grandes simultáneamente." + "description": "Límite de tamaño acumulativo máximo (en MB) para todas las imágenes procesadas en una sola operación read_file. Al leer múltiples imágenes, el tamaño de cada imagen se suma al total. Si incluir otra imagen excedería este límite, será omitida." }, "diagnostics": { "includeMessages": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 3f5b0e95302..bbe657082a5 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -507,10 +507,10 @@ "mb": "MB", "description": "Taille maximale (en MB) pour les fichiers d'image qui peuvent être traités par l'outil de lecture de fichier." }, - "maxTotalImageMemory": { - "label": "Mémoire totale maximale pour les images", + "maxTotalImageSize": { + "label": "Taille totale maximale des images", "mb": "MB", - "description": "Mémoire totale maximale (en MB) pour toutes les images dans une seule opération de lecture. Empêche les problèmes de mémoire lors de la lecture simultanée de plusieurs grandes images." + "description": "Limite de taille cumulée maximale (en MB) pour toutes les images traitées dans une seule opération read_file. Lors de la lecture de plusieurs images, la taille de chaque image est ajoutée au total. Si l'inclusion d'une autre image dépasserait cette limite, elle sera ignorée." }, "diagnostics": { "includeMessages": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 69fc029eaa9..33a23b2d110 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "छवि फ़ाइलों के लिए अधिकतम आकार (MB में) जो read file tool द्वारा प्रसंस्कृत किया जा सकता है।" }, - "maxTotalImageMemory": { - "label": "छवियों के लिए अधिकतम कुल मेमोरी", + "maxTotalImageSize": { + "label": "अधिकतम कुल छवि आकार", "mb": "MB", - "description": "एक ही पठन ऑपरेशन में सभी छवियों के लिए अधिकतम कुल मेमोरी (MB में)। एक साथ कई बड़ी छवियों को पढ़ते समय मेमोरी समस्याओं को रोकता है।" + "description": "एकल read_file ऑपरेशन में संसाधित सभी छवियों के लिए अधिकतम संचयी आकार सीमा (MB में)। कई छवियों को पढ़ते समय, प्रत्येक छवि का आकार कुल में जोड़ा जाता है। यदि किसी अन्य छवि को शामिल करने से यह सीमा पार हो जाएगी, तो उसे छोड़ दिया जाएगा।" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 252a20c1cde..12352cdeb8e 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -537,10 +537,10 @@ "mb": "MB", "description": "Ukuran maksimum (dalam MB) untuk file gambar yang dapat diproses oleh alat baca file." }, - "maxTotalImageMemory": { - "label": "Total memori maksimum untuk gambar", + "maxTotalImageSize": { + "label": "Ukuran total gambar maksimum", "mb": "MB", - "description": "Total memori maksimum (dalam MB) untuk semua gambar dalam satu operasi baca. Mencegah masalah memori saat membaca beberapa gambar besar secara bersamaan." + "description": "Batas ukuran kumulatif maksimum (dalam MB) untuk semua gambar yang diproses dalam satu operasi read_file. Saat membaca beberapa gambar, ukuran setiap gambar ditambahkan ke total. Jika menyertakan gambar lain akan melebihi batas ini, gambar tersebut akan dilewati." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index eb6a6dfd5fd..5bb2e422a62 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "Dimensione massima (in MB) per i file immagine che possono essere elaborati dallo strumento di lettura file." }, - "maxTotalImageMemory": { - "label": "Memoria totale massima per le immagini", + "maxTotalImageSize": { + "label": "Dimensione totale massima immagini", "mb": "MB", - "description": "Memoria totale massima (in MB) per tutte le immagini in una singola operazione di lettura. Previene problemi di memoria durante la lettura simultanea di più immagini grandi." + "description": "Limite di dimensione cumulativa massima (in MB) per tutte le immagini elaborate in una singola operazione read_file. Durante la lettura di più immagini, la dimensione di ogni immagine viene aggiunta al totale. Se l'inclusione di un'altra immagine supererebbe questo limite, verrà saltata." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 8e8d7dad4c1..eb6c30bfe18 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "read fileツールで処理できる画像ファイルの最大サイズ(MB単位)。" }, - "maxTotalImageMemory": { - "label": "画像の最大合計メモリ", + "maxTotalImageSize": { + "label": "最大合計画像サイズ", "mb": "MB", - "description": "単一の読み取り操作ですべての画像に使用できる最大合計メモリ(MB単位)。複数の大きな画像を同時に読み取る際のメモリ問題を防ぎます。" + "description": "単一のread_file操作で処理されるすべての画像の累積サイズ制限(MB単位)。複数の画像を読み取る際、各画像のサイズが合計に加算されます。別の画像を含めるとこの制限を超える場合、その画像はスキップされます。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 550e35320e3..9bbaa6ca0c3 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "read file 도구로 처리할 수 있는 이미지 파일의 최대 크기(MB 단위)입니다." }, - "maxTotalImageMemory": { - "label": "이미지 최대 총 메모리", + "maxTotalImageSize": { + "label": "최대 총 이미지 크기", "mb": "MB", - "description": "단일 읽기 작업에서 모든 이미지에 대한 최대 총 메모리(MB 단위). 여러 대용량 이미지를 동시에 읽을 때 메모리 문제를 방지합니다." + "description": "단일 read_file 작업에서 처리되는 모든 이미지의 최대 누적 크기 제한(MB 단위)입니다. 여러 이미지를 읽을 때 각 이미지의 크기가 총계에 추가됩니다. 다른 이미지를 포함하면 이 제한을 초과하는 경우 해당 이미지는 건너뜁니다." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 9cf0434274a..228da40231b 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -507,10 +507,10 @@ "mb": "MB", "description": "Maximale grootte (in MB) voor afbeeldingsbestanden die kunnen worden verwerkt door de read file tool." }, - "maxTotalImageMemory": { - "label": "Maximaal totaal afbeeldingsgeheugen", + "maxTotalImageSize": { + "label": "Maximale totale afbeeldingsgrootte", "mb": "MB", - "description": "Maximaal totaal geheugen (in MB) voor alle afbeeldingen in één leesbewerking. Voorkomt geheugenproblemen bij het gelijktijdig lezen van meerdere grote afbeeldingen." + "description": "Maximale cumulatieve groottelimiet (in MB) voor alle afbeeldingen die in één read_file-bewerking worden verwerkt. Bij het lezen van meerdere afbeeldingen wordt de grootte van elke afbeelding bij het totaal opgeteld. Als het toevoegen van een andere afbeelding deze limiet zou overschrijden, wordt deze overgeslagen." }, "diagnostics": { "includeMessages": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index bfacaea3b3a..615c0d68d75 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "Maksymalny rozmiar (w MB) plików obrazów, które mogą być przetwarzane przez narzędzie do czytania plików." }, - "maxTotalImageMemory": { - "label": "Maksymalna całkowita pamięć dla obrazów", + "maxTotalImageSize": { + "label": "Maksymalny całkowity rozmiar obrazów", "mb": "MB", - "description": "Maksymalna całkowita pamięć (w MB) dla wszystkich obrazów w jednej operacji odczytu. Zapobiega problemom z pamięcią podczas jednoczesnego odczytu wielu dużych obrazów." + "description": "Maksymalny skumulowany limit rozmiaru (w MB) dla wszystkich obrazów przetwarzanych w jednej operacji read_file. Podczas odczytu wielu obrazów rozmiar każdego obrazu jest dodawany do sumy. Jeśli dołączenie kolejnego obrazu przekroczyłoby ten limit, zostanie on pominięty." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 52c6720373e..96ab854bfb6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "Tamanho máximo (em MB) para arquivos de imagem que podem ser processados pela ferramenta de leitura de arquivos." }, - "maxTotalImageMemory": { - "label": "Memória total máxima para imagens", + "maxTotalImageSize": { + "label": "Tamanho total máximo da imagem", "mb": "MB", - "description": "Memória total máxima (em MB) para todas as imagens em uma única operação de leitura. Evita problemas de memória ao ler múltiplas imagens grandes simultaneamente." + "description": "Limite máximo de tamanho cumulativo (em MB) para todas as imagens processadas em uma única operação read_file. Ao ler várias imagens, o tamanho de cada imagem é adicionado ao total. Se incluir outra imagem exceder esse limite, ela será ignorada." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 2927c30f94c..bda495d04a6 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "Максимальный размер (в МБ) для файлов изображений, которые могут быть обработаны инструментом чтения файлов." }, - "maxTotalImageMemory": { - "label": "Максимальная общая память для изображений", - "mb": "MB", - "description": "Максимальная общая память (в МБ) для всех изображений в одной операции чтения. Предотвращает проблемы с памятью при одновременном чтении нескольких больших изображений." + "maxTotalImageSize": { + "label": "Максимальный общий размер изображений", + "mb": "МБ", + "description": "Максимальный совокупный лимит размера (в МБ) для всех изображений, обрабатываемых в одной операции read_file. При чтении нескольких изображений размер каждого изображения добавляется к общему. Если включение другого изображения превысит этот лимит, оно будет пропущено." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index d3c225c6464..e3e71633f39 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "Dosya okuma aracı tarafından işlenebilecek görüntü dosyaları için maksimum boyut (MB cinsinden)." }, - "maxTotalImageMemory": { - "label": "Görüntüler için maksimum toplam bellek", + "maxTotalImageSize": { + "label": "Maksimum toplam görüntü boyutu", "mb": "MB", - "description": "Tek bir okuma işlemindeki tüm görüntüler için maksimum toplam bellek (MB cinsinden). Birden çok büyük görüntüyü eş zamanlı okurken bellek sorunlarını önler." + "description": "Tek bir read_file işleminde işlenen tüm görüntüler için maksimum kümülatif boyut sınırı (MB cinsinden). Birden çok görüntü okurken, her görüntünün boyutu toplama eklenir. Başka bir görüntü eklemek bu sınırı aşacaksa, atlanacaktır." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 56b0c98859d..9bcbb1263a7 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "Kích thước tối đa (tính bằng MB) cho các tệp hình ảnh có thể được xử lý bởi công cụ đọc tệp." }, - "maxTotalImageMemory": { - "label": "Bộ nhớ tổng tối đa cho hình ảnh", + "maxTotalImageSize": { + "label": "Kích thước tổng tối đa của hình ảnh", "mb": "MB", - "description": "Bộ nhớ tổng tối đa (tính bằng MB) cho tất cả hình ảnh trong một hoạt động đọc. Ngăn ngừa vấn đề bộ nhớ khi đọc đồng thời nhiều hình ảnh lớn." + "description": "Giới hạn kích thước tích lũy tối đa (tính bằng MB) cho tất cả hình ảnh được xử lý trong một thao tác read_file duy nhất. Khi đọc nhiều hình ảnh, kích thước của mỗi hình ảnh được cộng vào tổng. Nếu việc thêm một hình ảnh khác sẽ vượt quá giới hạn này, nó sẽ bị bỏ qua." } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 264d2d49333..5cbd80aed60 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "read file工具可以处理的图像文件的最大大小(以MB为单位)。" }, - "maxTotalImageMemory": { - "label": "图像最大总内存", + "maxTotalImageSize": { + "label": "图片总大小上限", "mb": "MB", - "description": "单次读取操作中所有图像的最大总内存(以MB为单位)。防止同时读取多个大图像时出现内存问题。" + "description": "单次 read_file 操作中处理的所有图片的最大累计大小限制(MB)。读取多张图片时,每张图片的大小会累加到总大小中。如果包含另一张图片会超过此限制,则会跳过该图片。" } }, "terminal": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index be55a5de414..b9e9f1ef06a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -533,10 +533,10 @@ "mb": "MB", "description": "read file工具可以處理的圖像檔案的最大大小(以MB為單位)。" }, - "maxTotalImageMemory": { - "label": "圖像最大總記憶體", + "maxTotalImageSize": { + "label": "圖片總大小上限", "mb": "MB", - "description": "單次讀取操作中所有圖像的最大總記憶體(以MB為單位)。防止同時讀取多個大圖像時出現記憶體問題。" + "description": "單次 read_file 操作中處理的所有圖片的最大累計大小限制(MB)。讀取多張圖片時,每張圖片的大小會累加到總大小中。如果包含另一張圖片會超過此限制,則會跳過該圖片。" } }, "terminal": { From 1ead3953a2a6308ca58caad90b165ffb16ddd511 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Mon, 28 Jul 2025 11:32:03 -0500 Subject: [PATCH 19/21] fix: update test to use maxTotalImageSize instead of maxTotalImageMemory --- src/core/webview/__tests__/ClineProvider.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 21232f66494..98de24a1cdc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -534,7 +534,7 @@ describe("ClineProvider", () => { renderContext: "sidebar", maxReadFileLine: 500, maxImageFileSize: 5, - maxTotalImageMemory: 20, + maxTotalImageSize: 20, cloudUserInfo: null, organizationAllowList: ORGANIZATION_ALLOW_ALL, autoCondenseContext: true, From 701d5fc0db88604bdff58a8c32518a3058fe2968 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Mon, 28 Jul 2025 11:32:50 -0500 Subject: [PATCH 20/21] fix: update webview test to use maxTotalImageSize --- webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 5b4874a6ffe..d9f81011131 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -210,7 +210,7 @@ describe("mergeExtensionState", () => { profileThresholds: {}, hasOpenedModeSelector: false, // Add the new required property maxImageFileSize: 5, - maxTotalImageMemory: 20 + maxTotalImageSize: 20, } const prevState: ExtensionState = { From 32a68d2baf4a711460340c4e4e69720c5c0d70a7 Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Mon, 28 Jul 2025 11:45:54 -0500 Subject: [PATCH 21/21] refactor: move image processing logic to imageHelpers - Extract image validation logic into validateImageForProcessing function - Extract image processing logic into processImageFile function - Add ImageMemoryTracker class to encapsulate memory tracking - Add proper TypeScript interfaces for validation and processing results - Reduce code duplication and improve separation of concerns --- src/core/tools/helpers/imageHelpers.ts | 124 +++++++++++++++++++++++++ src/core/tools/readFileTool.ts | 70 ++++---------- 2 files changed, 143 insertions(+), 51 deletions(-) diff --git a/src/core/tools/helpers/imageHelpers.ts b/src/core/tools/helpers/imageHelpers.ts index d238e98f423..a1adb078e63 100644 --- a/src/core/tools/helpers/imageHelpers.ts +++ b/src/core/tools/helpers/imageHelpers.ts @@ -1,5 +1,7 @@ import path from "path" import * as fs from "fs/promises" +import { t } from "../../../i18n" +import prettyBytes from "pretty-bytes" /** * Default maximum allowed image file size in bytes (5MB) @@ -46,6 +48,27 @@ export const IMAGE_MIME_TYPES: Record = { ".avif": "image/avif", } +/** + * Result of image validation + */ +export interface ImageValidationResult { + isValid: boolean + reason?: "size_limit" | "memory_limit" | "unsupported_model" + notice?: string + sizeInMB?: number +} + +/** + * Result of image processing + */ +export interface ImageProcessingResult { + dataUrl: string + buffer: Buffer + sizeInKB: number + sizeInMB: number + notice: string +} + /** * Reads an image file and returns both the data URL and buffer */ @@ -66,3 +89,104 @@ export async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{ export function isSupportedImageFormat(extension: string): boolean { return SUPPORTED_IMAGE_FORMATS.includes(extension.toLowerCase() as (typeof SUPPORTED_IMAGE_FORMATS)[number]) } + +/** + * Validates if an image can be processed based on size limits and model support + */ +export async function validateImageForProcessing( + fullPath: string, + supportsImages: boolean, + maxImageFileSize: number, + maxTotalImageSize: number, + currentTotalMemoryUsed: number, +): Promise { + // Check if model supports images + if (!supportsImages) { + return { + isValid: false, + reason: "unsupported_model", + notice: "Image file detected but current model does not support images. Skipping image processing.", + } + } + + const imageStats = await fs.stat(fullPath) + const imageSizeInMB = imageStats.size / (1024 * 1024) + + // Check individual file size limit + if (imageStats.size > maxImageFileSize * 1024 * 1024) { + const imageSizeFormatted = prettyBytes(imageStats.size) + return { + isValid: false, + reason: "size_limit", + notice: t("tools:readFile.imageTooLarge", { + size: imageSizeFormatted, + max: maxImageFileSize, + }), + sizeInMB: imageSizeInMB, + } + } + + // Check total memory limit + if (currentTotalMemoryUsed + imageSizeInMB > maxTotalImageSize) { + const currentMemoryFormatted = prettyBytes(currentTotalMemoryUsed * 1024 * 1024) + const fileMemoryFormatted = prettyBytes(imageStats.size) + return { + isValid: false, + reason: "memory_limit", + notice: `Image skipped to avoid size limit (${maxTotalImageSize}MB). Current: ${currentMemoryFormatted} + this file: ${fileMemoryFormatted}. Try fewer or smaller images.`, + sizeInMB: imageSizeInMB, + } + } + + return { + isValid: true, + sizeInMB: imageSizeInMB, + } +} + +/** + * Processes an image file and returns the result + */ +export async function processImageFile(fullPath: string): Promise { + const imageStats = await fs.stat(fullPath) + const { dataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath) + const imageSizeInKB = Math.round(imageStats.size / 1024) + const imageSizeInMB = imageStats.size / (1024 * 1024) + const noticeText = t("tools:readFile.imageWithSize", { size: imageSizeInKB }) + + return { + dataUrl, + buffer, + sizeInKB: imageSizeInKB, + sizeInMB: imageSizeInMB, + notice: noticeText, + } +} + +/** + * Memory tracker for image processing + */ +export class ImageMemoryTracker { + private totalMemoryUsed: number = 0 + + /** + * Gets the current total memory used in MB + */ + getTotalMemoryUsed(): number { + return this.totalMemoryUsed + } + + /** + * Adds to the total memory used + */ + addMemoryUsage(sizeInMB: number): void { + this.totalMemoryUsed += sizeInMB + } + + /** + * Resets the memory tracker + */ + reset(): void { + this.totalMemoryUsed = 0 + } +} diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index e21f4f95307..01427f4d9dc 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -1,6 +1,5 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" -import prettyBytes from "pretty-bytes" import { Task } from "../task/Task" import { ClineSayTool } from "../../shared/ExtensionMessage" @@ -15,12 +14,13 @@ import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { parseXml } from "../../utils/xml" -import * as fs from "fs/promises" import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - readImageAsDataUrlWithBuffer, isSupportedImageFormat, + validateImageForProcessing, + processImageFile, + ImageMemoryTracker, } from "./helpers/imageHelpers" export function getReadFileToolDescription(blockName: string, blockParams: any): string { @@ -434,7 +434,7 @@ export async function readFileTool( } // Track total image memory usage across all files - let totalImageMemoryUsed = 0 + const imageMemoryTracker = new ImageMemoryTracker() const state = await cline.providerRef.deref()?.getState() const { maxReadFileLine = -1, @@ -463,71 +463,39 @@ export async function readFileTool( // Check if it's a supported image format if (isSupportedImageFormat(fileExtension)) { - // Skip image processing if model doesn't support images - if (!supportsImages) { - const notice = - "Image file detected but current model does not support images. Skipping image processing." - - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - xmlContent: `${relPath}\n${notice}\n`, - }) - continue - } - try { - const imageStats = await fs.stat(fullPath) - - // Check if image file exceeds individual size limit - if (imageStats.size > maxImageFileSize * 1024 * 1024) { - const imageSizeFormatted = prettyBytes(imageStats.size) - const notice = t("tools:readFile.imageTooLarge", { - size: imageSizeFormatted, - max: maxImageFileSize, - }) + // Validate image for processing + const validationResult = await validateImageForProcessing( + fullPath, + supportsImages, + maxImageFileSize, + maxTotalImageSize, + imageMemoryTracker.getTotalMemoryUsed(), + ) + if (!validationResult.isValid) { // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) updateFileResult(relPath, { - xmlContent: `${relPath}\n${notice}\n`, + xmlContent: `${relPath}\n${validationResult.notice}\n`, }) continue } - // Check if adding this image would exceed total memory limit - const imageSizeInMB = imageStats.size / (1024 * 1024) - if (totalImageMemoryUsed + imageSizeInMB > maxTotalImageSize) { - const currentMemoryFormatted = prettyBytes(totalImageMemoryUsed * 1024 * 1024) - const fileMemoryFormatted = prettyBytes(imageStats.size) - const notice = `Image skipped to avoid size limit (${maxTotalImageSize}MB). Current: ${currentMemoryFormatted} + this file: ${fileMemoryFormatted}. Try fewer or smaller images.` - - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - xmlContent: `${relPath}\n${notice}\n`, - }) - continue - } + // Process the image + const imageResult = await processImageFile(fullPath) // Track memory usage for this image - totalImageMemoryUsed += imageSizeInMB - - const { dataUrl: imageDataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath) - const imageSizeInKB = Math.round(imageStats.size / 1024) + imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) // Track file read await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) // Store image data URL separately - NOT in XML - const noticeText = t("tools:readFile.imageWithSize", { size: imageSizeInKB }) - updateFileResult(relPath, { - xmlContent: `${relPath}\n${noticeText}\n`, - imageDataUrl: imageDataUrl, + xmlContent: `${relPath}\n${imageResult.notice}\n`, + imageDataUrl: imageResult.dataUrl, }) continue } catch (error) {