diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index d5e76eccea..dc5a9e6744 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -101,6 +101,8 @@ export const globalSettingsSchema = z.object({ maxWorkspaceFiles: z.number().optional(), showRooIgnoredFiles: z.boolean().optional(), maxReadFileLine: z.number().optional(), + maxImageFileSize: 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 44be1d3b92..7ba822dce0 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_SIZE_MB } from "../helpers/imageHelpers" vi.mock("path", async () => { const originalPath = await vi.importActual("path") @@ -20,22 +21,29 @@ 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 = "" // 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 @@ -48,6 +56,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() { @@ -63,6 +118,109 @@ 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) => { + // Map translation keys to English text + const translations: Record = { + "tools:readFile.imageWithSize": "Image file ({{size}} KB)", + "tools:readFile.imageTooLarge": + "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)", + } + + 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 + }), +})) + +// 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" @@ -80,12 +238,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) @@ -102,31 +275,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) - toolResult = undefined }) @@ -148,7 +296,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, maxTotalImageSize: 20 }) mockedCountFileLines.mockResolvedValue(totalLines) // Reset the spy before each test @@ -340,13 +488,38 @@ 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", + ) - 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) @@ -359,30 +532,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, maxTotalImageSize: 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 - toolResult = undefined }) @@ -403,7 +557,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, maxTotalImageSize: 20 }) mockedCountFileLines.mockResolvedValue(totalLines) mockedIsBinaryFile.mockResolvedValue(isBinary) mockCline.rooIgnoreController.validateAccess = vi.fn().mockReturnValue(validateAccess) @@ -442,7 +596,11 @@ 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, + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size // Execute const result = await executeReadFileTool() @@ -471,7 +629,11 @@ 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, + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size // Execute const result = await executeReadFileTool({}, { totalLines: 0 }) @@ -481,6 +643,651 @@ 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 + ] + + // 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() + 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 ?? "", + ) + // 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 + } + + 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, + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size + + // 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 normalizedFilePath = path.normalize(filePath.toString()) + const image = smallImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) + 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, + maxTotalImageSize: 20, + }) // Allow up to 15MB per image and 20MB total size + + // 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 normalizedFilePath = path.normalize(filePath.toString()) + const image = largeImages.find((img) => normalizedFilePath.includes(path.normalize(img.path))) + 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 = 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() + + // 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("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 () => { + // 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, + maxTotalImageSize: 20, + }) // Allow up to 15MB per image and 20MB total size + + // 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) => { + 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 + }) + + // Mock path.resolve + mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + + // Execute + const result = await executeReadMultipleImagesTool(exactLimitImages.map((img) => img.path)) + + // Verify + 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("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 () => { + // 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, + maxTotalImageSize: 20, + }) // Allow up to 20MB per image and total size + + // 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, + maxTotalImageSize: 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).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 () => { + // 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 + maxTotalImageSize: 20, // 20MB total + }) + + mockedIsBinaryFile.mockResolvedValue(true) + mockedCountFileLines.mockResolvedValue(0) + mockedFsReadFile.mockResolvedValue(imageBuffer) + + fsPromises.stat.mockImplementation(async (filePath) => { + 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 } + } + 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 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 () => { + // 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, + maxTotalImageSize: 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, + maxTotalImageSize: 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 + }) + + 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 + }) + }) }) describe("Error Handling Tests", () => { @@ -520,3 +1327,343 @@ 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) + + let localMockCline: any + let localMockProvider: any + let toolResult: ToolResponse | undefined + + beforeEach(() => { + // 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) + + // Setup mock provider with default maxReadFileLine + localMockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) + + 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, + } + + // Debug: Check if mock is working + console.log("Mock API:", localMockCline.api) + console.log("Supports images:", localMockCline.api?.getModel?.()?.info?.supportsImages) + + await readFileTool( + localMockCline, + toolUse, + localMockCline.ask, + vi.fn(), + (result: ToolResponse) => { + toolResult = result + }, + (_: ToolParamName, content?: string) => content ?? "", + ) + + console.log("Result type:", Array.isArray(toolResult) ? "array" : typeof toolResult) + console.log("Result:", toolResult) + + 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) + + // Ensure API mock supports images + setImageSupport(localMockCline, true) + + // 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 exclude images when model does not support images", async () => { + // Setup - mock API handler that doesn't support images + setImageSupport(localMockCline, 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 + setImageSupport(localMockCline, 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 + setImageSupport(localMockCline, 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")) + + // 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( + localMockCline, + toolUse, + localMockCline.ask, + handleErrorSpy, // Use our spy here + (result: ToolResponse) => { + toolResult = result + }, + (_: ToolParamName, content?: string) => content ?? "", + ) + + // Verify error handling + expect(toolResult).toContain("Error reading image file: Failed to read image") + expect(handleErrorSpy).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("") + // 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 () => { + // 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/helpers/imageHelpers.ts b/src/core/tools/helpers/imageHelpers.ts new file mode 100644 index 0000000000..a1adb078e6 --- /dev/null +++ b/src/core/tools/helpers/imageHelpers.ts @@ -0,0 +1,192 @@ +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) + */ +export const DEFAULT_MAX_IMAGE_FILE_SIZE_MB = 5 + +/** + * Default maximum total memory usage for all images in a single read operation (20MB) + * 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_SIZE_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", +} + +/** + * 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 + */ +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]) +} + +/** + * 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 6de8dd5642..01427f4d9d 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -14,6 +14,14 @@ 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 { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + isSupportedImageFormat, + validateImageForProcessing, + processImageFile, + ImageMemoryTracker, +} from "./helpers/imageHelpers" export function getReadFileToolDescription(blockName: string, blockParams: any): string { // Handle both single path and multiple files via args @@ -66,6 +74,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 } @@ -83,6 +92,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 = "" @@ -420,6 +433,15 @@ export async function readFileTool( } } + // Track total image memory usage across all files + const imageMemoryTracker = new ImageMemoryTracker() + const state = await cline.providerRef.deref()?.getState() + const { + maxReadFileLine = -1, + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + } = state ?? {} + // Then process only approved files for (const fileResult of fileResults) { // Skip files that weren't approved @@ -429,7 +451,6 @@ export async function readFileTool( const relPath = fileResult.path const fullPath = path.resolve(cline.cwd, relPath) - const { maxReadFileLine = -1 } = (await cline.providerRef.deref()?.getState()) ?? {} // Process approved files try { @@ -440,14 +461,71 @@ export async function readFileTool( const fileExtension = path.extname(relPath).toLowerCase() const supportedBinaryFormats = getSupportedBinaryFormats() - if (!supportedBinaryFormats.includes(fileExtension)) { + // Check if it's a supported image format + if (isSupportedImageFormat(fileExtension)) { + try { + // 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${validationResult.notice}\n`, + }) + continue + } + + // Process the image + const imageResult = await processImageFile(fullPath) + + // Track memory usage for this image + imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) + + // Track file read + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + // Store image data URL separately - NOT in XML + updateFileResult(relPath, { + xmlContent: `${relPath}\n${imageResult.notice}\n`, + imageDataUrl: imageResult.dataUrl, + }) + 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 + } + } + + // 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) @@ -546,6 +624,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 +656,39 @@ export async function readFileTool( } } + // Combine all images: feedback images first, then file images + const allImages = [...feedbackImages, ...fileImageUrls] + + // 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) { - const result = formatResponse.toolResult(statusMessage, feedbackImages) + if (statusMessage || imagesToInclude.length > 0) { + // Always use formatResponse.toolResult when we have a status message or images + const result = formatResponse.toolResult( + statusMessage || filesXml, + imagesToInclude.length > 0 ? imagesToInclude : 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, we need to convert the filesXml to a text block and append it - 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 { - // No status message, just push the files XML + // No images or status message, just push the files XML pushToolResult(filesXml) } } catch (error) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 905e657b37..60d198345b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1425,6 +1425,8 @@ export class ClineProvider showRooIgnoredFiles, language, maxReadFileLine, + maxImageFileSize, + maxTotalImageSize, terminalCompressProgressBar, historyPreviewCollapsed, cloudUserInfo, @@ -1532,6 +1534,8 @@ export class ClineProvider language: language ?? formatLanguage(vscode.env.language), renderContext: this.renderContext, maxReadFileLine: maxReadFileLine ?? -1, + maxImageFileSize: maxImageFileSize ?? 5, + maxTotalImageSize: maxTotalImageSize ?? 20, maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, terminalCompressProgressBar: terminalCompressProgressBar ?? true, @@ -1702,6 +1706,8 @@ export class ClineProvider telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true, maxReadFileLine: stateValues.maxReadFileLine ?? -1, + maxImageFileSize: stateValues.maxImageFileSize ?? 5, + maxTotalImageSize: stateValues.maxTotalImageSize ?? 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 344b098816..98de24a1cd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -533,6 +533,8 @@ describe("ClineProvider", () => { showRooIgnoredFiles: true, renderContext: "sidebar", maxReadFileLine: 500, + maxImageFileSize: 5, + maxTotalImageSize: 20, cloudUserInfo: null, organizationAllowList: ORGANIZATION_ALLOW_ALL, autoCondenseContext: true, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c739c2ade8..93608e7131 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1265,6 +1265,14 @@ 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 "maxTotalImageSize": + await updateGlobalState("maxTotalImageSize", 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/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 5b3a228bde..0f10b6fc2a 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -2,7 +2,9 @@ "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.", + "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 eb1afbc082..ecf372a50b 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -2,7 +2,9 @@ "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.", + "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 0265a84398..5b88affae6 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -2,7 +2,9 @@ "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.", + "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 303f5365ed..6fd1cc2122 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -2,7 +2,9 @@ "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.", + "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 a6c71aca33..b6d7accebb 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -2,7 +2,9 @@ "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.", + "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 0cb4aeb14e..cbfbd7aef7 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -2,7 +2,9 @@ "readFile": { "linesRange": " (पंक्तियाँ {{start}}-{{end}})", "definitionsOnly": " (केवल परिभाषाएँ)", - "maxLines": " (अधिकतम {{max}} पंक्तियाँ)" + "maxLines": " (अधिकतम {{max}} पंक्तियाँ)", + "imageTooLarge": "छवि फ़ाइल बहुत बड़ी है ({{size}} MB)। अधिकतम अनुमतित आकार {{max}} MB है।", + "imageWithSize": "छवि फ़ाइल ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo एक लूप में फंसा हुआ लगता है, बार-बार एक ही क्रिया ({{toolName}}) को दोहरा रहा है। यह उसकी वर्तमान रणनीति में किसी समस्या का संकेत हो सकता है। कार्य को पुनः परिभाषित करने, अधिक विशिष्ट निर्देश देने, या उसे एक अलग दृष्टिकोण की ओर मार्गदर्शित करने पर विचार करें।", "codebaseSearch": { diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 2e3c4f0c22..3eb8854eff 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -2,7 +2,9 @@ "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.", + "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 ffae474f1d..35b114a719 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -2,7 +2,9 @@ "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.", + "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 04a5fcc085..257d5aa201 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -2,7 +2,9 @@ "readFile": { "linesRange": " ({{start}}-{{end}}行目)", "definitionsOnly": " (定義のみ)", - "maxLines": " (最大{{max}}行)" + "maxLines": " (最大{{max}}行)", + "imageTooLarge": "画像ファイルが大きすぎます({{size}} MB)。最大許可サイズは {{max}} MB です。", + "imageWithSize": "画像ファイル({{size}} KB)" }, "toolRepetitionLimitReached": "Rooが同じ操作({{toolName}})を繰り返し試みるループに陥っているようです。これは現在の方法に問題がある可能性を示しています。タスクの言い換え、より具体的な指示の提供、または別のアプローチへの誘導を検討してください。", "codebaseSearch": { diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index e43a541794..94b6d8c377 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -2,7 +2,9 @@ "readFile": { "linesRange": " ({{start}}-{{end}}행)", "definitionsOnly": " (정의만)", - "maxLines": " (최대 {{max}}행)" + "maxLines": " (최대 {{max}}행)", + "imageTooLarge": "이미지 파일이 너무 큽니다 ({{size}} MB). 최대 허용 크기는 {{max}} MB입니다.", + "imageWithSize": "이미지 파일 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo가 같은 동작({{toolName}})을 반복적으로 시도하면서 루프에 갇힌 것 같습니다. 이는 현재 전략에 문제가 있을 수 있음을 나타냅니다. 작업을 다시 표현하거나, 더 구체적인 지침을 제공하거나, 다른 접근 방식으로 안내해 보세요.", "codebaseSearch": { diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index 56a8cdbc46..449cd54583 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -2,7 +2,9 @@ "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.", + "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 62568826aa..979b2f54ae 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -2,7 +2,9 @@ "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.", + "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 f74e0f8196..4e3296fd4a 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -2,7 +2,9 @@ "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.", + "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 1e59d10499..d74918f058 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -2,7 +2,9 @@ "readFile": { "linesRange": " (строки {{start}}-{{end}})", "definitionsOnly": " (только определения)", - "maxLines": " (макс. {{max}} строк)" + "maxLines": " (макс. {{max}} строк)", + "imageTooLarge": "Файл изображения слишком большой ({{size}} МБ). Максимально допустимый размер {{max}} МБ.", + "imageWithSize": "Файл изображения ({{size}} КБ)" }, "toolRepetitionLimitReached": "Похоже, что Roo застрял в цикле, многократно пытаясь выполнить одно и то же действие ({{toolName}}). Это может указывать на проблему с его текущей стратегией. Попробуйте переформулировать задачу, предоставить более конкретные инструкции или направить его к другому подходу.", "codebaseSearch": { diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index e4c73cdc4b..5341a23cb1 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -2,7 +2,9 @@ "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.", + "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 9811ee12c9..4c5080a146 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -2,7 +2,9 @@ "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.", + "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 13641b8d43..c0c93d8436 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -2,7 +2,9 @@ "readFile": { "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (仅定义)", - "maxLines": " (最多 {{max}} 行)" + "maxLines": " (最多 {{max}} 行)", + "imageTooLarge": "图片文件过大 ({{size}} MB)。允许的最大大小为 {{max}} MB。", + "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 a726e3c919..b736448c20 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -2,7 +2,9 @@ "readFile": { "linesRange": " (第 {{start}}-{{end}} 行)", "definitionsOnly": " (僅定義)", - "maxLines": " (最多 {{max}} 行)" + "maxLines": " (最多 {{max}} 行)", + "imageTooLarge": "圖片檔案過大 ({{size}} MB)。允許的最大大小為 {{max}} MB。", + "imageWithSize": "圖片檔案 ({{size}} KB)" }, "toolRepetitionLimitReached": "Roo 似乎陷入循環,反覆嘗試同一操作 ({{toolName}})。這可能表明目前策略存在問題。請考慮重新描述工作、提供更具體的指示或引導其嘗試不同的方法。", "codebaseSearch": { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 000762e317..07d57d679f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -278,6 +278,8 @@ 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 + 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 795e276522..e8e8721b7e 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -162,6 +162,8 @@ export interface WebviewMessage { | "remoteBrowserEnabled" | "language" | "maxReadFileLine" + | "maxImageFileSize" + | "maxTotalImageSize" | "maxConcurrentFileReads" | "includeDiagnosticMessages" | "maxDiagnosticMessages" diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 4530fdb1ba..88484e1d63 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -20,6 +20,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxWorkspaceFiles: number showRooIgnoredFiles?: boolean maxReadFileLine?: number + maxImageFileSize?: number + maxTotalImageSize?: number maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean @@ -32,6 +34,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxWorkspaceFiles" | "showRooIgnoredFiles" | "maxReadFileLine" + | "maxImageFileSize" + | "maxTotalImageSize" | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" @@ -49,6 +53,8 @@ export const ContextManagementSettings = ({ showRooIgnoredFiles, setCachedStateField, maxReadFileLine, + maxImageFileSize, + maxTotalImageSize, maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, @@ -206,6 +212,62 @@ 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")} +
+
+ +
+
+ {t("settings:contextManagement.maxTotalImageSize.label")} +
+ { + const newValue = parseInt(e.target.value, 10) + if (!isNaN(newValue) && newValue >= 1 && newValue <= 500) { + setCachedStateField("maxTotalImageSize", newValue) + } + }} + onClick={(e) => e.currentTarget.select()} + data-testid="max-total-image-size-input" + /> + {t("settings:contextManagement.maxTotalImageSize.mb")} +
+
+
+ {t("settings:contextManagement.maxTotalImageSize.description")} +
+
+
(({ onDone, t showRooIgnoredFiles, remoteBrowserEnabled, maxReadFileLine, + maxImageFileSize, + maxTotalImageSize, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, @@ -321,6 +323,8 @@ 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: "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 }) @@ -667,6 +671,8 @@ const SettingsView = forwardRef(({ onDone, t maxWorkspaceFiles={maxWorkspaceFiles ?? 200} showRooIgnoredFiles={showRooIgnoredFiles} maxReadFileLine={maxReadFileLine} + maxImageFileSize={maxImageFileSize} + 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 ff1ce31c53..3d39613eda 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -120,6 +120,10 @@ export interface ExtensionStateContextType extends ExtensionState { setAwsUsePromptCache: (value: boolean) => void maxReadFileLine: number setMaxReadFileLine: (value: number) => void + maxImageFileSize: number + setMaxImageFileSize: (value: number) => void + maxTotalImageSize: number + setMaxTotalImageSize: (value: number) => void machineId?: string pinnedApiConfigs?: Record setPinnedApiConfigs: (value: Record) => void @@ -208,6 +212,8 @@ 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 + 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 @@ -448,6 +454,8 @@ 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 })), + 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/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 1e5867d3fc..d9f8101113 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -209,6 +209,8 @@ describe("mergeExtensionState", () => { sharingEnabled: false, profileThresholds: {}, hasOpenedModeSelector: false, // Add the new required property + maxImageFileSize: 5, + maxTotalImageSize: 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 ab929d724d..7264c85791 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -526,6 +526,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Mida total màxima d'imatges", + "mb": "MB", + "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 766bc891e4..1ba4286772 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -526,6 +526,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Maximale Gesamtbildgröße", + "mb": "MB", + "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 cfd5b04286..8f40db0bff 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -502,6 +502,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Max total image size", + "mb": "MB", + "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": { "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 f536863909..e6c101285d 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -502,6 +502,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Tamaño total máximo de imágenes", + "mb": "MB", + "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": { "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 0e12c58d38..bbe657082a 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -502,6 +502,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Taille totale maximale des images", + "mb": "MB", + "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": { "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 d2cfa971ff..33a23b2d11 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -527,6 +527,16 @@ "profileDescription": "केवल इस प्रोफ़ाइल के लिए कस्टम सीमा (वैश्विक डिफ़ॉल्ट को ओवरराइड करता है)", "inheritDescription": "यह प्रोफ़ाइल वैश्विक डिफ़ॉल्ट सीमा को इनहेरिट करता है ({{threshold}}%)", "usesGlobal": "(वैश्विक {{threshold}}% का उपयोग करता है)" + }, + "maxImageFileSize": { + "label": "अधिकतम छवि फ़ाइल आकार", + "mb": "MB", + "description": "छवि फ़ाइलों के लिए अधिकतम आकार (MB में) जो read file tool द्वारा प्रसंस्कृत किया जा सकता है।" + }, + "maxTotalImageSize": { + "label": "अधिकतम कुल छवि आकार", + "mb": "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 3f362f650d..12352cdeb8 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -531,6 +531,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Ukuran total gambar maksimum", + "mb": "MB", + "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 14deda20ea..5bb2e422a6 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -527,6 +527,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Dimensione totale massima immagini", + "mb": "MB", + "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 d7864e40db..eb6c30bfe1 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -527,6 +527,16 @@ "profileDescription": "このプロファイルのみのカスタムしきい値(グローバルデフォルトを上書き)", "inheritDescription": "このプロファイルはグローバルデフォルトしきい値を継承します({{threshold}}%)", "usesGlobal": "(グローバル {{threshold}}% を使用)" + }, + "maxImageFileSize": { + "label": "最大画像ファイルサイズ", + "mb": "MB", + "description": "read fileツールで処理できる画像ファイルの最大サイズ(MB単位)。" + }, + "maxTotalImageSize": { + "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 adbe216463..9bbaa6ca0c 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -527,6 +527,16 @@ "profileDescription": "이 프로필만을 위한 사용자 정의 임계값 (글로벌 기본값 재정의)", "inheritDescription": "이 프로필은 글로벌 기본 임계값을 상속합니다 ({{threshold}}%)", "usesGlobal": "(글로벌 {{threshold}}% 사용)" + }, + "maxImageFileSize": { + "label": "최대 이미지 파일 크기", + "mb": "MB", + "description": "read file 도구로 처리할 수 있는 이미지 파일의 최대 크기(MB 단위)입니다." + }, + "maxTotalImageSize": { + "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 6085be8fb1..228da40231 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -502,6 +502,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Maximale totale afbeeldingsgrootte", + "mb": "MB", + "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": { "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 9107fd959e..615c0d68d7 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -527,6 +527,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Maksymalny całkowity rozmiar obrazów", + "mb": "MB", + "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 8137d917ae..96ab854bfb 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -527,6 +527,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Tamanho total máximo da imagem", + "mb": "MB", + "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 e93f24e9d7..bda495d04a 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -527,6 +527,16 @@ "profileDescription": "Пользовательский порог только для этого профиля (переопределяет глобальный по умолчанию)", "inheritDescription": "Этот профиль наследует глобальный порог по умолчанию ({{threshold}}%)", "usesGlobal": "(использует глобальный {{threshold}}%)" + }, + "maxImageFileSize": { + "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 450738183b..e3e71633f3 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -527,6 +527,16 @@ "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)." + }, + "maxTotalImageSize": { + "label": "Maksimum toplam görüntü boyutu", + "mb": "MB", + "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 87e99ce6f5..9bcbb1263a 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -527,6 +527,16 @@ "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." + }, + "maxTotalImageSize": { + "label": "Kích thước tổng tối đa của hình ảnh", + "mb": "MB", + "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 e94c857e65..5cbd80aed6 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -527,6 +527,16 @@ "profileDescription": "仅此配置文件的自定义阈值(覆盖全局默认)", "inheritDescription": "此配置文件继承全局默认阈值({{threshold}}%)", "usesGlobal": "(使用全局 {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "最大图像文件大小", + "mb": "MB", + "description": "read file工具可以处理的图像文件的最大大小(以MB为单位)。" + }, + "maxTotalImageSize": { + "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 fa10cf3028..b9e9f1ef06 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -527,6 +527,16 @@ "profileDescription": "僅此檔案的自訂閾值(覆蓋全域預設)", "inheritDescription": "此檔案繼承全域預設閾值({{threshold}}%)", "usesGlobal": "(使用全域 {{threshold}}%)" + }, + "maxImageFileSize": { + "label": "最大圖像檔案大小", + "mb": "MB", + "description": "read file工具可以處理的圖像檔案的最大大小(以MB為單位)。" + }, + "maxTotalImageSize": { + "label": "圖片總大小上限", + "mb": "MB", + "description": "單次 read_file 操作中處理的所有圖片的最大累計大小限制(MB)。讀取多張圖片時,每張圖片的大小會累加到總大小中。如果包含另一張圖片會超過此限制,則會跳過該圖片。" } }, "terminal": {