diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts index 998b08e3f4..4cea362bf7 100644 --- a/src/api/providers/__tests__/base-provider.spec.ts +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -5,6 +5,7 @@ import type { ModelInfo } from "@roo-code/types" import { BaseProvider } from "../base-provider" import type { ApiStream } from "../../transform/stream" import { expectNoLoneSurrogates } from "../../../test-utils/surrogates" +import { createReadFileTool } from "../../../core/prompts/tools/native-tools/read_file" // Create a concrete implementation for testing class TestProvider extends BaseProvider { @@ -270,6 +271,37 @@ describe("BaseProvider", () => { expect(result.properties.only.type).toBe("null") }) describe("convertToolsForOpenAI", () => { + it("preserves read_file integer minima while requiring properties for strict mode", () => { + const result = provider.testConvertToolsForOpenAI([createReadFileTool()]) + + expect(result?.[0]).toMatchObject({ + function: { + strict: true, + parameters: { + required: ["path", "mode", "offset", "limit", "indentation"], + properties: { + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 }, + indentation: { + required: [ + "anchor_line", + "max_levels", + "include_siblings", + "include_header", + "max_lines", + ], + properties: { + anchor_line: { type: "integer", minimum: 1 }, + max_levels: { type: "integer", minimum: 0 }, + max_lines: { type: "integer", minimum: 1 }, + }, + }, + }, + }, + }, + }) + }) + it("should return undefined for undefined input", () => { const result = provider.testConvertToolsForOpenAI(undefined) expect(result).toBeUndefined() diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index d55d69b431..8d4c7e90be 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -11,6 +11,7 @@ import { toolParamNames, } from "../../shared/tools" import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode" +import { validateReadFileNumbers } from "../tools/file-reading/readFileValidation" import type { ApiStreamToolCallStartChunk, ApiStreamToolCallDeltaChunk, @@ -831,6 +832,11 @@ export class NativeToolCallParser { } // New format: { path: "...", mode: "..." } if (!nativeArgs && args.path !== undefined) { + const validationError = validateReadFileNumbers(args) + if (validationError) { + console.error(`Invalid read_file parameters: ${validationError}`) + return null + } nativeArgs = { path: args.path, mode: args.mode, diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 67a7953159..954e8e71e1 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -3,6 +3,82 @@ import { NativeToolCallParser, type ToolCallStreamEvent } from "../NativeToolCal describe("NativeToolCallParser", () => { describe("parseToolCall", () => { describe("read_file tool", () => { + it("rejects a zero limit instead of constructing successful read arguments", () => { + const result = NativeToolCallParser.parseToolCall({ + id: "invalid_limit", + name: "read_file", + arguments: JSON.stringify({ path: "test.ts", limit: 0 }), + }) + + expect(result).toBeNull() + }) + + it.each( + ["offset", "limit", "anchor_line", "max_levels", "max_lines"].flatMap((name) => + [-1, 1.5, "1", "NaN", true, [], {}].map((value) => ({ name, value })), + ), + )("rejects malformed supplied $name=$value before coercion", ({ name, value }) => { + const args = + name === "offset" || name === "limit" + ? { path: "test.ts", [name]: value } + : { path: "test.ts", mode: "slice", indentation: { [name]: value } } + + expect( + NativeToolCallParser.parseToolCall({ + id: "invalid_number", + name: "read_file", + arguments: JSON.stringify(args), + }), + ).toBeNull() + }) + + it.each(["offset", "limit", "anchor_line", "max_lines"])("rejects zero for positive integer %s", (name) => { + const args = + name === "offset" || name === "limit" + ? { path: "test.ts", [name]: 0 } + : { path: "test.ts", indentation: { [name]: 0 } } + + expect( + NativeToolCallParser.parseToolCall({ + id: "invalid_zero", + name: "read_file", + arguments: JSON.stringify(args), + }), + ).toBeNull() + }) + + it("rejects a nonfinite number produced by JSON exponent overflow", () => { + expect( + NativeToolCallParser.parseToolCall({ + id: "nonfinite", + name: "read_file", + arguments: '{"path":"test.ts","limit":1e309}', + }), + ).toBeNull() + }) + + it.each([ + { offset: 1, limit: 1, indentation: { anchor_line: 1, max_levels: 0, max_lines: 1 } }, + { offset: null, limit: null, indentation: { anchor_line: null, max_levels: null, max_lines: null } }, + { indentation: null }, + ])("keeps valid boundary values and null-as-unset numbers: %j", (numbers) => { + const result = NativeToolCallParser.parseToolCall({ + id: "valid_numbers", + name: "read_file", + arguments: JSON.stringify({ path: "test.ts", ...numbers }), + }) + + expect(result).toMatchObject({ type: "tool_use", nativeArgs: { path: "test.ts" } }) + if (result?.type !== "tool_use" || !result.nativeArgs || "files" in result.nativeArgs) { + throw new Error("Expected modern read_file arguments") + } + expect(result.nativeArgs.offset).toBe(numbers.offset ?? undefined) + expect(result.nativeArgs.limit).toBe(numbers.limit ?? undefined) + expect(result.nativeArgs.indentation?.anchor_line).toBe(numbers.indentation?.anchor_line ?? undefined) + expect(result.nativeArgs.indentation?.max_levels).toBe(numbers.indentation?.max_levels ?? undefined) + expect(result.nativeArgs.indentation?.max_lines).toBe(numbers.indentation?.max_lines ?? undefined) + }) + it("should parse minimal single-file read_file args", () => { const toolCall = { id: "toolu_123", diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts index 7381b2c2d0..384e2918fe 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -4,6 +4,7 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" import { presentAssistantMessage } from "../presentAssistantMessage" import { validateToolUse } from "../../tools/validateToolUse" +import { ReadFileTool } from "../../tools/file-reading/ReadFileTool" import { getModeBySlug } from "../../../shared/modes" import type { Task } from "../../task/Task" @@ -155,6 +156,127 @@ describe("presentAssistantMessage - tool usage attribution", () => { expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") }) + describe("shared read_file tool", () => { + it("reuses one instance for partial and complete calls across tasks", async () => { + const handle = vi.spyOn(ReadFileTool.prototype, "handle").mockResolvedValue(undefined) + const otherTask = { ...mockTask, taskId: "other-task-id", instanceId: "other-instance" } + + try { + for (const task of [mockTask, otherTask]) { + for (const partial of [true, false]) { + task.assistantMessageContent = [ + { + type: "tool_use", + id: `call_${task.taskId}`, + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial, + }, + ] + + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(task as unknown as Task) + } + } + + expect(handle).toHaveBeenCalledTimes(4) + const sharedInstance = handle.mock.contexts[0] + expect(sharedInstance).toBeInstanceOf(ReadFileTool) + for (const instance of handle.mock.contexts) { + expect(instance).toBe(sharedInstance) + } + } finally { + handle.mockRestore() + } + }) + + it("preserves the injected reader when recursively processing tool calls", async () => { + const reader = { + handle: vi.fn().mockResolvedValue(undefined), + getReadFileToolDescription: vi.fn(() => ""), + } + mockTask.assistantMessageContent = ["first.txt", "second.txt"].map((path, index) => ({ + type: "tool_use", + id: `call_read_${index}`, + name: "read_file", + params: { path }, + nativeArgs: { path }, + partial: false, + })) + + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(mockTask as unknown as Task, reader) + + expect(reader.handle).toHaveBeenCalledTimes(2) + for (const instance of reader.handle.mock.contexts) { + expect(instance).toBe(reader) + } + }) + + it("preserves the injected reader when processing pending streaming updates", async () => { + const reader = { + handle: vi + .fn() + .mockResolvedValue(undefined) + .mockImplementationOnce(async (task) => { + task.presentAssistantMessageHasPendingUpdates = true + }), + getReadFileToolDescription: vi.fn(() => ""), + } + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_streaming_read", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: true, + }, + ] + + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(mockTask as unknown as Task, reader) + + expect(reader.handle).toHaveBeenCalledTimes(2) + for (const instance of reader.handle.mock.contexts) { + expect(instance).toBe(reader) + } + }) + + it.each([ + { nativeArgs: { path: "native.txt" }, expectedPath: "native.txt" }, + { nativeArgs: undefined, expectedPath: "legacy.txt" }, + ])("uses the injected reader to describe $expectedPath", async ({ nativeArgs, expectedPath }) => { + const reader = new ReadFileTool() + const describeTool = vi.spyOn(reader, "getReadFileToolDescription") + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_rejected_read", + name: "read_file", + params: { path: "legacy.txt" }, + nativeArgs, + partial: false, + }, + ] + + try { + // This dispatch double omits Task fields unrelated to tool presentation. + await presentAssistantMessage(mockTask as unknown as Task, reader) + + expect(describeTool).toHaveBeenCalledExactlyOnceWith("read_file", { path: expectedPath }) + expect(describeTool.mock.contexts[0]).toBe(reader) + expect(mockTask.pushToolResultToUserContent).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining(expectedPath) }), + ) + } finally { + describeTool.mockRestore() + } + }) + }) + it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => { mockTask.assistantMessageContent = [ { diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 417dcf7a45..d9e6a13fc9 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -17,7 +17,7 @@ import { AskIgnoredError } from "../task/AskIgnoredError" import { Task } from "../task/Task" import { listFilesTool } from "../tools/ListFilesTool" -import { readFileTool } from "../tools/ReadFileTool" +import { ReadFileTool } from "../tools/file-reading/ReadFileTool" import { readCommandOutputTool } from "../tools/ReadCommandOutputTool" import { writeToFileTool } from "../tools/WriteToFileTool" import { editTool } from "../tools/EditTool" @@ -44,6 +44,8 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +const defaultReadFileTool = new ReadFileTool() + /** * Maps a raw, potentially model-controlled tool name to a safe analytics key. * Never returns the raw name unless it is a known static tool, so an @@ -86,7 +88,10 @@ export function toTelemetryToolName( * as it becomes available. */ -export async function presentAssistantMessage(cline: Task) { +export async function presentAssistantMessage( + cline: Task, + readFileTool: Pick = defaultReadFileTool, +) { if (cline.abort) { return } @@ -103,7 +108,7 @@ export async function presentAssistantMessage(cline: Task) { // The last pending check and the release below run in the same // synchronous step, so no update can be stranded behind the lock. do { - await presentAssistantMessageBlock(cline) + await presentAssistantMessageBlock(cline, readFileTool) } while (!cline.abort && cline.presentAssistantMessageHasPendingUpdates) } finally { // Tool handlers and provider-state reads can reject. Never strand the @@ -112,7 +117,10 @@ export async function presentAssistantMessage(cline: Task) { } } -async function presentAssistantMessageBlock(cline: Task): Promise { +async function presentAssistantMessageBlock( + cline: Task, + readFileTool: Pick, +): Promise { if (cline.abort) { return } @@ -1124,7 +1132,7 @@ async function presentAssistantMessageBlock(cline: Task): Promise { if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) { // There are already more content blocks to stream, so we'll call // this function ourselves. - return await presentAssistantMessageBlock(cline) + return await presentAssistantMessageBlock(cline, readFileTool) } else { // CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady // This handles the case where assistantMessageContent is empty or becomes empty after processing diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index 1bfb90d23f..fbd75f9312 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -11,7 +11,7 @@ import { getCommitInfo, getWorkingState } from "../../utils/git" import { openFile } from "../../integrations/misc/open-file" import { extractTextFromFileWithMetadata, type ExtractTextResult } from "../../integrations/misc/extract-text" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import { DEFAULT_LINE_LIMIT } from "../tools/file-reading/readFileConstants" import { FileContextTracker } from "../context-tracking/FileContextTracker" diff --git a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts index dded7fba50..023c72a6cb 100644 --- a/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/read_file.spec.ts @@ -1,5 +1,6 @@ import type OpenAI from "openai" import { createReadFileTool } from "../read_file" +import { normalizeToolSchema, ToolInputSchema } from "../../../../../utils/json-schema" // Helper type to access function tools type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } @@ -8,6 +9,31 @@ type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" } const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function describe("createReadFileTool", () => { + it("keeps integer lower bounds in the native and normalized strict schemas", () => { + const schema = getFunctionDef(createReadFileTool()).parameters + if (!schema) throw new Error("read_file parameters are required") + const expected = { + properties: { + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 }, + indentation: { + properties: { + anchor_line: { type: "integer", minimum: 1 }, + max_levels: { type: "integer", minimum: 0 }, + max_lines: { type: "integer", minimum: 1 }, + }, + additionalProperties: false, + }, + }, + required: ["path"], + additionalProperties: false, + } + + expect(schema).toMatchObject(expected) + expect(ToolInputSchema.parse(schema)).toMatchObject(expected) + expect(normalizeToolSchema(schema)).toMatchObject(expected) + }) + describe("single-file-per-call documentation", () => { it("should indicate single-file-per-call and suggest parallel tool calls", () => { const tool = createReadFileTool() @@ -52,22 +78,21 @@ describe("createReadFileTool", () => { }) describe("supportsImages option", () => { - it("should include image format documentation when supportsImages is true", () => { + it("should advertise only shared-contract image formats when supportsImages is true", () => { const tool = createReadFileTool({ supportsImages: true }) const description = getFunctionDef(tool).description expect(description).toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", + "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, WEBP) for visual analysis", ) + expect(description).toContain("Other binary image formats are not supported") }) it("should not include image format documentation when supportsImages is false", () => { const tool = createReadFileTool({ supportsImages: false }) const description = getFunctionDef(tool).description - expect(description).not.toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) + expect(description).not.toContain("Automatically processes and returns image files") expect(description).toContain("may not handle other binary files properly") }) @@ -75,9 +100,7 @@ describe("createReadFileTool", () => { const tool = createReadFileTool({}) const description = getFunctionDef(tool).description - expect(description).not.toContain( - "Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis", - ) + expect(description).not.toContain("Automatically processes and returns image files") }) it("should always include PDF and DOCX support in description", () => { diff --git a/src/core/prompts/tools/native-tools/read_file.ts b/src/core/prompts/tools/native-tools/read_file.ts index 35d6a0d53a..1ed89548c2 100644 --- a/src/core/prompts/tools/native-tools/read_file.ts +++ b/src/core/prompts/tools/native-tools/read_file.ts @@ -1,15 +1,9 @@ import type OpenAI from "openai" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../../../tools/file-reading/readFileConstants" +import { READ_FILE_NUMERIC_CONSTRAINTS } from "../../../tools/file-reading/readFileValidation" -// ─── Constants ──────────────────────────────────────────────────────────────── - -/** Default maximum lines to return per file (Codex-inspired predictable limit) */ -export const DEFAULT_LINE_LIMIT = 2000 - -/** Maximum characters per line before truncation */ -export const MAX_LINE_LENGTH = 2000 - -/** Default indentation levels to include above anchor (0 = unlimited) */ -export const DEFAULT_MAX_LEVELS = 0 +// Preserve older imports without making runtime readers depend on this tool definition. +export { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH, DEFAULT_MAX_LEVELS } from "../../../tools/file-reading/readFileConstants" // ─── Helper Functions ───────────────────────────────────────────────────────── @@ -21,7 +15,7 @@ export const DEFAULT_MAX_LEVELS = 0 */ function getReadFileSupportsNote(supportsImages: boolean): string { if (supportsImages) { - return `Supports text extraction from PDF and DOCX files. Automatically processes and returns image files (PNG, JPG, JPEG, GIF, BMP, SVG, WEBP, ICO, AVIF) for visual analysis. May not handle other binary files properly.` + return `Supports text extraction from PDF and DOCX files. Automatically processes and returns image files (PNG, JPG, JPEG, GIF, WEBP) for visual analysis. Other binary image formats are not supported by the shared image-block contract. May not handle other binary files properly.` } return `Supports text extraction from PDF and DOCX files, but may not handle other binary files properly.` } @@ -83,12 +77,12 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch const indentationProperties: Record = { anchor_line: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.anchor_line, description: "1-based line number to anchor the extraction. REQUIRED for meaningful indentation mode results. The extractor finds the semantic block (function, method, class) containing this line and returns it completely. Without anchor_line, indentation mode defaults to line 1 and returns only imports/header content. Obtain anchor_line from: search results, error stack traces, definition lookups, codebase_search results, or condensed file summaries (e.g., '14--28 | export class UserService' means anchor_line=14).", }, max_levels: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.max_levels, description: `Maximum indentation levels to include above the anchor (indentation mode, 0 = unlimited (default)). Higher values include more parent context.`, }, include_siblings: { @@ -102,7 +96,7 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch "Include file header content (imports, module-level comments) at the top of output (indentation mode, default: true).", }, max_lines: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.max_lines, description: "Hard cap on lines returned for indentation mode. Acts as a separate limit from the top-level 'limit' parameter.", }, @@ -120,11 +114,11 @@ export function createReadFileTool(options: ReadFileToolOptions = {}): OpenAI.Ch "Reading mode. 'slice' (default): read lines sequentially with offset/limit - use for general file exploration or when you don't have a target line number (may truncate code mid-function). 'indentation': extract complete semantic code blocks containing anchor_line - PREFERRED when you have a line number because it guarantees complete, valid code blocks. WARNING: Do not use indentation mode without specifying indentation.anchor_line, or you will only get header content.", }, offset: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.offset, description: "1-based line offset to start reading from (slice mode, default: 1)", }, limit: { - type: "integer", + ...READ_FILE_NUMERIC_CONSTRAINTS.limit, description: `Maximum number of lines to return (slice mode, default: ${DEFAULT_LINE_LIMIT})`, }, indentation: { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index d6be42f0bd..a1451b0f87 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -36,7 +36,7 @@ import type { ApiMessage } from "../../task-persistence" import { asyncStreamFrom } from "../../../test-utils/stream" import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" -import { readFileTool } from "../../tools/ReadFileTool" +import { ReadFileTool } from "../../tools/file-reading/ReadFileTool" import { writeToFileTool } from "../../tools/WriteToFileTool" type TaskTestAccess = { @@ -837,7 +837,7 @@ describe("Cline", () => { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) const readFileHandleSpy = vi - .spyOn(readFileTool, "handle") + .spyOn(ReadFileTool.prototype, "handle") .mockImplementation(async (_task, block, callbacks) => { if (!block.partial) callbacks.pushToolResult(`File: ${block.params.path}\nfinished`) }) @@ -929,7 +929,7 @@ describe("Cline", () => { handlerPushedResult = resolve }) const readFileHandleSpy = vi - .spyOn(readFileTool, "handle") + .spyOn(ReadFileTool.prototype, "handle") .mockImplementation(async (_task, block, callbacks) => { if (block.partial) return callbacks.pushToolResult("File: README.md\nfinished") @@ -983,7 +983,7 @@ describe("Cline", () => { vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo) const readFileHandleSpy = vi - .spyOn(readFileTool, "handle") + .spyOn(ReadFileTool.prototype, "handle") .mockImplementation(async (_task, block, callbacks) => { if (!block.partial) callbacks.pushToolResult("File: README.md\nfinished") }) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts deleted file mode 100644 index 00fc507692..0000000000 --- a/src/core/tools/ReadFileTool.ts +++ /dev/null @@ -1,816 +0,0 @@ -/** - * ReadFileTool - Codex-inspired file reading with indentation mode support. - * - * Supports two modes: - * 1. Slice mode (default): Read contiguous lines with offset/limit - * 2. Indentation mode: Extract semantic code blocks based on indentation hierarchy - * - * Also supports legacy format for backward compatibility: - * - Legacy format: { files: [{ path: string, lineRanges?: [...] }] } - */ -import path from "path" -import * as fs from "fs/promises" -import { isBinaryFile } from "isbinaryfile" - -import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineRange } from "@roo-code/types" -import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" - -import { Task } from "../task/Task" -import { formatResponse } from "../prompts/responses" -import { RecordSource } from "../context-tracking/FileContextTrackerTypes" -import { isPathOutsideWorkspace } from "../../utils/pathUtils" -import { getReadablePath } from "../../utils/path" -import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" -import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" -import type { ToolUse, PushToolResult } from "../../shared/tools" - -import { - DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - isSupportedImageFormat, - validateImageForProcessing, - processImageFile, - ImageMemoryTracker, -} from "./helpers/imageHelpers" -import { BaseTool, ToolCallbacks } from "./BaseTool" - -// ─── Types ──────────────────────────────────────────────────────────────────── - -/** - * Internal entry structure for tracking file read parameters. - */ -interface InternalFileEntry { - path: string - mode?: ReadFileMode - offset?: number - limit?: number - anchor_line?: number - max_levels?: number - include_siblings?: boolean - include_header?: boolean - max_lines?: number -} - -interface FileResult { - path: string - status: "approved" | "denied" | "blocked" | "error" | "pending" - content?: string - error?: string - notice?: string - nativeContent?: string - imageDataUrl?: string - feedbackText?: string - feedbackImages?: string[] - // Store the original entry for mode processing - entry?: InternalFileEntry -} - -// ─── Tool Implementation ────────────────────────────────────────────────────── - -export class ReadFileTool extends BaseTool<"read_file"> { - readonly name = "read_file" as const - - async execute(params: ReadFileToolParams, task: Task, callbacks: ToolCallbacks): Promise { - // Dispatch to legacy or new execution path based on format - if (isLegacyReadFileParams(params)) { - return this.executeLegacy(params.files, task, callbacks) - } - - return this.executeNew(params, task, callbacks) - } - - /** - * Execute new single-file format with slice/indentation mode support. - */ - private async executeNew(params: ReadFileParams, task: Task, callbacks: ToolCallbacks): Promise { - const { pushToolResult } = callbacks - const modelInfo = task.api.getModel().info - const filePath = params.path - - // Validate input - if (!filePath) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = await task.sayAndCreateMissingParamError("read_file", "path") - pushToolResult(`Error: ${errorMsg}`) - return - } - - const supportsImages = modelInfo.supportsImages ?? false - - // Initialize file results tracking - // Validate line number parameters (must be 1-indexed positive integers) - if (params.offset !== undefined && params.offset < 1) { - const errorMsg = `offset must be a 1-indexed line number (got ${params.offset}). Line numbers start at 1.` - pushToolResult(`Error: ${errorMsg}`) - return - } - if (params.indentation?.anchor_line !== undefined && params.indentation.anchor_line < 1) { - const errorMsg = `anchor_line must be a 1-indexed line number (got ${params.indentation.anchor_line}). Line numbers start at 1.` - pushToolResult(`Error: ${errorMsg}`) - return - } - - const fileEntry: InternalFileEntry = { - path: filePath, - mode: params.mode, - offset: params.offset, - limit: params.limit, - anchor_line: params.indentation?.anchor_line, - max_levels: params.indentation?.max_levels, - include_siblings: params.indentation?.include_siblings, - include_header: params.indentation?.include_header, - max_lines: params.indentation?.max_lines, - } - - const fileResults: FileResult[] = [ - { - path: filePath, - status: "pending" as const, - entry: fileEntry, - }, - ] - - const updateFileResult = (filePath: string, updates: Partial) => { - const index = fileResults.findIndex((result) => result.path === filePath) - if (index !== -1) { - fileResults[index] = { ...fileResults[index], ...updates } - } - } - - try { - // Phase 1: Validate and filter files for approval - const filesToApprove: FileResult[] = [] - - for (const fileResult of fileResults) { - const relPath = fileResult.path - - // RooIgnore validation - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await task.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - updateFileResult(relPath, { - status: "blocked", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - continue - } - - filesToApprove.push(fileResult) - } - - // Phase 2: Request user approval - await this.requestApproval(task, filesToApprove, updateFileResult) - - // Phase 3: Process approved files - const imageMemoryTracker = new ImageMemoryTracker() - const state = await task.providerRef.deref()?.getState() - const { - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - - for (const fileResult of fileResults) { - if (fileResult.status !== "approved") continue - - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const entry = fileResult.entry! - - try { - // Check if path is a directory - const stats = await fs.stat(fullPath) - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory. Use list_files tool instead.` - updateFileResult(relPath, { - status: "error", - error: errorMsg, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - continue - } - - // Check for binary file - const isBinary = await isBinaryFile(fullPath) - - if (isBinary) { - await this.handleBinaryFile( - task, - relPath, - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker, - updateFileResult, - ) - continue - } - - // Read text file content with lossy UTF-8 conversion - // Reading as Buffer first allows graceful handling of non-UTF8 bytes - // (they become U+FFFD replacement characters instead of throwing) - const buffer = await fs.readFile(fullPath) - const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, - }) - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - } - } - - // Phase 4: Build and return result - const hasErrors = fileResults.some((r) => r.status === "error" || r.status === "blocked") - if (hasErrors) { - task.didToolFailInCurrentTurn = true - } - - this.buildAndPushResult(task, fileResults, pushToolResult) - } catch (error) { - const relPath = filePath || "unknown" - const errorMsg = error instanceof Error ? error.message : String(error) - - updateFileResult(relPath, { - status: "error", - error: `Error reading file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - task.didToolFailInCurrentTurn = true - - const errorResult = fileResults - .filter((r) => r.nativeContent) - .map((r) => r.nativeContent) - .join("\n\n---\n\n") - - pushToolResult(errorResult || `Error: ${errorMsg}`) - } - } - - /** - * Process a text file according to the requested mode. - */ - private processTextFile(content: string, entry: InternalFileEntry): string { - const mode = entry.mode || "slice" - - if (mode === "indentation") { - // Indentation mode: semantic block extraction - // When anchor_line is not provided, default to offset (which defaults to 1) - const anchorLine = entry.anchor_line ?? entry.offset ?? 1 - const result = readWithIndentation(content, { - anchorLine, - maxLevels: entry.max_levels, - includeSiblings: entry.include_siblings, - includeHeader: entry.include_header, - limit: entry.limit ?? DEFAULT_LINE_LIMIT, - maxLines: entry.max_lines, - }) - - let output = result.content - - if (result.wasTruncated && result.includedRanges.length > 0) { - const [start, end] = result.includedRanges[0] - const nextOffset = end + 1 - const effectiveLimit = entry.limit ?? DEFAULT_LINE_LIMIT - // Put truncation warning at TOP (before content) to match @ mention format - output = `IMPORTANT: File content truncated. - Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. - To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. - - ${result.content}` - } else if (result.includedRanges.length > 0) { - const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") - output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` - } - - return output - } - - // Slice mode (default): simple offset/limit reading - // NOTE: read_file offset is 1-based externally; convert to 0-based for readWithSlice. - const offset1 = entry.offset ?? 1 - const offset0 = Math.max(0, offset1 - 1) - const limit = entry.limit ?? DEFAULT_LINE_LIMIT - - const result = readWithSlice(content, offset0, limit) - - let output = result.content - - if (result.wasTruncated) { - const startLine = offset1 - const endLine = offset1 + result.returnedLines - 1 - const nextOffset = endLine + 1 - // Put truncation warning at TOP (before content) to match @ mention format - output = `IMPORTANT: File content truncated. - Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. - To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. - - ${result.content}` - } else if (result.returnedLines === 0) { - output = "Note: File is empty" - } - - return output - } - - /** - * Handle binary file processing (images, PDF, DOCX, etc.). - */ - private async handleBinaryFile( - task: Task, - relPath: string, - fullPath: string, - supportsImages: boolean, - maxImageFileSize: number, - maxTotalImageSize: number, - imageMemoryTracker: ImageMemoryTracker, - updateFileResult: (path: string, updates: Partial) => void, - ): Promise { - const fileExtension = path.extname(relPath).toLowerCase() - const supportedBinaryFormats = getSupportedBinaryFormats() - - // Handle image files - if (isSupportedImageFormat(fileExtension)) { - try { - const validationResult = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - imageMemoryTracker.getTotalMemoryUsed(), - ) - - if (!validationResult.isValid) { - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nNote: ${validationResult.notice}`, - }) - return - } - - const imageResult = await processImageFile(fullPath) - imageMemoryTracker.addMemoryUsage(imageResult.sizeInMB) - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: `File: ${relPath}\nNote: ${imageResult.notice}`, - imageDataUrl: imageResult.dataUrl, - }) - return - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error reading image file: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error reading image file ${relPath}: ${errorMsg}`) - return - } - } - - // Handle other supported binary formats (PDF, DOCX, etc.) - if (supportedBinaryFormats && supportedBinaryFormats.includes(fileExtension)) { - try { - const content = await extractTextFromFile(fullPath) - const numberedContent = addLineNumbers(content) - const lineCount = content.split("\n").length - - await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) - - updateFileResult(relPath, { - nativeContent: - lineCount > 0 - ? `File: ${relPath}\nLines 1-${lineCount}:\n${numberedContent}` - : `File: ${relPath}\nNote: File is empty`, - }) - return - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - updateFileResult(relPath, { - status: "error", - error: `Error extracting text: ${errorMsg}`, - nativeContent: `File: ${relPath}\nError: ${errorMsg}`, - }) - await task.say("error", `Error extracting text from ${relPath}: ${errorMsg}`) - return - } - } - - // Unsupported binary format - const fileFormat = fileExtension.slice(1) || "bin" - updateFileResult(relPath, { - notice: `Binary file format: ${fileFormat}`, - nativeContent: `File: ${relPath}\nBinary file (${fileFormat}) - content not displayed`, - }) - } - - /** - * Request user approval for file reads. - */ - private async requestApproval( - task: Task, - filesToApprove: FileResult[], - updateFileResult: (path: string, updates: Partial) => void, - ): Promise { - if (filesToApprove.length === 0) return - - if (filesToApprove.length > 1) { - // Batch approval - const batchFiles = filesToApprove.map((fileResult) => { - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const readablePath = getReadablePath(task.cwd, relPath) - - const lineSnippet = this.getLineSnippet(fileResult.entry!) - const key = `${readablePath}${lineSnippet ? ` (${lineSnippet})` : ""}` - - return { path: readablePath, lineSnippet, isOutsideWorkspace, key, content: fullPath } - }) - - const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response === "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - filesToApprove.forEach((fr) => { - updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) - }) - } else if (response === "noButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - filesToApprove.forEach((fr) => { - updateFileResult(fr.path, { - status: "denied", - nativeContent: `File: ${fr.path}\nStatus: Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - }) - } else { - // Individual permissions - try { - const individualPermissions = JSON.parse(text || "{}") - let hasAnyDenial = false - - batchFiles.forEach((batchFile, index) => { - const fileResult = filesToApprove[index] - const approved = individualPermissions[batchFile.key] === true - - if (approved) { - updateFileResult(fileResult.path, { status: "approved" }) - } else { - hasAnyDenial = true - updateFileResult(fileResult.path, { - status: "denied", - nativeContent: `File: ${fileResult.path}\nStatus: Denied by user`, - }) - } - }) - - if (hasAnyDenial) task.didRejectTool = true - } catch { - task.didRejectTool = true - filesToApprove.forEach((fr) => { - updateFileResult(fr.path, { - status: "denied", - nativeContent: `File: ${fr.path}\nStatus: Denied by user`, - }) - }) - } - } - } else { - // Single file approval - const fileResult = filesToApprove[0] - const relPath = fileResult.path - const fullPath = path.resolve(task.cwd, relPath) - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const lineSnippet = this.getLineSnippet(fileResult.entry!) - - const startLine = this.getStartLine(fileResult.entry!) - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(task.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet, - startLine, - } satisfies ClineSayTool) - - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - updateFileResult(relPath, { - status: "denied", - nativeContent: `File: ${relPath}\nStatus: Denied by user`, - feedbackText: text, - feedbackImages: images, - }) - } else { - if (text) await task.say("user_feedback", text, images) - updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) - } - } - } - - /** - * Get the starting line number for navigation purposes. - */ - private getStartLine(entry: InternalFileEntry): number | undefined { - if (entry.mode === "indentation") { - // For indentation mode, always return the effective anchor line - return entry.anchor_line ?? entry.offset ?? 1 - } - const offset = entry.offset ?? 1 - return offset > 1 ? offset : undefined - } - - /** - * Generate a human-readable line snippet for approval messages. - */ - private getLineSnippet(entry: InternalFileEntry): string { - if (entry.mode === "indentation") { - // Always show indentation mode with the effective anchor line - const effectiveAnchor = entry.anchor_line ?? entry.offset ?? 1 - return `(indentation mode at line ${effectiveAnchor})` - } - - const limit = entry.limit ?? DEFAULT_LINE_LIMIT - const offset1 = entry.offset ?? 1 - - if (offset1 > 1) { - return `(lines ${offset1}-${offset1 + limit - 1})` - } - - // Always show the line limit, even when using the default - return `(up to ${limit} lines)` - } - - /** - * Build and push the final result to the tool output. - */ - private buildAndPushResult(task: Task, fileResults: FileResult[], pushToolResult: PushToolResult): void { - const finalResult = fileResults - .filter((r) => r.nativeContent) - .map((r) => r.nativeContent) - .join("\n\n---\n\n") - - const fileImageUrls = fileResults.filter((r) => r.imageDataUrl).map((r) => r.imageDataUrl as string) - - let statusMessage = "" - let feedbackImages: string[] = [] - - const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText) - - if (deniedWithFeedback?.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) - feedbackImages = deniedWithFeedback.feedbackImages || [] - } else if (task.didRejectTool) { - statusMessage = formatResponse.toolDenied() - } else { - const approvedWithFeedback = fileResults.find((r) => r.status === "approved" && r.feedbackText) - if (approvedWithFeedback?.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - feedbackImages = approvedWithFeedback.feedbackImages || [] - } - } - - const allImages = [...feedbackImages, ...fileImageUrls] - const finalModelSupportsImages = task.api.getModel().info.supportsImages ?? false - const imagesToInclude = finalModelSupportsImages ? allImages : [] - - if (statusMessage || imagesToInclude.length > 0) { - const result = formatResponse.toolResult( - statusMessage || finalResult, - imagesToInclude.length > 0 ? imagesToInclude : undefined, - ) - - if (typeof result === "string") { - pushToolResult(statusMessage ? `${result}\n${finalResult}` : result) - } else { - if (statusMessage) { - const textBlock = { type: "text" as const, text: finalResult } - pushToolResult([...result, textBlock] as any) - } else { - pushToolResult(result as any) - } - } - } else { - pushToolResult(finalResult) - } - } - - getReadFileToolDescription(blockName: string, blockParams: { path?: string }): string - getReadFileToolDescription(blockName: string, nativeArgs: ReadFileParams): string - getReadFileToolDescription(blockName: string, second: unknown): string { - // If native typed args were provided - if (second && typeof second === "object" && "path" in second && typeof (second as any).path === "string") { - return `[${blockName} for '${(second as any).path}']` - } - - const blockParams = second as Record - if (blockParams?.path) { - return `[${blockName} for '${blockParams.path}']` - } - return `[${blockName} with missing path]` - } - - override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { - // Handle both legacy and new format for partial display - let filePath = "" - if (block.nativeArgs) { - if (isLegacyReadFileParams(block.nativeArgs)) { - // Legacy format - show first file - filePath = block.nativeArgs.files[0]?.path ?? "" - } else { - filePath = block.nativeArgs.path ?? "" - } - } - - const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" - const sharedMessageProps: ClineSayTool = { - tool: "readFile", - path: filePath ? getReadablePath(task.cwd, filePath) : "", - isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, - } - const partialMessage = JSON.stringify({ - ...sharedMessageProps, - content: undefined, - } satisfies ClineSayTool) - await task.ask("tool", partialMessage, block.partial).catch(() => {}) - } - - /** - * Execute legacy multi-file format for backward compatibility. - * This handles the old format: { files: [{ path: string, lineRanges?: [...] }] } - */ - private async executeLegacy(fileEntries: FileEntry[], task: Task, callbacks: ToolCallbacks): Promise { - const { pushToolResult } = callbacks - const modelInfo = task.api.getModel().info - - if (!fileEntries || fileEntries.length === 0) { - task.consecutiveMistakeCount++ - task.recordToolError("read_file") - const errorMsg = await task.sayAndCreateMissingParamError("read_file", "files") - pushToolResult(`Error: ${errorMsg}`) - return - } - - const supportsImages = modelInfo.supportsImages ?? false - - // Process each file sequentially (legacy behavior) - const results: string[] = [] - - for (const entry of fileEntries) { - const relPath = entry.path - const fullPath = path.resolve(task.cwd, relPath) - - // RooIgnore validation - const accessAllowed = task.rooIgnoreController?.validateAccess(relPath) - if (!accessAllowed) { - await task.say("rooignore_error", relPath) - const errorMsg = formatResponse.rooIgnoreError(relPath) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - // Mirror the native path: a blocked file marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - continue - } - - // Request approval for single file - const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - let lineSnippet = "" - if (entry.lineRanges && entry.lineRanges.length > 0) { - const ranges = entry.lineRanges.map((range: LineRange) => `(lines ${range.start}-${range.end})`) - lineSnippet = ranges.join(", ") - } - - const completeMessage = JSON.stringify({ - tool: "readFile", - path: getReadablePath(task.cwd, relPath), - isOutsideWorkspace, - content: fullPath, - reason: lineSnippet || undefined, - } satisfies ClineSayTool) - - const { response, text, images } = await task.ask("tool", completeMessage, false) - - if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) - task.didRejectTool = true - results.push(`File: ${relPath}\nStatus: Denied by user`) - continue - } - - if (text) await task.say("user_feedback", text, images) - - try { - // Check if the path is a directory - const stats = await fs.stat(fullPath) - if (stats.isDirectory()) { - const errorMsg = `Cannot read '${relPath}' because it is a directory.` - results.push(`File: ${relPath}\nError: ${errorMsg}`) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - // Mirror the native path: a failed read marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - continue - } - - const isBinary = await isBinaryFile(fullPath).catch(() => false) - - if (isBinary) { - // Handle binary files (images) - const fileExtension = path.extname(relPath).toLowerCase() - if (supportsImages && isSupportedImageFormat(fileExtension)) { - const state = await task.providerRef.deref()?.getState() - const { - maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, - maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, - } = state ?? {} - const validation = await validateImageForProcessing( - fullPath, - supportsImages, - maxImageFileSize, - maxTotalImageSize, - 0, // Legacy path doesn't track cumulative memory - ) - if (!validation.isValid) { - results.push(`File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}`) - continue - } - const imageResult = await processImageFile(fullPath) - if (imageResult) { - results.push(`File: ${relPath}\n[Image file - content processed for vision model]`) - } - } else { - results.push(`File: ${relPath}\nError: Cannot read binary file`) - } - continue - } - - // Read text file - const rawContent = await fs.readFile(fullPath, "utf8") - - // Handle line ranges if specified - let content: string - if (entry.lineRanges && entry.lineRanges.length > 0) { - const lines = rawContent.split("\n") - const selectedLines: string[] = [] - - for (const range of entry.lineRanges) { - // Convert to 0-based index, ranges are 1-based inclusive - const startIdx = Math.max(0, range.start - 1) - const endIdx = Math.min(lines.length - 1, range.end - 1) - - for (let i = startIdx; i <= endIdx; i++) { - selectedLines.push(`${i + 1} | ${lines[i]}`) - } - } - content = selectedLines.join("\n") - } else { - // Read with default limits using slice mode - const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) - content = result.content - if (result.wasTruncated) { - content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` - } - } - - results.push(`File: ${relPath}\n${content}`) - - // Track file in context - await task.fileContextTracker.trackFileContext(relPath, "read_tool") - } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - results.push(`File: ${relPath}\nError: ${errorMsg}`) - await task.say("error", `Error reading file ${relPath}: ${errorMsg}`) - // Mirror the native path: a failed read marks the tool turn as failed. - task.didToolFailInCurrentTurn = true - } - } - - // Push combined results - pushToolResult(results.join("\n\n---\n\n")) - } -} - -export const readFileTool = new ReadFileTool() diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts deleted file mode 100644 index 5f9033eb7c..0000000000 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ /dev/null @@ -1,1559 +0,0 @@ -/** - * Tests for ReadFileTool - Codex-inspired file reading with indentation mode support. - * - * These tests cover: - * - Input validation (missing path parameter) - * - RooIgnore blocking - * - Directory read error handling - * - Binary file handling (images, PDF, DOCX, unsupported) - * - Image memory limits - * - Approval flow (approve, deny, feedback) - * - Text file processing (slice and indentation modes) - * - Output structure formatting - */ - -import path from "path" - -import type { ToolUse } from "../../../shared/tools" -import type { Task } from "../../task/Task" - -import { isBinaryFile } from "isbinaryfile" - -import { readFileTool, ReadFileTool } from "../ReadFileTool" -import { formatResponse } from "../../prompts/responses" -import { - validateImageForProcessing, - processImageFile, - isSupportedImageFormat, - ImageMemoryTracker, -} from "../helpers/imageHelpers" -import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" -import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader" - -// ─── Mocks ──────────────────────────────────────────────────────────────────── - -vi.mock("path", async () => { - const originalPath = await vi.importActual("path") - return { - default: originalPath, - ...originalPath, - resolve: vi.fn().mockImplementation((...args) => { - return args.join("/") - }), - } -}) - -vi.mock("fs/promises", () => ({ - readFile: vi.fn(), - stat: vi.fn(), -})) - -vi.mock("isbinaryfile") - -vi.mock("../../../integrations/misc/extract-text", () => ({ - extractTextFromFile: vi.fn(), - addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => { - if (!text) return "" - const lines = text.split("\n") - return lines.map((line, i) => `${startLine + i} | ${line}`).join("\n") - }), - getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]), -})) - -vi.mock("../../../integrations/misc/indentation-reader", () => ({ - readWithIndentation: vi.fn(), - readWithSlice: vi.fn(), -})) - -vi.mock("../helpers/imageHelpers", () => ({ - DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5, - DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20, - isSupportedImageFormat: vi.fn(), - validateImageForProcessing: vi.fn(), - processImageFile: vi.fn(), - ImageMemoryTracker: vi.fn().mockImplementation(function () { - return { - getTotalMemoryUsed: vi.fn().mockReturnValue(0), - addMemoryUsage: vi.fn(), - } - }), -})) - -vi.mock("../../prompts/responses", () => ({ - formatResponse: { - toolDenied: vi.fn(() => "The user denied this operation."), - toolDeniedWithFeedback: vi.fn( - (feedback?: string) => - `The user denied this operation and responded with the message:\n\n${feedback}\n`, - ), - toolApprovedWithFeedback: vi.fn( - (feedback?: string) => - `The user approved this operation and responded with the message:\n\n${feedback}\n`, - ), - rooIgnoreError: vi.fn( - (filePath: string) => - `Access to ${filePath} 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: 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 - }), - imageBlocks: 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 } } - }) - : [] - }), - }, -})) - -// Mock fs/promises -const fsPromises = await import("fs/promises") -const mockedFsReadFile = vi.mocked(fsPromises.readFile) -const mockedFsStat = vi.mocked(fsPromises.stat) - -const mockedIsBinaryFile = vi.mocked(isBinaryFile) -const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) -const mockedReadWithSlice = vi.mocked(readWithSlice) -const mockedReadWithIndentation = vi.mocked(readWithIndentation) -const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat) -const mockedValidateImageForProcessing = vi.mocked(validateImageForProcessing) -const mockedProcessImageFile = vi.mocked(processImageFile) - -// ─── Test Helpers ───────────────────────────────────────────────────────────── - -interface MockTaskOptions { - supportsImages?: boolean - rooIgnoreAllowed?: boolean - maxImageFileSize?: number - maxTotalImageSize?: number -} - -function createMockTask(options: MockTaskOptions = {}) { - const { supportsImages = false, rooIgnoreAllowed = true, maxImageFileSize = 5, maxTotalImageSize = 20 } = options - - return { - cwd: "/test/workspace", - api: { - getModel: vi.fn().mockReturnValue({ - info: { supportsImages }, - }), - }, - consecutiveMistakeCount: 0, - didToolFailInCurrentTurn: false, - didRejectTool: false, - ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), - say: vi.fn().mockResolvedValue(undefined), - sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"), - recordToolError: vi.fn(), - rooIgnoreController: { - validateAccess: vi.fn().mockReturnValue(rooIgnoreAllowed), - }, - fileContextTracker: { - trackFileContext: vi.fn().mockResolvedValue(undefined), - }, - providerRef: { - deref: vi.fn().mockReturnValue({ - getState: vi.fn().mockResolvedValue({ - maxImageFileSize, - maxTotalImageSize, - }), - }), - }, - } -} - -function createMockCallbacks() { - return { - pushToolResult: vi.fn(), - askApproval: vi.fn(), - handleError: vi.fn(), - } -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe("ReadFileTool", () => { - beforeEach(() => { - vi.clearAllMocks() - - // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) - mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | test content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - }) - - describe("input validation", () => { - it("should return error when path is missing", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "" } as any, mockTask as any, callbacks) - - expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") - expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) - }) - - it("should return error when path is undefined", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({} as any, mockTask as any, callbacks) - - expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) - }) - - it("should return error when offset is 0 or negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "test.txt", offset: 0 }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("offset must be a 1-indexed line number"), - ) - }) - - it("should return error when offset is negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "test.txt", offset: -5 }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("offset must be a 1-indexed line number"), - ) - }) - - it("should return error when anchor_line is 0 or negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute( - { - path: "test.txt", - mode: "indentation", - indentation: { anchor_line: 0 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("anchor_line must be a 1-indexed line number"), - ) - }) - - it("should return error when anchor_line is negative", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute( - { - path: "test.txt", - mode: "indentation", - indentation: { anchor_line: -10 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("anchor_line must be a 1-indexed line number"), - ) - }) - }) - - describe("RooIgnore handling", () => { - it("should block access to rooignore-protected files", async () => { - const mockTask = createMockTask({ rooIgnoreAllowed: false }) - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "secret.env" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") - expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith("secret.env") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) - }) - }) - - describe("directory handling", () => { - it("should return error when trying to read a directory", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - - await readFileTool.execute({ path: "src/utils" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith( - "error", - expect.stringContaining("Cannot read 'src/utils' because it is a directory"), - ) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) - - describe("image handling", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(true) - }) - - it("should process image file when model supports images", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: true, - sizeInMB: 0.5, - }) - mockedProcessImageFile.mockResolvedValue({ - dataUrl: "data:image/png;base64,abc123", - buffer: Buffer.from("test"), - sizeInKB: 512, - sizeInMB: 0.5, - notice: "Image processed successfully", - }) - - await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) - - expect(mockedValidateImageForProcessing).toHaveBeenCalled() - expect(mockedProcessImageFile).toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalled() - }) - - it("should skip image when model does not support images", async () => { - const mockTask = createMockTask({ supportsImages: false }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "unsupported_model", - notice: "Model does not support image processing", - }) - - await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) - - expect(mockedValidateImageForProcessing).toHaveBeenCalled() - expect(mockedProcessImageFile).not.toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("Model does not support image processing"), - ) - }) - - it("should skip image when file exceeds size limit", async () => { - const mockTask = createMockTask({ supportsImages: true, maxImageFileSize: 1 }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "size_limit", - notice: "Image file size (10 MB) exceeds the maximum allowed size (1 MB)", - }) - - await readFileTool.execute({ path: "large-image.png" }, mockTask as any, callbacks) - - expect(mockedProcessImageFile).not.toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("exceeds the maximum allowed"), - ) - }) - - it("should skip image when total memory limit exceeded", async () => { - const mockTask = createMockTask({ supportsImages: true, maxTotalImageSize: 5 }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "memory_limit", - notice: "Skipping image: would exceed total memory limit", - }) - - await readFileTool.execute({ path: "another-image.png" }, mockTask as any, callbacks) - - expect(mockedProcessImageFile).not.toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("would exceed total memory")) - }) - - it("should handle image read errors gracefully", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: true, - sizeInMB: 0.5, - }) - mockedProcessImageFile.mockRejectedValue(new Error("Failed to read image")) - - await readFileTool.execute({ path: "corrupt.png" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading image file")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) - }) - }) - - describe("binary file handling", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(false) - }) - - it("should extract text from PDF files", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedExtractTextFromFile.mockResolvedValue("PDF content here") - - await readFileTool.execute({ path: "document.pdf" }, mockTask as any, callbacks) - - expect(mockedExtractTextFromFile).toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("PDF content here")) - }) - - it("should extract text from DOCX files", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedExtractTextFromFile.mockResolvedValue("DOCX content here") - - await readFileTool.execute({ path: "document.docx" }, mockTask as any, callbacks) - - expect(mockedExtractTextFromFile).toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("DOCX content here")) - }) - - it("should handle unsupported binary formats", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - // Return empty array to indicate .exe is not supported - vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx"]) - - await readFileTool.execute({ path: "program.exe" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Binary file")) - }) - - it("should handle extraction errors gracefully", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedExtractTextFromFile.mockRejectedValue(new Error("Extraction failed")) - - await readFileTool.execute({ path: "corrupt.pdf" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error extracting text")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) - - describe("text file processing", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should read text file with slice mode (default)", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - const content = "line 1\nline 2\nline 3" - mockedFsReadFile.mockResolvedValue(Buffer.from(content)) - mockedReadWithSlice.mockReturnValue({ - content: "1 | line 1\n2 | line 2\n3 | line 3", - returnedLines: 3, - totalLines: 3, - wasTruncated: false, - includedRanges: [[1, 3]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockedReadWithSlice).toHaveBeenCalled() - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("line 1")) - }) - - it("should read text file with offset and limit", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("line 1\nline 2\nline 3\nline 4\nline 5")) - mockedReadWithSlice.mockReturnValue({ - content: "2 | line 2\n3 | line 3", - returnedLines: 2, - totalLines: 5, - wasTruncated: true, - includedRanges: [[2, 3]], - }) - - await readFileTool.execute( - { path: "test.ts", mode: "slice", offset: 2, limit: 2 }, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 1, 2) // offset converted to 0-based - }) - - it("should read text file with indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - const content = "class Foo {\n method() {\n return 42\n }\n}" - mockedFsReadFile.mockResolvedValue(Buffer.from(content)) - mockedReadWithIndentation.mockReturnValue({ - content: "1 | class Foo {\n2 | method() {\n3 | return 42\n4 | }\n5 | }", - returnedLines: 5, - totalLines: 5, - wasTruncated: false, - includedRanges: [[1, 5]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 3 }, - }, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - content, - expect.objectContaining({ - anchorLine: 3, - }), - ) - }) - - it("should show truncation notice when content is truncated", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content...")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | truncated content", - returnedLines: 100, - totalLines: 5000, - wasTruncated: true, - includedRanges: [[1, 100]], - }) - - await readFileTool.execute({ path: "large.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("truncated")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more")) - }) - - it("should handle empty files", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("")) - mockedReadWithSlice.mockReturnValue({ - content: "", - returnedLines: 0, - totalLines: 0, - wasTruncated: false, - includedRanges: [], - }) - - await readFileTool.execute({ path: "empty.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("empty")) - }) - }) - - describe("approval flow", () => { - it("should approve file read when user clicks yes", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.any(String), false) - expect(mockTask.didRejectTool).toBe(false) - }) - - it("should deny file read when user clicks no", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.didRejectTool).toBe(true) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) - }) - - it("should include user feedback when provided with approval", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "yesButtonClicked", - text: "Please be careful with this file", - images: undefined, - }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined) - expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file") - }) - - it("should include user feedback when provided with denial", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "noButtonClicked", - text: "This file contains secrets", - images: undefined, - }) - - await readFileTool.execute({ path: "secrets.env" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) - expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") - }) - }) - - describe("output structure", () => { - it("should include file path in output", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "src/app.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: src/app.ts")) - }) - - it("should track file context after successful read", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test.ts", "read_tool") - }) - }) - - describe("error handling", () => { - it("should handle file read errors gracefully", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory")) - - await readFileTool.execute({ path: "nonexistent.ts" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should handle stat errors gracefully", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockRejectedValue(new Error("Permission denied")) - - await readFileTool.execute({ path: "protected.ts" }, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) - - describe("getReadFileToolDescription", () => { - it("should return description with path when nativeArgs provided", () => { - const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) - - expect(description).toBe("[read_file for 'src/app.ts']") - }) - - it("should return description with path when params provided", () => { - const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) - - expect(description).toBe("[read_file for 'src/app.ts']") - }) - - it("should return description indicating missing path", () => { - const description = readFileTool.getReadFileToolDescription("read_file", {}) - - expect(description).toBe("[read_file with missing path]") - }) - }) - - describe("legacy format handling", () => { - it("should detect legacy format and use backward compatibility path", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValue(Buffer.from("legacy content")) - - await readFileTool.execute({ files: [{ path: "legacy.ts" }] } as any, mockTask as any, callbacks) - - // The legacy path emits "File: " entries — proof the backward-compat branch ran. - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: legacy.ts")) - }) - - it("should return error when legacy files array is empty", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - await readFileTool.execute({ files: [] } as any, mockTask as any, callbacks) - - expect(mockTask.consecutiveMistakeCount).toBe(1) - expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) - }) - - it("should handle multiple files in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file1 content")) - mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file2 content")) - - // Override readWithSlice to return content that reflects the actual file data - mockedReadWithSlice - .mockReturnValueOnce({ - content: "1 | file1 content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - .mockReturnValueOnce({ - content: "1 | file2 content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { files: [{ path: "file1.ts" }, { path: "file2.ts" }] } as any, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file1 content")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file2 content")) - }) - - it("should block rooignore-protected files in legacy format", async () => { - const mockTask = createMockTask({ rooIgnoreAllowed: false }) - const callbacks = createMockCallbacks() - - await readFileTool.execute({ files: [{ path: "secret.env" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) - // Consistent with the native path: a blocked file fails the tool turn. - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should deny legacy file read when user clicks no", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) - - await readFileTool.execute({ files: [{ path: "protected.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.didRejectTool).toBe(true) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) - }) - - it("should handle directory path in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - - await readFileTool.execute({ files: [{ path: "src/utils" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) - // Consistent with the native path: a failed read fails the tool turn. - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should handle line ranges in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) - - await readFileTool.execute( - { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("2 | line2")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("4 | line4")) - }) - - it("should handle binary image files in legacy format with image support", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(true) - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: true, - sizeInMB: 0.5, - }) - mockedProcessImageFile.mockResolvedValue({ - dataUrl: "data:image/png;base64,abc123", - buffer: Buffer.from("test"), - sizeInKB: 512, - sizeInMB: 0.5, - notice: "Image processed successfully", - }) - - await readFileTool.execute({ files: [{ path: "image.png" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.stringContaining("Image file - content processed"), - ) - }) - - it("should handle binary image validation failure in legacy format", async () => { - const mockTask = createMockTask({ supportsImages: true }) - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(true) - mockedValidateImageForProcessing.mockResolvedValue({ - isValid: false, - reason: "size_limit", - notice: "Image too large", - }) - - await readFileTool.execute({ files: [{ path: "large.png" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Image too large")) - }) - - it("should handle unsupported binary files in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedIsBinaryFile.mockResolvedValue(true) - mockedIsSupportedImageFormat.mockReturnValue(false) - - await readFileTool.execute({ files: [{ path: "program.exe" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Cannot read binary file")) - }) - - it("should handle file read errors in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) - - await readFileTool.execute({ files: [{ path: "missing.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("ENOENT")) - // Consistent with the native path: a failed read fails the tool turn. - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should handle user feedback on approval in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "yesButtonClicked", - text: "Read carefully", - images: undefined, - }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - - await readFileTool.execute({ files: [{ path: "test.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Read carefully", undefined) - }) - - it("should handle user feedback on denial in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ - response: "noButtonClicked", - text: "Not allowed", - images: undefined, - }) - - await readFileTool.execute({ files: [{ path: "secret.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Not allowed", undefined) - }) - - it("should handle truncation in legacy format when no line ranges", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 100, - totalLines: 5000, - wasTruncated: true, - includedRanges: [[1, 100]], - }) - - await readFileTool.execute({ files: [{ path: "large.ts" }] } as any, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File truncated")) - }) - - it("should track file context in legacy format", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - - await readFileTool.execute({ files: [{ path: "tracked.ts" }] } as any, mockTask as any, callbacks) - - expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("tracked.ts", "read_tool") - }) - }) - - describe("handlePartial", () => { - it.each([{}, { path: "" }, undefined])( - "renders streamed native arguments before their path arrives: %j", - async (nativeArgs) => { - const mockTask = createMockTask() - mockTask.cwd = path.resolve(mockTask.cwd) - const streamedBlock = { - type: "tool_use", - name: "read_file", - params: {}, - nativeArgs, - partial: true, - } - - // Streaming JSON is incomplete even though finalized native arguments require a path. - await Reflect.apply(readFileTool.handlePartial, readFileTool, [mockTask, streamedBlock]) - - expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( - "tool", - JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), - true, - ) - expect(mockedFsReadFile).not.toHaveBeenCalled() - expect(mockedFsStat).not.toHaveBeenCalled() - expect(mockedExtractTextFromFile).not.toHaveBeenCalled() - }, - ) - - it("renders incomplete legacy arguments without a file entry or outside-workspace warning", async () => { - const mockTask = createMockTask() - mockTask.cwd = path.resolve(mockTask.cwd) - const block: ToolUse<"read_file"> = { - type: "tool_use", - name: "read_file", - params: {}, - nativeArgs: { files: [], _legacyFormat: true }, - partial: true, - } - - // This reader-boundary double omits Task fields unrelated to file reading. - await readFileTool.handlePartial(mockTask as unknown as Task, block) - - expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( - "tool", - JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), - true, - ) - expect(mockedFsReadFile).not.toHaveBeenCalled() - expect(mockedFsStat).not.toHaveBeenCalled() - expect(mockedExtractTextFromFile).not.toHaveBeenCalled() - }) - - it.each(["src/app.ts", "src/файл.ts", "src/e\u0301-📚.ts"])( - "should preserve partial display for %s", - async (filePath) => { - const mockTask = createMockTask() - mockTask.cwd = path.resolve(mockTask.cwd) - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block: ToolUse<"read_file"> = { - type: "tool_use", - name: "read_file", - params: {}, - nativeArgs: { path: filePath }, - partial: true, - } - - // This reader-boundary double omits Task fields unrelated to file reading. - await readFileTool.handlePartial(mockTask as unknown as Task, block) - - expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( - "tool", - JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), - true, - ) - }, - ) - - it("should handle partial display for legacy format", async () => { - const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: { files: [{ path: "legacy.ts" }] }, - partial: false, - } - - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("legacy.ts"), false) - }) - - it("should handle partial display with empty path", async () => { - const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: { path: "" }, - partial: true, - } - - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalled() - }) - - it("should handle partial display with no nativeArgs", async () => { - const mockTask = createMockTask() - mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) - - const block = { - nativeArgs: undefined, - partial: true, - } - - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalled() - }) - - it.each(["Cancelled", "superseded"])("should gracefully handle %s ask rejection in partial", async (reason) => { - const mockTask = createMockTask() - mockTask.ask.mockRejectedValue(new Error(reason)) - - const block = { - nativeArgs: { path: "test.ts" }, - partial: true, - } - - // Should not throw - await readFileTool.handlePartial(mockTask as any, block as any) - - expect(mockTask.ask).toHaveBeenCalled() - }) - }) - - describe("processTextFile indentation mode edge cases", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should use offset as fallback when anchor_line is not provided in indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "5 | line 5 content", - returnedLines: 1, - totalLines: 10, - wasTruncated: false, - includedRanges: [[5, 5]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - offset: 5, - } as any, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ anchorLine: 5 }), - ) - }) - - it("should default anchorLine to 1 when neither anchor_line nor offset in indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "1 | first line", - returnedLines: 1, - totalLines: 10, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - } as any, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ anchorLine: 1 }), - ) - }) - - it("should show truncation notice in indentation mode", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "1 | truncated block", - returnedLines: 50, - totalLines: 200, - wasTruncated: true, - includedRanges: [[1, 50]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 1 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File content truncated")) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("To read more")) - }) - - it("should include range information in indentation mode when not truncated", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "5 | function foo() { ... }", - returnedLines: 10, - totalLines: 100, - wasTruncated: false, - includedRanges: [[5, 14]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 5 }, - }, - mockTask as any, - callbacks, - ) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Included ranges: 5-14")) - }) - - it("should pass all indentation options to readWithIndentation", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { - anchor_line: 10, - max_levels: 2, - include_siblings: true, - include_header: false, - max_lines: 200, - }, - limit: 500, - }, - mockTask as any, - callbacks, - ) - - expect(mockedReadWithIndentation).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - anchorLine: 10, - maxLevels: 2, - includeSiblings: true, - includeHeader: false, - limit: 500, - maxLines: 200, - }), - ) - }) - }) - - describe("slice mode edge cases", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should convert 1-based offset to 0-based for readWithSlice", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3")) - mockedReadWithSlice.mockReturnValue({ - content: "4 | line4", - returnedLines: 1, - totalLines: 10, - wasTruncated: false, - includedRanges: [[4, 4]], - }) - - await readFileTool.execute( - { path: "test.ts", mode: "slice", offset: 4, limit: 1 }, - mockTask as any, - callbacks, - ) - - // offset=4 (1-based) should become 3 (0-based) passed to readWithSlice - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 3, 1) - }) - - it("should use default offset of 1 when not specified", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - // Default offset=1 -> 0-based = 0 - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 0, expect.any(Number)) - }) - - it("should use default limit when not specified", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - // Should use DEFAULT_LINE_LIMIT (which is typically 2000) - expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.any(Number)) - }) - - it("should show correct line range in truncation notice", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content")) - mockedReadWithSlice.mockReturnValue({ - content: "5 | partial", - returnedLines: 100, - totalLines: 500, - wasTruncated: true, - includedRanges: [[5, 104]], - }) - - await readFileTool.execute({ path: "test.ts", offset: 5, limit: 100 }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Showing lines 5-104")) - }) - }) - - describe("getLineSnippet and getStartLine", () => { - beforeEach(() => { - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should show indentation mode snippet in approval message", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - indentation: { anchor_line: 42 }, - }, - mockTask as any, - callbacks, - ) - - // The approval message should contain the indentation mode info - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("indentation mode at line 42") - }) - - it("should show line range in approval when offset > 1", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts", offset: 10, limit: 50 }, mockTask as any, callbacks) - - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("lines 10-59") - expect(message.startLine).toBe(10) - }) - - it("should show up to N lines in approval when offset is default", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("up to") - expect(message.reason).toContain("lines") - expect(message.startLine).toBeUndefined() - }) - - it("should show indentation mode snippet with offset fallback", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithIndentation.mockReturnValue({ - content: "result", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute( - { - path: "test.ts", - mode: "indentation", - offset: 7, - } as any, - mockTask as any, - callbacks, - ) - - const askCall = mockTask.ask.mock.calls[0] - const message = JSON.parse(askCall[1]) - expect(message.reason).toContain("indentation mode at line 7") - }) - }) - - describe("provider state handling", () => { - it("should use default image limits when state is null", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - // Make providerRef return null - mockTask.providerRef.deref.mockReturnValue(null) - - mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - // Should still work - uses default limits - expect(callbacks.pushToolResult).toHaveBeenCalled() - }) - - it("should use default limits when state has no image config", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockTask.providerRef.deref.mockReturnValue({ - getState: vi.fn().mockResolvedValue({}), - }) - - mockedIsBinaryFile.mockResolvedValue(false) - mockedFsReadFile.mockResolvedValue(Buffer.from("content")) - mockedReadWithSlice.mockReturnValue({ - content: "1 | content", - returnedLines: 1, - totalLines: 1, - wasTruncated: false, - includedRanges: [[1, 1]], - }) - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(callbacks.pushToolResult).toHaveBeenCalled() - }) - }) - - describe("error handling edge cases", () => { - it("should handle unknown error types (non-Error)", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockRejectedValue("string error") - - await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) - - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error")) - }) - - it("should set didToolFailInCurrentTurn on rooignore block", async () => { - const mockTask = createMockTask({ rooIgnoreAllowed: false }) - const callbacks = createMockCallbacks() - - await readFileTool.execute({ path: "blocked.ts" }, mockTask as any, callbacks) - - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - - it("should set didToolFailInCurrentTurn on directory read", async () => { - const mockTask = createMockTask() - const callbacks = createMockCallbacks() - - mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) - - await readFileTool.execute({ path: "src/" }, mockTask as any, callbacks) - - expect(mockTask.didToolFailInCurrentTurn).toBe(true) - }) - }) -}) diff --git a/src/core/tools/file-reading/LegacyFileReader.ts b/src/core/tools/file-reading/LegacyFileReader.ts new file mode 100644 index 0000000000..01a5602f86 --- /dev/null +++ b/src/core/tools/file-reading/LegacyFileReader.ts @@ -0,0 +1,157 @@ +import path from "path" +import type { FileHandle } from "fs/promises" +import { isBinaryFile } from "isbinaryfile" +import type { ClineSayTool, FileEntry, LineRange } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { formatResponse } from "../../prompts/responses" +import { DEFAULT_LINE_LIMIT } from "./readFileConstants" +import { getReadablePath } from "../../../utils/path" +import { readWithSlice } from "../../../integrations/misc/indentation-reader" +import { ReadFileTarget } from "./ReadFileTarget" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + isSupportedImageFormat, + validateImageForProcessing, + processImageFile, +} from "../helpers/imageHelpers" + +/** Compatibility-only reader. Do not use it for advertised native batches. */ +export class LegacyFileReader { + async read(entries: FileEntry[], task: Task): Promise { + const supportsImages = task.api.getModel().info.supportsImages ?? false + const results: string[] = [] + for (const entry of entries) { + const result = await this.readLegacyEntry(entry, task, supportsImages) + results.push(result) + } + return results.join("\n\n---\n\n") + } + + private async readLegacyEntry(entry: FileEntry, task: Task, supportsImages: boolean): Promise { + const relPath = entry.path + if (!task.rooIgnoreController?.validateAccess(relPath)) { + await task.say("rooignore_error", relPath) + task.didToolFailInCurrentTurn = true + return `File: ${relPath}\nError: ${formatResponse.rooIgnoreError(relPath)}` + } + let target: ReadFileTarget + try { + target = await ReadFileTarget.resolve(path.resolve(task.cwd, relPath)) + } catch (error) { + return this.reportLegacyReadError(relPath, task, error) + } + if (!task.rooIgnoreController?.validateAccess(target.fullPath)) { + await task.say("rooignore_error", relPath) + task.didToolFailInCurrentTurn = true + return `File: ${relPath}\nError: ${formatResponse.rooIgnoreError(relPath)}` + } + if (!(await this.approveLegacyFile(entry, task, target.fullPath))) + return `File: ${relPath}\nStatus: Denied by user` + try { + return await target.withHandle(async (file, stats) => { + if (stats.isDirectory()) { + return await this.reportLegacyReadError( + relPath, + task, + `Cannot read '${relPath}' because it is a directory.`, + ) + } + if (await this.detectLegacyBinary(file)) { + return await this.readLegacyBinary(relPath, target.fullPath, task, supportsImages, file) + } + const content = this.formatLegacyText(await file.readFile("utf8"), entry.lineRanges) + return await this.trackLegacyResult(relPath, `File: ${relPath}\n${content}`, task) + }) + } catch (error) { + return this.reportLegacyReadError(relPath, task, error) + } + } + + private async approveLegacyFile(entry: FileEntry, task: Task, fullPath: string): Promise { + const reason = entry.lineRanges?.map((range) => `(lines ${range.start}-${range.end})`).join(", ") + const message = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, fullPath), + isOutsideWorkspace: await ReadFileTarget.isOutsideWorkspace(fullPath), + content: fullPath, + reason: reason || undefined, + } satisfies ClineSayTool) + const { response, text, images } = await task.ask("tool", message, false) + if (text) await task.say("user_feedback", text, images) + if (response === "yesButtonClicked") return true + task.didRejectTool = true + return false + } + + private async detectLegacyBinary(file: FileHandle): Promise { + try { + const sample = Buffer.alloc(512) + const { bytesRead } = await file.read(sample, 0, sample.length, 0) + return await isBinaryFile(sample.subarray(0, bytesRead), bytesRead) + } catch { + return false + } + } + + private async readLegacyBinary( + relPath: string, + fullPath: string, + task: Task, + supportsImages: boolean, + file: FileHandle, + ): Promise { + if (!supportsImages || !isSupportedImageFormat(path.extname(relPath).toLowerCase())) { + return `File: ${relPath}\nError: Cannot read binary file` + } + const state = await task.providerRef.deref()?.getState() + const validation = await validateImageForProcessing( + fullPath, + supportsImages, + state?.maxImageFileSize ?? DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + state?.maxTotalImageSize ?? DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, // Preserve the historical non-cumulative image validation contract. + file, + ) + if (!validation.isValid) return `File: ${relPath}\nNotice: ${validation.notice ?? "Image validation failed"}` + await processImageFile(relPath, file) + return `File: ${relPath}\n[Image file - content processed for vision model]` + } + + private async reportLegacyReadError(relPath: string, task: Task, error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error) + await task.say("error", `Error reading file ${relPath}: ${message}`) + task.didToolFailInCurrentTurn = true + return `File: ${relPath}\nError: ${message}` + } + + private async trackLegacyResult(relPath: string, content: string, task: Task): Promise { + try { + await task.fileContextTracker.trackFileContext(relPath, "read_tool") + return content + } catch (error) { + // Historically successful content was pushed before tracking, so retain it. + return `${content}\n\n---\n\n${await this.reportLegacyReadError(relPath, task, error)}` + } + } + + private formatLegacyText(content: string, ranges?: LineRange[]): string { + if (!ranges?.length) { + const result = readWithSlice(content, 0, DEFAULT_LINE_LIMIT) + const notice = result.wasTruncated + ? `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + : "" + return result.content + notice + } + const lines = content.split("\n") + return ranges + .flatMap((range) => { + const selected: string[] = [] + const start = Math.max(0, range.start - 1) + const end = Math.min(lines.length - 1, range.end - 1) + for (let index = start; index <= end; index++) selected.push(`${index + 1} | ${lines[index]}`) + return selected + }) + .join("\n") + } +} diff --git a/src/core/tools/file-reading/ModernFileReader.ts b/src/core/tools/file-reading/ModernFileReader.ts new file mode 100644 index 0000000000..bc97074601 --- /dev/null +++ b/src/core/tools/file-reading/ModernFileReader.ts @@ -0,0 +1,66 @@ +import path from "path" +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { ReadFileAccess } from "./ReadFileAccess" +import { ReadFileContentReader } from "./ReadFileContentReader" +import { ReadFileErrorReporter } from "./ReadFileErrorReporter" +import { ReadFileTarget } from "./ReadFileTarget" +import { isReadFileCancelled, throwIfReadFileCancelled } from "./readFileCancellation" +import { validateReadFileNumbers } from "./readFileValidation" +import type { FileResult, ReadEntryOptions } from "./types" + +/** Shared modern reader. Approval belongs to each entry; callers own aggregation. */ +export class ModernFileReader { + constructor( + private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter(), + private readonly access: ReadFileAccess = new ReadFileAccess(), + private readonly contentReader: ReadFileContentReader = new ReadFileContentReader(errorReporter), + ) {} + + async read(params: ReadFileParams, task: Task, options: ReadEntryOptions = {}): Promise { + let approval: FileResult | undefined + const cancelled = (): FileResult => ({ + path: params.path, + status: "cancelled", + ...(approval + ? { + feedbackText: approval.feedbackText, + feedbackImages: approval.feedbackImages, + ...(approval.approvalStatus ? { approvalStatus: approval.approvalStatus } : {}), + } + : {}), + }) + try { + if (isReadFileCancelled(task, options)) return cancelled() + const validationError = validateReadFileNumbers(params) + if (validationError) throw new Error(validationError) + const blocked = await this.access.check(params.path, task) + if (isReadFileCancelled(task, options)) return cancelled() + if (blocked) return blocked + const lexicalPath = path.resolve(task.cwd, params.path) + const target = await ReadFileTarget.resolve(lexicalPath, () => throwIfReadFileCancelled(task, options)) + if (isReadFileCancelled(task, options)) return cancelled() + const fullPath = target.fullPath + const targetBlocked = await this.access.check(fullPath, task) + if (isReadFileCancelled(task, options)) return cancelled() + if (targetBlocked) return { ...targetBlocked, path: params.path } + approval = await this.access.requestApproval(params, task, options, fullPath) + if (approval.status === "approved") { + approval = { ...approval, approvalStatus: approval.status } + } + if (isReadFileCancelled(task, options)) return cancelled() + if (approval.status !== "approved") return approval + const result = await this.contentReader.read(params, task, options, target) + return { + ...approval, + ...(isReadFileCancelled(task, options) ? cancelled() : result), + } + } catch (error) { + if (isReadFileCancelled(task, options)) return cancelled() + task.didToolFailInCurrentTurn = true + const result = await this.errorReporter.report(params.path || "unknown", task, error) + if (isReadFileCancelled(task, options)) return cancelled() + return approval ? { ...approval, ...result } : result + } + } +} diff --git a/src/core/tools/file-reading/ReadFileAccess.ts b/src/core/tools/file-reading/ReadFileAccess.ts new file mode 100644 index 0000000000..44fd9923cf --- /dev/null +++ b/src/core/tools/file-reading/ReadFileAccess.ts @@ -0,0 +1,101 @@ +import path from "path" +import type { ReadFileParams, ClineSayTool } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { AskIgnoredError } from "../../task/AskIgnoredError" +import { formatResponse } from "../../prompts/responses" +import { getReadablePath } from "../../../utils/path" +import { DEFAULT_LINE_LIMIT } from "./readFileConstants" +import { isReadFileCancelled } from "./readFileCancellation" +import { ReadFileTarget } from "./ReadFileTarget" +import type { FileResult, ReadEntryOptions } from "./types" + +export class ReadFileAccess { + async check(relPath: string, task: Task): Promise { + if (task.rooIgnoreController?.validateAccess(relPath)) return undefined + await task.say("rooignore_error", relPath) + const error = formatResponse.rooIgnoreError(relPath) + return { path: relPath, status: "blocked", error, nativeContent: `File: ${relPath}\nError: ${error}` } + } + + async requestApproval( + params: ReadFileParams, + task: Task, + options: ReadEntryOptions, + fullPath = path.resolve(task.cwd, params.path), + ): Promise { + const message = JSON.stringify({ + tool: "readFile", + path: getReadablePath(task.cwd, fullPath), + content: fullPath, + isOutsideWorkspace: await ReadFileTarget.isOutsideWorkspace(fullPath), + reason: this.getLineSnippet(params), + startLine: this.getStartLine(params), + } satisfies ClineSayTool) + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + let approval: Awaited> + try { + approval = await task.ask("tool", message, false) + } catch (error) { + // Withdrawn approvals cancel batches even before task flags propagate. + if (isReadFileCancelled(task, options) || (options.textOnly && error instanceof AskIgnoredError)) { + return { path: params.path, status: "cancelled" } + } + throw error + } + const { response, text, images } = approval + const approvalStatus: NonNullable = + response === "yesButtonClicked" ? "approved" : "denied" + if (isReadFileCancelled(task, options)) + return { + path: params.path, + status: "cancelled", + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + try { + if (text) await task.say("user_feedback", text, images) + } catch (error) { + if (isReadFileCancelled(task, options)) + return { + path: params.path, + status: "cancelled", + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + throw error + } + if (isReadFileCancelled(task, options)) + return { + path: params.path, + status: "cancelled", + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + const feedback = { + path: params.path, + entry: params, + approvalStatus, + feedbackText: text, + feedbackImages: images, + } + if (response === "yesButtonClicked") return { ...feedback, status: "approved" } + task.didRejectTool = true + return { ...feedback, status: "denied", nativeContent: `File: ${params.path}\nStatus: Denied by user` } + } + + private getStartLine(params: ReadFileParams): number | undefined { + if (params.mode === "indentation") return params.indentation?.anchor_line ?? params.offset ?? 1 + const offset = params.offset ?? 1 + return offset > 1 ? offset : undefined + } + + private getLineSnippet(params: ReadFileParams): string { + if (params.mode === "indentation") return `(indentation mode at line ${this.getStartLine(params)})` + const offset = params.offset ?? 1 + const limit = params.limit ?? DEFAULT_LINE_LIMIT + return offset > 1 ? `(lines ${offset}-${offset + limit - 1})` : `(up to ${limit} lines)` + } +} diff --git a/src/core/tools/file-reading/ReadFileContentReader.ts b/src/core/tools/file-reading/ReadFileContentReader.ts new file mode 100644 index 0000000000..d3491520cc --- /dev/null +++ b/src/core/tools/file-reading/ReadFileContentReader.ts @@ -0,0 +1,79 @@ +import path from "path" +import { isBinaryFile } from "isbinaryfile" +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { isSupportedImageFormat } from "../helpers/imageHelpers" +import { ReadFileErrorReporter } from "./ReadFileErrorReporter" +import { ReadFileTextReader } from "./strategies/ReadFileTextReader" +import { ReadFileBinaryReader } from "./strategies/ReadFileBinaryReader" +import { ReadFileImageReader } from "./strategies/ReadFileImageReader" +import { ReadFileDocumentReader } from "./strategies/ReadFileDocumentReader" +import { ReadFileTarget } from "./ReadFileTarget" +import type { ReadFileStrategy } from "./strategies/ReadFileStrategy" +import { isReadFileCancelled, throwIfReadFileCancelled } from "./readFileCancellation" +import type { FileResult, ReadEntryOptions, ReadFileContext } from "./types" + +export class ReadFileContentReader { + constructor( + private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter(), + private readonly strategies: readonly ReadFileStrategy[] = [ + new ReadFileTextReader(), + new ReadFileImageReader(errorReporter), + new ReadFileDocumentReader(errorReporter), + new ReadFileBinaryReader(), + ], + ) {} + + async read( + params: ReadFileParams, + task: Task, + options: ReadEntryOptions, + target?: ReadFileTarget, + ): Promise { + try { + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + const resolved = + target ?? + (await ReadFileTarget.resolve(path.resolve(task.cwd, params.path), () => + throwIfReadFileCancelled(task, options), + )) + const result = await resolved.withHandle( + async (file, stats) => { + const fullPath = resolved.fullPath + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + if (stats.isDirectory()) { + return this.errorReporter.report( + params.path, + task, + `Cannot read '${params.path}' because it is a directory. Use list_files tool instead.`, + { prefix: "" }, + ) + } + const extension = path.extname(params.path).toLowerCase() + if (options.textOnly && isSupportedImageFormat(extension)) { + return { + path: params.path, + status: "unsupported", + error: "Batch images are not supported; use read_file.", + } + } + const sample = Buffer.alloc(512) + const { bytesRead } = await file.read(sample, 0, sample.length, 0) + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + const binary = await isBinaryFile(sample.subarray(0, bytesRead), bytesRead) + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + const context: ReadFileContext = { params, task, fullPath, extension, binary, options, file } + const strategy = this.strategies.find((candidate) => candidate.canRead(context)) + if (!strategy) throw new Error(`No file-reading strategy supports '${params.path}'.`) + return await strategy.read(context) + }, + () => throwIfReadFileCancelled(task, options), + ) + // Cleanup may itself settle after cancellation; never deliver its late payload. + return isReadFileCancelled(task, options) ? { path: params.path, status: "cancelled" } : result + } catch (error) { + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + return this.errorReporter.report(params.path, task, error) + } + } +} diff --git a/src/core/tools/file-reading/ReadFileErrorReporter.ts b/src/core/tools/file-reading/ReadFileErrorReporter.ts new file mode 100644 index 0000000000..6c42af1fa0 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileErrorReporter.ts @@ -0,0 +1,16 @@ +import type { Task } from "../../task/Task" +import type { FileResult, ReadErrorContext } from "./types" + +export class ReadFileErrorReporter { + async report(relPath: string, task: Task, error: unknown, context: ReadErrorContext = {}): Promise { + const message = error instanceof Error ? error.message : String(error) + const { prefix = "Error reading file: ", action = "Error reading file" } = context + await task.say("error", `${action} ${relPath}: ${message}`) + return { + path: relPath, + status: "error", + error: prefix + message, + nativeContent: `File: ${relPath}\nError: ${message}`, + } + } +} diff --git a/src/core/tools/file-reading/ReadFileResultFormatter.ts b/src/core/tools/file-reading/ReadFileResultFormatter.ts new file mode 100644 index 0000000000..be6b69713b --- /dev/null +++ b/src/core/tools/file-reading/ReadFileResultFormatter.ts @@ -0,0 +1,49 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import type { FileResult } from "./types" +import { formatResponse } from "../../prompts/responses" + +type ReadFileResponse = Array + +export class ReadFileResultFormatter { + format(result: FileResult, supportsImages: boolean): ReadFileResponse { + let feedbackMessage: string | undefined + const approvalStatus = result.approvalStatus ?? result.status + switch (approvalStatus) { + case "denied": + feedbackMessage = result.feedbackText + ? formatResponse.toolDeniedWithFeedback(result.feedbackText) + : formatResponse.toolDenied() + break + case "blocked": + case "error": + case "cancelled": + case "unsupported": + case "pending": + feedbackMessage = undefined + break + case "approved": + feedbackMessage = result.feedbackText + ? formatResponse.toolApprovedWithFeedback(result.feedbackText) + : undefined + break + default: { + const unhandledStatus: never = approvalStatus + throw new Error(`Unhandled read file status: ${unhandledStatus}`) + } + } + + const content = result.nativeContent + const imageBlocks = supportsImages ? this.getReadFileImageBlocks(result) : undefined + return [ + ...(feedbackMessage ? [{ type: "text" as const, text: feedbackMessage }] : []), + ...(imageBlocks ?? []), + ...(content ? [{ type: "text" as const, text: content }] : []), + ] + } + + private getReadFileImageBlocks(result: FileResult): Anthropic.ImageBlockParam[] { + const images = [...(result.feedbackImages ?? [])] + if (result.status === "approved" && result.imageDataUrl) images.push(result.imageDataUrl) + return formatResponse.imageBlocks(images) + } +} diff --git a/src/core/tools/file-reading/ReadFileTarget.ts b/src/core/tools/file-reading/ReadFileTarget.ts new file mode 100644 index 0000000000..8607043756 --- /dev/null +++ b/src/core/tools/file-reading/ReadFileTarget.ts @@ -0,0 +1,66 @@ +import * as fs from "fs/promises" +import { constants, type BigIntStats } from "fs" +import path from "path" +import * as vscode from "vscode" + +/** Metadata only until approval; content consumers must use the verified handle, never reopen fullPath. */ +export class ReadFileTarget { + private constructor( + readonly fullPath: string, + private readonly identity?: BigIntStats, + private readonly missingError?: unknown, + ) {} + + static async resolve(lexicalPath: string, checkCancelled?: () => void): Promise { + try { + const fullPath = await fs.realpath(lexicalPath) + checkCancelled?.() + const identity = await fs.lstat(fullPath, { bigint: true }) + checkCancelled?.() + if (identity.isSymbolicLink() || (await fs.realpath(fullPath)) !== fullPath) { + throw new Error(`File target changed before approval: ${lexicalPath}`) + } + return new ReadFileTarget(fullPath, identity) + } catch (error) { + // Keep missing-file diagnostics at the historical post-approval boundary. + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return new ReadFileTarget(lexicalPath, undefined, error) + } + throw error + } + } + + static async isOutsideWorkspace(fullPath: string): Promise { + const roots = await Promise.all( + (vscode.workspace.workspaceFolders ?? []).map((folder) => + fs.realpath(folder.uri.fsPath).catch(() => undefined), + ), + ) + return !roots.some((root) => { + if (!root) return false + const relative = path.relative(root, fullPath) + return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) + }) + } + + async withHandle( + read: (file: fs.FileHandle, stats: BigIntStats) => Promise, + checkCancelled?: () => void, + ): Promise { + checkCancelled?.() + if (!this.identity) throw this.missingError + // O_NOFOLLOW rejects final-component substitutions on platforms that support it. + // The identity comparison also guards replacements and parent-directory retargeting. + const file = await fs.open(this.fullPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)) + try { + checkCancelled?.() + const stats = await file.stat({ bigint: true }) + if (stats.dev !== this.identity.dev || stats.ino !== this.identity.ino) { + throw new Error(`File target changed after approval: ${this.fullPath}`) + } + return await read(file, stats) + } finally { + await file.close() + } + } +} diff --git a/src/core/tools/file-reading/ReadFileTextProcessor.ts b/src/core/tools/file-reading/ReadFileTextProcessor.ts new file mode 100644 index 0000000000..e55a558aea --- /dev/null +++ b/src/core/tools/file-reading/ReadFileTextProcessor.ts @@ -0,0 +1,57 @@ +/** Text slicing, structural reading, and continuation formatting for file-reading tools. */ +import type { ReadFileParams } from "@roo-code/types" +import { readWithIndentation, readWithSlice } from "../../../integrations/misc/indentation-reader" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "./readFileConstants" + +export class ReadFileTextProcessor { + process(content: string, params: ReadFileParams): string { + const limit = params.limit ?? DEFAULT_LINE_LIMIT + if (params.mode === "indentation") { + const options = params.indentation + const result = readWithIndentation(content, { + anchorLine: options?.anchor_line ?? params.offset ?? 1, + maxLevels: options?.max_levels, + includeSiblings: options?.include_siblings, + includeHeader: options?.include_header, + limit, + maxLines: options?.max_lines, + }) + if (!result.includedRanges.length) return result.content + if (result.wasTruncated) { + const [start, end] = result.includedRanges[0] + return this.formatTruncatedText(result.content, start, end, result.totalLines, limit) + } + const ranges = result.includedRanges.map(([start, end]) => `${start}-${end}`).join(", ") + return `${result.content}\n\nIncluded ranges: ${ranges} (total: ${result.totalLines} lines)` + } + + const offset = params.offset ?? 1 + const result = readWithSlice(content, Math.max(0, offset - 1), limit) + if (result.content.startsWith("Error:")) return result.content + if (result.wasTruncated) { + return this.formatTruncatedText( + result.content, + offset, + offset + result.returnedLines - 1, + result.totalLines, + limit, + ) + } + return result.returnedLines === 0 ? "Note: File is empty" : result.content + } + + hasClippedLines(source: string, output: string): boolean { + const lines = source.split("\n") + return [...output.matchAll(/^\s*(\d+)\s*\|/gm)].some( + (match) => (lines[Number(match[1]) - 1]?.length ?? 0) > MAX_LINE_LENGTH, + ) + } + + private formatTruncatedText(content: string, start: number, end: number, total: number, limit: number): string { + return `IMPORTANT: File content truncated. + Status: Showing lines ${start}-${end} of ${total} total lines. + To read more: Use the read_file tool with offset=${end + 1} and limit=${limit}. +\t + ${content}` + } +} diff --git a/src/core/tools/file-reading/ReadFileTool.ts b/src/core/tools/file-reading/ReadFileTool.ts new file mode 100644 index 0000000000..f43bbcde6b --- /dev/null +++ b/src/core/tools/file-reading/ReadFileTool.ts @@ -0,0 +1,85 @@ +/** Modern single-file reading, with a separate legacy conversation adapter. */ +import path from "path" +import type { ReadFileParams, ReadFileToolParams, ClineSayTool } from "@roo-code/types" +import { isLegacyReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import { getReadablePath } from "../../../utils/path" +import type { ToolUse } from "../../../shared/tools" +import { ReadFileResultFormatter } from "./ReadFileResultFormatter" +import { LegacyFileReader } from "./LegacyFileReader" +import { ModernFileReader } from "./ModernFileReader" +import { validateReadFileNumbers } from "./readFileValidation" +import type { FileResult, ReadEntryOptions } from "./types" +import { BaseTool, type ToolCallbacks } from "../BaseTool" + +export class ReadFileTool extends BaseTool<"read_file"> { + readonly name = "read_file" as const + + constructor( + private readonly resultFormatter: ReadFileResultFormatter = new ReadFileResultFormatter(), + private readonly fileReader: ModernFileReader = new ModernFileReader(), + private readonly legacyFileReader: LegacyFileReader = new LegacyFileReader(), + ) { + super() + } + + async execute(params: ReadFileToolParams, task: Task, callbacks: ToolCallbacks): Promise { + if (isLegacyReadFileParams(params)) { + if (await this.reportMissingParameter(!params.files?.length, "files", task, callbacks)) return + callbacks.pushToolResult(await this.legacyFileReader.read(params.files, task)) + return + } + if (await this.reportMissingParameter(!params.path, "path", task, callbacks)) return + const validationError = validateReadFileNumbers(params) + if (validationError) { + callbacks.pushToolResult(`Error: ${validationError}`) + return + } + const result = await this.readEntry(params, task) + if (result.status === "error" || result.status === "blocked") task.didToolFailInCurrentTurn = true + callbacks.pushToolResult(this.resultFormatter.format(result, task.api.getModel().info.supportsImages ?? false)) + } + + private async reportMissingParameter( + missing: boolean, + parameter: "path" | "files", + task: Task, + callbacks: ToolCallbacks, + ): Promise { + if (!missing) return false + task.consecutiveMistakeCount++ + task.recordToolError(this.name) + callbacks.pushToolResult(`Error: ${await task.sayAndCreateMissingParamError(this.name, parameter)}`) + return true + } + + /** Shared modern reader. Approval belongs to each entry; callers own aggregation. */ + async readEntry(params: ReadFileParams, task: Task, options: ReadEntryOptions = {}): Promise { + return this.fileReader.read(params, task, options) + } + + getReadFileToolDescription(blockName: string, params: { path?: string }): string + getReadFileToolDescription(blockName: string, params: ReadFileToolParams): string + getReadFileToolDescription(blockName: string, params: unknown): string { + if (params && typeof params === "object" && "path" in params && params.path) + return `[${blockName} for '${String(params.path)}']` + return `[${blockName} with missing path]` + } + + override async handlePartial(task: Task, block: ToolUse<"read_file">): Promise { + const args = block.nativeArgs + const filePath = args ? (isLegacyReadFileParams(args) ? (args.files[0]?.path ?? "") : (args.path ?? "")) : "" + const fullPath = filePath ? path.resolve(task.cwd, filePath) : "" + const message = JSON.stringify({ + tool: "readFile", + path: filePath ? getReadablePath(task.cwd, filePath) : "", + isOutsideWorkspace: filePath ? isPathOutsideWorkspace(fullPath) : false, + } satisfies ClineSayTool) + try { + await task.ask("tool", message, block.partial) + } catch { + /* Streaming asks may be replaced by a newer update. */ + } + } +} diff --git a/src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts new file mode 100644 index 0000000000..63da7645ff --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileCancellation.integration.spec.ts @@ -0,0 +1,846 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import { isBinaryFile } from "isbinaryfile" +import type { Task } from "../../../task/Task" +import { ModernFileReader } from "../ModernFileReader" +import { ReadFileContentReader } from "../ReadFileContentReader" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { ReadFileAccess } from "../ReadFileAccess" +import { ReadFileTarget } from "../ReadFileTarget" +import { extractRawTextFromFile } from "../../../../integrations/misc/extract-text" +import { processImageFile, validateImageForProcessing } from "../../helpers/imageHelpers" +import type { FileResult, ReadEntryOptions } from "../types" + +// Instrument real filesystem calls; descriptors and their cleanup remain real. +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + realpath: vi.fn(actual.realpath), + lstat: vi.fn(actual.lstat), + open: vi.fn(actual.open), + } +}) + +vi.mock("isbinaryfile", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, isBinaryFile: vi.fn(actual.isBinaryFile) } +}) + +vi.mock("../../../../integrations/misc/extract-text", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, extractRawTextFromFile: vi.fn(actual.extractRawTextFromFile) } +}) + +vi.mock("../../helpers/imageHelpers", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateImageForProcessing: vi.fn(actual.validateImageForProcessing), + processImageFile: vi.fn(actual.processImageFile), + } +}) + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve + reject = onReject + }) + return { promise, resolve, reject } +} + +function createTask(cwd: string) { + const getState = vi.fn(async () => ({ maxImageFileSize: 5, maxTotalImageSize: 20 })) + return { + cwd, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + say: vi.fn().mockResolvedValue(undefined), + rooIgnoreController: { validateAccess: vi.fn(() => true) }, + fileContextTracker: { trackFileContext: vi.fn(async () => {}) }, + api: { getModel: () => ({ info: { supportsImages: true } }) }, + providerRef: { deref: () => ({ getState }) }, + } +} + +describe("ordinary read cancellation boundaries", () => { + let directory: string + let task: ReturnType + + function read(file = "source.txt", options: ReadEntryOptions = {}) { + // This public-boundary host double omits Task members unrelated to file reading. + return new ModernFileReader().read({ path: file }, task as unknown as Task, options) + } + + beforeEach(async () => { + vi.restoreAllMocks() + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.realpath).mockImplementation(actual.realpath) + vi.mocked(fs.lstat).mockImplementation(actual.lstat) + vi.mocked(fs.open).mockImplementation(actual.open) + directory = await actual.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-read-cancel-"))) + await fs.writeFile(path.join(directory, "source.txt"), "ordinary content") + task = createTask(directory) + vi.clearAllMocks() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("does no access UI, approval or filesystem work for a pre-aborted ordinary read", async () => { + task.abort = true + task.rooIgnoreController.validateAccess.mockReturnValue(false) + + expect(await read()).toEqual({ path: "source.txt", status: "cancelled" }) + expect(task.rooIgnoreController.validateAccess).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(fs.realpath).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("stops canonical inspection after realpath settles for an abandoned read", async () => { + const canonical = deferred() + const started = deferred() + vi.mocked(fs.realpath).mockImplementationOnce(() => { + started.resolve() + return canonical.promise + }) + const pending = read() + await started.promise + task.abandoned = true + canonical.resolve(path.join(directory, "source.txt")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.lstat).not.toHaveBeenCalled() + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not revalidate the canonical path after cancellation during identity capture", async () => { + const identity = await fs.lstat(path.join(directory, "source.txt"), { bigint: true }) + const inspection = deferred() + const started = deferred() + vi.mocked(fs.lstat).mockImplementationOnce(() => { + started.resolve() + return inspection.promise + }) + const pending = read() + await started.promise + task.abort = true + inspection.resolve(identity) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.realpath).toHaveBeenCalledOnce() + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not check target access when final canonical revalidation finishes after abort", async () => { + const actual = await vi.importActual("fs/promises") + const revalidation = deferred() + const started = deferred() + vi.mocked(fs.realpath).mockImplementation((...args) => { + if (vi.mocked(fs.realpath).mock.calls.length === 2) { + started.resolve() + return revalidation.promise + } + return actual.realpath(...args) + }) + const pending = read() + await started.promise + task.abort = true + revalidation.resolve(path.join(directory, "source.txt")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.rooIgnoreController.validateAccess).toHaveBeenCalledExactlyOnceWith("source.txt") + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not start approval after target access checking resolves for an abandoned task", async () => { + const original = ReadFileAccess.prototype.check + const access = deferred() + const started = deferred() + vi.spyOn(ReadFileAccess.prototype, "check").mockImplementation(function (this: ReadFileAccess, relPath, host) { + if (path.isAbsolute(relPath)) { + started.resolve() + return access.promise + } + return original.call(this, relPath, host) + }) + const pending = read() + await started.promise + task.abandoned = true + access.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not ask when workspace classification resolves after abort", async () => { + const classification = deferred() + const started = deferred() + vi.spyOn(ReadFileTarget, "isOutsideWorkspace").mockImplementationOnce(() => { + started.resolve() + return classification.promise + }) + const pending = read() + await started.promise + task.abort = true + classification.resolve(false) + + expect(await pending).toEqual({ path: "source.txt", status: "cancelled" }) + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("closes a real descriptor opened after abort without starting fstat or content I/O", async () => { + const actual = await vi.importActual("fs/promises") + const opened = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + opened.resolve(file) + await release.promise + return file + }) + const pending = read() + const file = await opened.promise + const stat = vi.spyOn(file, "stat") + const probe = vi.spyOn(file, "read") + const load = vi.spyOn(file, "readFile") + const close = vi.spyOn(file, "close") + task.abort = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(stat).not.toHaveBeenCalled() + expect(probe).not.toHaveBeenCalled() + expect(load).not.toHaveBeenCalled() + expect(close).toHaveBeenCalledOnce() + expect(file.fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not detect binary content after the descriptor probe settles for an abandoned read", async () => { + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const probe = file.read.bind(file) + vi.spyOn(file, "read").mockImplementationOnce(async (...readArgs) => { + const result = await probe(...readArgs) + started.resolve() + await release.promise + return result + }) + opened.resolve(file) + return file + }) + const pending = read() + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(isBinaryFile).not.toHaveBeenCalled() + expect((await opened.promise).fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not start ordinary document extraction after descriptor bytes arrive following abort", async () => { + await fs.writeFile( + path.join(directory, "document.ipynb"), + JSON.stringify({ cells: [{ cell_type: "code", source: ["document content"] }] }), + ) + vi.mocked(isBinaryFile).mockResolvedValueOnce(true) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const load = file.readFile.bind(file) + vi.spyOn(file, "readFile").mockImplementationOnce(async (...readArgs) => { + const bytes = await load(...readArgs) + started.resolve() + await release.promise + return bytes + }) + opened.resolve(file) + return file + }) + const pending = read("document.ipynb") + await started.promise + task.abort = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(extractRawTextFromFile).not.toHaveBeenCalled() + expect((await opened.promise).fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not start text-only extraction after document bytes arrive following abandonment", async () => { + await fs.writeFile( + path.join(directory, "document.ipynb"), + JSON.stringify({ cells: [{ cell_type: "code", source: ["document content"] }] }), + ) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const load = file.readFile.bind(file) + vi.spyOn(file, "readFile").mockImplementationOnce(async (...readArgs) => { + const bytes = await load(...readArgs) + started.resolve() + await release.promise + return bytes + }) + return file + }) + const pending = read("document.ipynb", { textOnly: true }) + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(extractRawTextFromFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not validate or process an image after provider state arrives following abort", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const getState = task.providerRef.deref().getState + const state = deferred>>() + const started = deferred() + getState.mockImplementationOnce(() => { + started.resolve() + return state.promise + }) + const pending = read("image.png") + await started.promise + task.abort = true + state.resolve({ maxImageFileSize: 5, maxTotalImageSize: 20 }) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(validateImageForProcessing).not.toHaveBeenCalled() + expect(processImageFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards a processed image arriving after abandonment without registering context", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const processing = deferred>>() + const started = deferred() + vi.mocked(processImageFile).mockImplementationOnce(() => { + started.resolve() + return processing.promise + }) + const pending = read("image.png") + await started.promise + task.abandoned = true + processing.resolve({ + dataUrl: "data:image/png;base64,bGF0ZQ==", + buffer: Buffer.from("late"), + sizeInKB: 1, + sizeInMB: 0.001, + notice: "late image", + }) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not publish an error when pending image processing rejects after abort", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const processing = deferred>>() + const started = deferred() + vi.mocked(processImageFile).mockImplementationOnce(() => { + started.resolve() + return processing.promise + }) + const pending = read("image.png") + await started.promise + task.abort = true + processing.reject(new Error("late processing failure")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not load image bytes after processing metadata settles following abandonment", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const inspect = file.stat.bind(file) + const stat = vi.spyOn(file, "stat").mockImplementation(async (...statArgs) => { + const result = await inspect(...statArgs) + // B1 identity inspection, image validation, then processing metadata. + if (stat.mock.calls.length === 3) { + started.resolve() + await release.promise + } + return result + }) + vi.spyOn(file, "readFile") + opened.resolve(file) + return file + }) + const pending = read("image.png") + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect((await opened.promise).readFile).not.toHaveBeenCalled() + expect((await opened.promise).fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not base64-encode image bytes arriving after abort", async () => { + const bytes = Buffer.from([137, 80, 78, 71, 0]) + await fs.writeFile(path.join(directory, "image.png"), bytes) + const encode = vi.spyOn(bytes, "toString") + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + vi.spyOn(file, "readFile").mockImplementationOnce(async () => { + started.resolve() + await release.promise + return bytes + }) + return file + }) + const pending = read("image.png") + await started.promise + task.abort = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(encode).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards read content after abort during descriptor closing but retains existing approval feedback", async () => { + task.ask.mockResolvedValueOnce({ + response: "yesButtonClicked", + text: "Inspect only", + images: ["data:image/png;base64,aW1hZ2U="], + }) + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + const close = file.close.bind(file) + vi.spyOn(file, "close").mockImplementationOnce(async () => { + started.resolve() + await release.promise + await close() + }) + opened.resolve(file) + return file + }) + const pending = read() + await started.promise + // Registration already started before cancellation; it cannot be rolled back. + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledOnce() + task.abort = true + release.resolve() + + const result = await pending + expect(result).toMatchObject({ + status: "cancelled", + feedbackText: "Inspect only", + feedbackImages: ["data:image/png;base64,aW1hZ2U="], + }) + expect(result.nativeContent).toBeUndefined() + expect(result.imageDataUrl).toBeUndefined() + expect((await opened.promise).fd).toBe(-1) + expect(task.say).toHaveBeenCalledExactlyOnceWith("user_feedback", "Inspect only", [ + "data:image/png;base64,aW1hZ2U=", + ]) + }) + + it("retains approval feedback if its already-started publication settles after abort", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + task.abort = true + publication.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(fs.open).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not begin feedback publication for an approval arriving after abandonment", async () => { + const approval = deferred>>() + const started = deferred() + task.ask.mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read() + await started.promise + task.abandoned = true + approval.resolve({ response: "yesButtonClicked", text: "Inspect only" }) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("discards a late content-reader result at final delivery while retaining approval feedback", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const content = deferred() + const started = deferred() + vi.spyOn(ReadFileContentReader.prototype, "read").mockImplementationOnce(() => { + started.resolve() + return content.promise + }) + const pending = read() + await started.promise + task.abandoned = true + content.resolve({ path: "source.txt", status: "approved", nativeContent: "late content" }) + + const result = await pending + expect(result).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(result.nativeContent).toBeUndefined() + }) + + it("retains approval feedback when the content reader rejects after abort", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const content = deferred() + const started = deferred() + vi.spyOn(ReadFileContentReader.prototype, "read").mockImplementationOnce(() => { + started.resolve() + return content.promise + }) + const pending = read() + await started.promise + task.abort = true + content.reject(new Error("late read failure")) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).toHaveBeenCalledExactlyOnceWith("user_feedback", "Inspect only", undefined) + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("discards an error result if its already-started diagnostic settles after abort", async () => { + vi.mocked(fs.realpath).mockRejectedValueOnce(new Error("canonical lookup failed")) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + expect(task.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file source.txt: canonical lookup failed", + ) + task.abort = true + publication.resolve(undefined) + + const result = await pending + expect(result.status).toBe("cancelled") + expect(result.error).toBeUndefined() + expect(result.nativeContent).toBeUndefined() + expect(task.say).toHaveBeenCalledOnce() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not open a target whose resolution completes after abort at the reusable content boundary", async () => { + const target = await ReadFileTarget.resolve(path.join(directory, "source.txt")) + vi.clearAllMocks() + const resolution = deferred() + const started = deferred() + vi.spyOn(ReadFileTarget, "resolve").mockImplementationOnce(() => { + started.resolve() + return resolution.promise + }) + // This public-boundary host double omits Task members unrelated to file reading. + const pending = new ReadFileContentReader().read({ path: "source.txt" }, task as unknown as Task, {}) + await started.promise + task.abort = true + resolution.resolve(target) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.open).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("stops canonical metadata work inside the reusable content reader after abandonment", async () => { + const canonical = deferred() + const started = deferred() + vi.mocked(fs.realpath).mockImplementationOnce(() => { + started.resolve() + return canonical.promise + }) + // This public-boundary host double omits Task members unrelated to file reading. + const pending = new ReadFileContentReader().read({ path: "source.txt" }, task as unknown as Task, {}) + await started.promise + task.abandoned = true + canonical.resolve(path.join(directory, "source.txt")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(fs.lstat).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does no canonical I/O for a pre-aborted reusable content read", async () => { + task.abort = true + // This public-boundary host double omits Task members unrelated to file reading. + const result = await new ReadFileContentReader().read({ path: "source.txt" }, task as unknown as Task, {}) + + expect(result.status).toBe("cancelled") + expect(fs.realpath).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("does not classify cancellation during denied-feedback publication as a user rejection", async () => { + task.ask.mockResolvedValueOnce({ response: "noButtonClicked", text: "Do not read" }) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + task.abandoned = true + publication.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Do not read" }) + expect(task.didRejectTool).toBe(false) + expect(fs.open).not.toHaveBeenCalled() + }) + + it("retains received feedback when its pending publication rejects after abort", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + const publication = deferred() + const started = deferred() + task.say.mockImplementationOnce(() => { + started.resolve() + return publication.promise + }) + const pending = read() + await started.promise + task.abort = true + publication.reject(new Error("late publication failure")) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).toHaveBeenCalledOnce() + expect(task.didToolFailInCurrentTurn).toBe(false) + expect(fs.open).not.toHaveBeenCalled() + }) + + it("closes the real descriptor when pending fstat rejects after abandonment without error UI", async () => { + const actual = await vi.importActual("fs/promises") + const inspection = deferred>>() + const started = deferred() + const opened = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + vi.spyOn(file, "stat").mockImplementationOnce(() => { + started.resolve() + return inspection.promise + }) + opened.resolve(file) + return file + }) + const pending = read() + await started.promise + task.abandoned = true + inspection.reject(new Error("late fstat failure")) + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect((await opened.promise).fd).toBe(-1) + expect(task.say).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("keeps uncancelled image-processing rejection visible as a technical error", async () => { + await fs.writeFile(path.join(directory, "image.png"), Buffer.from([137, 80, 78, 71, 0])) + const processing = deferred>>() + const started = deferred() + vi.mocked(processImageFile).mockImplementationOnce(() => { + started.resolve() + return processing.promise + }) + const pending = read("image.png") + await started.promise + processing.reject(new Error("technical processing failure")) + + expect(await pending).toMatchObject({ + status: "error", + error: "Error reading image file: technical processing failure", + }) + expect(task.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading image file image.png: technical processing failure", + ) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards content after abandonment during already-started context persistence", async () => { + const registration = deferred() + const started = deferred() + task.fileContextTracker.trackFileContext.mockImplementationOnce(() => { + started.resolve() + return registration.promise + }) + const pending = read() + await started.promise + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith("source.txt", "read_tool") + task.abandoned = true + registration.resolve() + + const result = await pending + expect(result.status).toBe("cancelled") + expect(result.nativeContent).toBeUndefined() + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledOnce() + expect(task.say).not.toHaveBeenCalled() + }) + + it("does not publish tracking errors when already-started context persistence rejects after abort", async () => { + const registration = deferred() + const started = deferred() + task.fileContextTracker.trackFileContext.mockImplementationOnce(() => { + started.resolve() + return registration.promise + }) + const pending = read() + await started.promise + task.abort = true + registration.reject(new Error("late tracking failure")) + + const result = await pending + expect(result.status).toBe("cancelled") + expect(result.nativeContent).toBeUndefined() + expect(task.say).not.toHaveBeenCalled() + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("does not calculate clipping metadata after text-only tracking settles following abort", async () => { + const clipped = vi.spyOn(ReadFileTextProcessor.prototype, "hasClippedLines") + const registration = deferred() + const started = deferred() + task.fileContextTracker.trackFileContext.mockImplementationOnce(() => { + started.resolve() + return registration.promise + }) + const pending = read("source.txt", { textOnly: true }) + await started.promise + task.abort = true + registration.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(clipped).not.toHaveBeenCalled() + }) + + it("does not decode ordinary text bytes arriving after abandonment", async () => { + const bytes = Buffer.from("late ordinary content") + const decode = vi.spyOn(bytes, "toString") + const actual = await vi.importActual("fs/promises") + const started = deferred() + const release = deferred() + vi.mocked(fs.open).mockImplementationOnce(async (...args) => { + const file = await actual.open(...args) + vi.spyOn(file, "readFile").mockImplementationOnce(async () => { + started.resolve() + await release.promise + return bytes + }) + return file + }) + const pending = read() + await started.promise + task.abandoned = true + release.resolve() + + expect(await pending).toMatchObject({ status: "cancelled" }) + expect(decode).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("retains approval feedback if a technical read diagnostic completes after cancellation", async () => { + task.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Inspect only" }) + vi.spyOn(ReadFileContentReader.prototype, "read").mockRejectedValueOnce(new Error("read failure")) + const publication = deferred() + const started = deferred() + task.say.mockImplementation((type) => { + if (type === "error") { + started.resolve() + return publication.promise + } + return Promise.resolve(undefined) + }) + const pending = read() + await started.promise + task.abort = true + publication.resolve(undefined) + + expect(await pending).toMatchObject({ status: "cancelled", feedbackText: "Inspect only" }) + expect(task.say).toHaveBeenCalledTimes(2) + }) + + it("cancellation wins over a late denied approval result without dropping its feedback", async () => { + const approval = deferred() + const started = deferred() + vi.spyOn(ReadFileAccess.prototype, "requestApproval").mockImplementationOnce(() => { + started.resolve() + return approval.promise + }) + const pending = read() + await started.promise + task.abandoned = true + approval.resolve({ + path: "source.txt", + status: "denied", + feedbackText: "Do not read", + nativeContent: "Denied by user", + }) + + const result = await pending + expect(result).toMatchObject({ status: "cancelled", feedbackText: "Do not read" }) + expect(result.nativeContent).toBeUndefined() + expect(fs.open).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts new file mode 100644 index 0000000000..6dd8514a31 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileDocument.integration.spec.ts @@ -0,0 +1,171 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import ExcelJS from "exceljs" +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../../task/Task" +import { extractRawTextFromFile, extractTextFromFile } from "../../../../integrations/misc/extract-text" +import { ModernFileReader } from "../ModernFileReader" +import { MAX_LINE_LENGTH } from "../readFileConstants" +import type { ReadEntryOptions } from "../types" + +function createReadingTask(cwd: string) { + return { + cwd, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true) }, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + } +} + +describe("text-only document extraction and structural reading", () => { + let directory: string + let reader: ModernFileReader + let task: ReturnType + + beforeEach(async () => { + directory = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-document-reading-")) + reader = new ModernFileReader() + task = createReadingTask(directory) + }) + + afterEach(async () => { + await fs.rm(directory, { recursive: true, force: true }) + }) + + async function writeNotebook(source: string[]) { + await fs.writeFile( + path.join(directory, "example.ipynb"), + JSON.stringify({ cells: [{ cell_type: "code", source }] }), + ) + } + + function readDocument(params: ReadFileParams, options: ReadEntryOptions = { textOnly: true }) { + // This boundary double omits Task fields unrelated to file reading. + return reader.read(params, task as unknown as Task, options) + } + + it("preserves the function declaration and indentation without numbering extracted notebook text twice", async () => { + await writeNotebook([ + "def first():", + " value = 1", + " doubled = value * 2", + " return doubled", + "", + "def second():", + " return 9", + ]) + + const result = await readDocument({ + path: "example.ipynb", + mode: "indentation", + indentation: { anchor_line: 3, max_levels: 1, include_header: false }, + }) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toBe( + "File: example.ipynb\n1 | def first():\n2 | value = 1\n3 | doubled = value * 2\n4 | return doubled\n5 | \n6 | def second():\n7 | return 9\n\nIncluded ranges: 1-7 (total: 7 lines)", + ) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith("example.ipynb", "read_tool") + }) + + it("applies slice limits and continuation offsets to raw extracted notebook lines", async () => { + await writeNotebook(["first", " second", "third"]) + + const result = await readDocument({ path: "example.ipynb", offset: 2, limit: 1 }) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("Status: Showing lines 2-2 of 3 total lines.") + expect(result.nativeContent).toContain("offset=3 and limit=1") + expect(result.nativeContent?.endsWith("2 | second")).toBe(true) + }) + + it("preserves source text resembling line-number prefixes and the existing formatted extraction API", async () => { + await writeNotebook(["12 | actual content", " 3 | indented content"]) + const file = path.join(directory, "example.ipynb") + + expect(await extractRawTextFromFile(file)).toBe("12 | actual content\n 3 | indented content") + expect(await extractTextFromFile(file)).toBe("1 | 12 | actual content\n2 | 3 | indented content\n") + const result = await readDocument({ path: "example.ipynb" }) + expect(result.nativeContent).toBe("File: example.ipynb\n1 | 12 | actual content\n2 | 3 | indented content") + }) + + it("reads XLSX sheet text without stripping legitimate number-like cell prefixes", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + sheet.addRow(["12 | actual content", "second column"]) + await workbook.xlsx.writeFile(path.join(directory, "example.xlsx")) + + const result = await readDocument({ path: "example.xlsx" }) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toBe( + "File: example.xlsx\n1 | --- Sheet: Data ---\n2 | 12 | actual content\tsecond column", + ) + }) + + it("clips raw document lines without spending the source budget on extraction prefixes", async () => { + await writeNotebook(["x".repeat(MAX_LINE_LENGTH + 10)]) + + const result = await readDocument({ path: "example.ipynb" }) + + expect(result.nativeContent).toBe(`File: example.ipynb\n1 | ${"x".repeat(MAX_LINE_LENGTH - 3)}...`) + expect(result.longLinesTruncated).toBe(true) + }) + + it("keeps empty notebooks empty in both raw and existing formatted extraction APIs", async () => { + const file = path.join(directory, "example.ipynb") + await fs.writeFile(file, JSON.stringify({ cells: [] })) + + expect(await extractRawTextFromFile(file)).toBe("") + expect(await extractTextFromFile(file)).toBe("") + }) + + it("rejects an out-of-range ordinary document read instead of returning the entire large document", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + for (let index = 1; index <= 2101; index++) sheet.addRow([`row ${index}`]) + await workbook.xlsx.writeFile(path.join(directory, "large.xlsx")) + + const result = await readDocument({ path: "large.xlsx", offset: 2500, limit: 1 }, {}) + + expect(result).toMatchObject({ + status: "error", + nativeContent: "File: large.xlsx\nError: offset 2499 is beyond file end (2102 lines)", + }) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("limits ordinary document output to the requested clipped line without returning unrelated rows", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + for (let index = 1; index <= 2100; index++) sheet.addRow([`row ${index}`]) + sheet.addRow(["x".repeat(5000)]) + await workbook.xlsx.writeFile(path.join(directory, "large.xlsx")) + + const result = await readDocument({ path: "large.xlsx", offset: 2102, limit: 1 }, {}) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toBe(`File: large.xlsx\n2102 | ${"x".repeat(MAX_LINE_LENGTH - 3)}...`) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith("large.xlsx", "read_tool") + }) + + it("applies the default line limit and continuation guidance to ordinary documents", async () => { + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet("Data") + for (let index = 1; index <= 2101; index++) sheet.addRow([`row ${index}`]) + await workbook.xlsx.writeFile(path.join(directory, "large.xlsx")) + + const result = await readDocument({ path: "large.xlsx" }, {}) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("Status: Showing lines 1-2000 of 2102 total lines.") + expect(result.nativeContent).toContain("offset=2001 and limit=2000") + expect(result.nativeContent?.endsWith("2000 | row 1999")).toBe(true) + expect(result.nativeContent).not.toContain("row 2101") + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts new file mode 100644 index 0000000000..673157caf3 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileImage.integration.spec.ts @@ -0,0 +1,314 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import type { Task } from "../../../task/Task" +import type { ToolCallbacks } from "../../BaseTool" +import { ReadFileTool } from "../ReadFileTool" +import { ReadFileResultFormatter } from "../ReadFileResultFormatter" +import * as imageHelpers from "../../helpers/imageHelpers" + +const pngBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aO1sAAAAASUVORK5CYII=", + "base64", +) +// Minimal baseline grayscale JPEG: unit quantization, zero DC, end-of-block AC. +const jpegBytes = Buffer.concat([ + Buffer.from("ffd8ffdb004300", "hex"), + Buffer.alloc(64, 1), + Buffer.from("ffc0000b080001000101011100ffc4002600", "hex"), + Buffer.from([1, ...Array(15).fill(0), 0, 16, 1, ...Array(15).fill(0), 0]), + Buffer.from("ffda0008010100003f003fffd9", "hex"), +]) +const supportedImages = [ + { extension: ".png", mimeType: "image/png", bytes: pngBytes }, + { extension: ".jpg", mimeType: "image/jpeg", bytes: jpegBytes }, + { extension: ".jpeg", mimeType: "image/jpeg", bytes: jpegBytes }, + { + extension: ".gif", + mimeType: "image/gif", + bytes: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), + }, + { + extension: ".webp", + mimeType: "image/webp", + bytes: Buffer.from("UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA", "base64"), + }, +] + +// A complete synthetic 1x1, 24-bit BMP, including its padded pixel row. +function createBitmap(): Buffer { + const bytes = Buffer.alloc(58) + bytes.write("BM") + bytes.writeUInt32LE(bytes.length, 2) + bytes.writeUInt32LE(54, 10) + bytes.writeUInt32LE(40, 14) + bytes.writeInt32LE(1, 18) + bytes.writeInt32LE(1, 22) + bytes.writeUInt16LE(1, 26) + bytes.writeUInt16LE(24, 28) + bytes.writeUInt32LE(4, 34) + bytes[54] = 255 + return bytes +} + +describe("public file-read image payload contract", () => { + let directory: string + + function createTask(supportsImages = true, maxImageFileSize?: number) { + return { + cwd: directory, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true) }, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + api: { getModel: () => ({ info: { supportsImages } }) }, + providerRef: { deref: () => ({ getState: async () => ({ maxImageFileSize }) }) }, + } + } + + async function read(file: string, task = createTask()) { + const tool = new ReadFileTool() + const entry = vi.spyOn(tool, "readEntry") + const callbacks = { + pushToolResult: vi.fn(), + askApproval: vi.fn(), + handleError: vi.fn(), + } + // The host double omits Task members unrelated to reading; all readers and formatters are real. + await tool.execute({ path: file }, task as unknown as Task, callbacks) + return { result: await entry.mock.results[0].value, blocks: callbacks.pushToolResult.mock.calls[0][0], task } + } + + beforeEach(async () => { + directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-image-reading-"))) + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("rejects a real BMP without adding unsupported image blocks or file context", async () => { + const bytes = createBitmap() + await fs.writeFile(path.join(directory, "image.bmp"), bytes) + + const { result, blocks, task } = await read("image.bmp") + + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(result.status).toBe("unsupported") + expect(result.imageDataUrl).toBeUndefined() + expect(result.nativeContent).toContain("Unsupported image MIME type: image/bmp") + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(task.didRejectTool).toBe(false) + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("does not let a PNG-named alias hide an unsupported canonical BMP target", async () => { + const bytes = createBitmap() + await fs.writeFile(path.join(directory, "target.bmp"), bytes) + await fs.symlink(path.join(directory, "target.bmp"), path.join(directory, "alias.png")) + + const { result, blocks, task } = await read("alias.png") + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: image/bmp") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not label an unknown canonical image format as PNG through an alias", async () => { + const bytes = Buffer.from("00000018667479706865696300000000686569636d696631", "hex") + await fs.writeFile(path.join(directory, "target.heic"), bytes) + await fs.symlink(path.join(directory, "target.heic"), path.join(directory, "alias.png")) + + const { result, blocks, task } = await read("alias.png") + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: unknown") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("checks the actual processed data URL even when both filename and target are PNG", async () => { + await fs.writeFile(path.join(directory, "image.png"), pngBytes) + const processImage = imageHelpers.processImageFile + // Simulate a processor returning an encoding different from its requested filename. + vi.spyOn(imageHelpers, "processImageFile").mockImplementation(async (...args) => { + const image = await processImage(...args) + return { ...image, dataUrl: image.dataUrl.replace("image/png", "image/tiff") } + }) + + const { result, blocks, task } = await read("image.png") + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: image/tiff") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).not.toEqual(expect.arrayContaining([expect.objectContaining({ type: "image" })])) + expect(JSON.stringify(blocks)).not.toContain(pngBytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(supportedImages)( + "preserves $extension bytes in final $mimeType blocks", + async ({ extension, mimeType, bytes }) => { + const file = `image${extension}` + await fs.writeFile(path.join(directory, file), bytes) + + const { result, blocks, task } = await read(file) + + expect(result.status).toBe("approved") + expect(blocks).toEqual([ + { type: "image", source: { type: "base64", media_type: mimeType, data: bytes.toString("base64") } }, + { type: "text", text: result.nativeContent }, + ]) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledWith(file, "read_tool") + }, + ) + + // Binary signature fixtures suffice: unsupported formats must be rejected before decoding. + it.each([ + { extension: ".ico", mimeType: "image/x-icon", bytes: Buffer.from("000001000000", "hex") }, + { extension: ".tiff", mimeType: "image/tiff", bytes: Buffer.from("49492a0008000000000000000000", "hex") }, + { extension: ".tif", mimeType: "image/tiff", bytes: Buffer.from("4d4d002a00000008000000000000", "hex") }, + { + extension: ".avif", + mimeType: "image/avif", + bytes: Buffer.from("0000001466747970617669660000000061766966", "hex"), + }, + ])("rejects $extension without returning bytes or registering context", async ({ extension, mimeType, bytes }) => { + await fs.writeFile(path.join(directory, `image${extension}`), bytes) + + const { result, blocks, task } = await read(`image${extension}`) + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain(`Unsupported image MIME type: ${mimeType}`) + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).toEqual([{ type: "text", text: result.nativeContent }]) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each([ + { file: "image.png", bytes: pngBytes }, + { file: "image.bmp", bytes: createBitmap() }, + ])("preserves the non-image-capable model notice for $file", async ({ file, bytes }) => { + await fs.writeFile(path.join(directory, file), bytes) + + const { result, blocks, task } = await read(file, createTask(false)) + + expect(result.status).toBe("approved") + expect(result.imageDataUrl).toBeUndefined() + expect(result.nativeContent).toContain("current model does not support images") + expect(blocks).toEqual([{ type: "text", text: result.nativeContent }]) + expect(JSON.stringify(blocks)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).toHaveBeenCalledWith(file, "read_tool") + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it.each([ + { file: "image.png", bytes: pngBytes }, + { file: "image.bmp", bytes: createBitmap() }, + ])("keeps text-only entry reads of $file unsupported and content-free", async ({ file, bytes }) => { + await fs.writeFile(path.join(directory, file), bytes) + const task = createTask() + // The host double intentionally omits Task members unrelated to file reading. + const result = await new ReadFileTool().readEntry({ path: file }, task as unknown as Task, { textOnly: true }) + + expect(result.status).toBe("unsupported") + expect(result.error).toBe("Batch images are not supported; use read_file.") + expect(result.imageDataUrl).toBeUndefined() + expect(new ReadFileResultFormatter().format(result, true)).toEqual([]) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("keeps legacy BMP reads string-only without creating an image block", async () => { + const bytes = createBitmap() + await fs.writeFile(path.join(directory, "image.bmp"), bytes) + const task = createTask() + const pushToolResult = vi.fn() + // Legacy reads use the same intentionally partial host double as modern reads. + await new ReadFileTool().execute( + { files: [{ path: "image.bmp" }], _legacyFormat: true }, + task as unknown as Task, + { + pushToolResult, + askApproval: vi.fn(), + handleError: vi.fn(), + }, + ) + + expect(pushToolResult).toHaveBeenCalledWith( + "File: image.bmp\n[Image file - content processed for vision model]", + ) + expect(JSON.stringify(pushToolResult.mock.calls)).not.toContain(bytes.toString("base64")) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("labels supported alias reads with the canonical encoding rather than the alias extension", async () => { + await fs.writeFile(path.join(directory, "target.png"), pngBytes) + await fs.symlink(path.join(directory, "target.png"), path.join(directory, "alias.jpg")) + + const { result, blocks } = await read("alias.jpg") + + expect(result.status).toBe("approved") + expect(blocks).toEqual([ + { type: "image", source: { type: "base64", media_type: "image/png", data: pngBytes.toString("base64") } }, + { type: "text", text: result.nativeContent }, + ]) + }) + + it("rejects unsupported BMP before size validation can register it as a successful notice", async () => { + await fs.writeFile(path.join(directory, "image.bmp"), createBitmap()) + + const { result, blocks, task } = await read("image.bmp", createTask(true, 0)) + + expect(result.status).toBe("unsupported") + expect(result.nativeContent).toContain("Unsupported image MIME type: image/bmp") + expect(blocks).toEqual([{ type: "text", text: result.nativeContent }]) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("preserves approval feedback and supported feedback images for a supported file read", async () => { + await fs.writeFile(path.join(directory, "image.png"), pngBytes) + const task = createTask() + const imageDataUrl = `data:image/png;base64,${pngBytes.toString("base64")}` + task.ask.mockResolvedValue({ response: "yesButtonClicked", text: "Inspect only", images: [imageDataUrl] }) + + const { result, blocks } = await read("image.png", task) + + expect(result.feedbackText).toBe("Inspect only") + expect(task.say).toHaveBeenCalledWith("user_feedback", "Inspect only", [imageDataUrl]) + const image = { + type: "image", + source: { type: "base64", media_type: "image/png", data: pngBytes.toString("base64") }, + } + expect(blocks).toEqual([ + { type: "text", text: JSON.stringify({ status: "approved", feedback: "Inspect only" }) }, + image, + image, + { type: "text", text: result.nativeContent }, + ]) + expect(task.didRejectTool).toBe(false) + expect(task.didToolFailInCurrentTurn).toBe(false) + }) + + it("keeps ordinary SVG source readable as text rather than creating an unsupported image block", async () => { + const source = '' + await fs.writeFile(path.join(directory, "image.svg"), source) + + const { result, blocks } = await read("image.svg") + + expect(result.status).toBe("approved") + expect(result.imageDataUrl).toBeUndefined() + expect(blocks).toEqual([{ type: "text", text: `File: image.svg\n1 | ${source}` }]) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts b/src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts new file mode 100644 index 0000000000..d97f7c6c68 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileResultFormatter.spec.ts @@ -0,0 +1,219 @@ +import { ReadFileResultFormatter } from "../ReadFileResultFormatter" +import type { FileResult } from "../types" +import { formatResponse } from "../../../prompts/responses" + +const image = "data:image/png;base64,aW1hZ2U=" + +describe("ReadFileResultFormatter", () => { + let formatter: ReadFileResultFormatter + + beforeEach(() => { + formatter = new ReadFileResultFormatter() + }) + + it.each([ + { path: "a", status: "approved", nativeContent: "File: a\n1 | content" }, + { path: "a", status: "error", nativeContent: "File: a\nError: missing" }, + { path: "a", status: "blocked", nativeContent: "File: a\nError: blocked" }, + ] satisfies FileResult[])("returns $status file text unchanged", (file) => { + expect(formatter.format(file, false)).toEqual([{ type: "text", text: file.nativeContent }]) + }) + + it("returns an empty array when the file has no content or feedback", () => { + expect(formatter.format({ path: "a", status: "cancelled" }, false)).toEqual([]) + }) + + it("places feedback and images before the file text without losing content", () => { + const result = formatter.format( + { + path: "a", + status: "approved", + nativeContent: "File: a\n1 | content", + feedbackText: "inspect carefully", + feedbackImages: [image], + imageDataUrl: image, + }, + true, + ) + expect(result.map((block) => block.type)).toEqual(["text", "image", "image", "text"]) + expect(result[0]).toMatchObject({ type: "text", text: expect.stringContaining("inspect carefully") }) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a\n1 | content" }) + }) + + it("keeps user feedback images before the image read from the file", () => { + const result = formatter.format( + { + path: "a", + status: "approved", + nativeContent: "File: a", + feedbackImages: [image], + imageDataUrl: "data:image/jpeg;base64,ZmlsZQ==", + }, + true, + ) + expect(result.filter((block) => block.type === "image")).toMatchObject([ + { source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } }, + { source: { type: "base64", media_type: "image/jpeg", data: "ZmlsZQ==" } }, + ]) + }) + + it("includes a file image without duplicating its text when there is no feedback", () => { + const result = formatter.format( + { path: "a", status: "approved", nativeContent: "File: a", imageDataUrl: image }, + true, + ) + expect(result.map((block) => block.type)).toEqual(["image", "text"]) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a" }) + }) + + it("omits file images for text-only models", () => { + expect( + formatter.format({ path: "a", status: "approved", nativeContent: "File: a", imageDataUrl: image }, false), + ).toEqual([{ type: "text", text: "File: a" }]) + }) + + it("does not format image blocks for text-only models", () => { + const imageBlocks = vi.spyOn(formatResponse, "imageBlocks") + try { + expect( + formatter.format( + { + path: "a", + status: "approved", + nativeContent: "File: a", + feedbackImages: [image], + imageDataUrl: image, + }, + false, + ), + ).toEqual([{ type: "text", text: "File: a" }]) + expect(imageBlocks).not.toHaveBeenCalled() + } finally { + imageBlocks.mockRestore() + } + }) + + it("retains approval feedback as text when there are no images", () => { + const result = formatter.format( + { path: "a", status: "approved", nativeContent: "File: a", feedbackText: "inspect carefully" }, + false, + ) + expect(result).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback("inspect carefully") }, + { type: "text", text: "File: a" }, + ]) + }) + + it("retains denial feedback and omits images for text-only models", () => { + const result = formatter.format( + { + path: "a", + status: "denied", + nativeContent: "File: a\nStatus: Denied by user", + feedbackText: "private file", + feedbackImages: [image], + }, + false, + ) + expect(result).toEqual([ + { type: "text", text: formatResponse.toolDeniedWithFeedback("private file") }, + { type: "text", text: "File: a\nStatus: Denied by user" }, + ]) + }) + + it.each(["error", "blocked", "cancelled", "unsupported", "pending"] as const)( + "does not label a %s result as approval or denial", + (status) => { + const file: FileResult = { path: "a", status, nativeContent: "File: a", feedbackText: "inspect carefully" } + expect(formatter.format(file, false)).toEqual([{ type: "text", text: "File: a" }]) + }, + ) + + it.each(["error", "blocked", "cancelled", "unsupported", "pending"] as const)( + "does not invent feedback for a %s result without content", + (status) => { + expect(formatter.format({ path: "a", status, feedbackText: "inspect carefully" }, false)).toEqual([]) + }, + ) + + it.each(["denied", "error", "blocked", "cancelled", "unsupported", "pending"] as const)( + "returns the %s response without including file images", + (status) => { + const file: FileResult = { + path: "a", + status, + nativeContent: `File: a\nStatus: ${status}`, + feedbackImages: [image], + imageDataUrl: "data:image/jpeg;base64,ZmlsZQ==", + } + const result = formatter.format(file, true) + expect(result.filter((block) => block.type === "image")).toMatchObject([ + { source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } }, + ]) + expect(result.filter((block) => block.type === "text").map((block) => block.text)).toContain( + file.nativeContent, + ) + }, + ) + + it.each(["approved", "denied"] as const)("retains image-only user feedback for %s results", (status) => { + const result = formatter.format({ path: "a", status, nativeContent: "File: a", feedbackImages: [image] }, true) + expect(result.filter((block) => block.type === "image")).toHaveLength(1) + if (status === "denied") { + expect(result[0]).toEqual({ type: "text", text: formatResponse.toolDenied() }) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a" }) + } else { + expect(result.map((block) => block.type)).toEqual(["image", "text"]) + expect(result.at(-1)).toEqual({ type: "text", text: "File: a" }) + } + }) + + it("omits image-only user feedback for text-only models", () => { + expect( + formatter.format( + { path: "a", status: "approved", nativeContent: "File: a", feedbackImages: [image] }, + false, + ), + ).toEqual([{ type: "text", text: "File: a" }]) + }) + + it("includes a generic denial when the user rejects without feedback", () => { + expect(formatter.format({ path: "a", status: "denied", nativeContent: "File: a" }, false)).toEqual([ + { type: "text", text: formatResponse.toolDenied() }, + { type: "text", text: "File: a" }, + ]) + }) + + it("formats denial feedback from the file result alone", () => { + expect( + formatter.format( + { path: "a", status: "denied", nativeContent: "File: a", feedbackText: "private file" }, + false, + ), + ).toEqual([ + { type: "text", text: formatResponse.toolDeniedWithFeedback("private file") }, + { type: "text", text: "File: a" }, + ]) + }) + + it("returns feedback without an empty content block", () => { + expect(formatter.format({ path: "a", status: "approved", feedbackText: "inspect carefully" }, false)).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback("inspect carefully") }, + ]) + }) + + it("returns image-only content without an empty text block", () => { + const result = formatter.format({ path: "a", status: "approved", imageDataUrl: image }, true) + expect(result).toMatchObject([ + { type: "image", source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" } }, + ]) + }) + + it("rejects an unrecognized runtime status instead of silently formatting a successful response", () => { + const malformedResult: unknown = { path: "a", status: "unrecognized", nativeContent: "File: a" } + // An injected JavaScript implementation can violate the TypeScript discriminator contract. + expect(() => Reflect.apply(formatter.format, formatter, [malformedResult, false])).toThrow( + "Unhandled read file status: unrecognized", + ) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts b/src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts new file mode 100644 index 0000000000..9258d8d141 --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileSymlink.integration.spec.ts @@ -0,0 +1,411 @@ +import * as fs from "fs/promises" +import os from "os" +import path from "path" +import * as vscode from "vscode" +import ExcelJS from "exceljs" +import type { ClineSayTool } from "@roo-code/types" +import type { Task } from "../../../task/Task" +import { checkAutoApproval } from "../../../auto-approval" +import { ReadFileTool } from "../ReadFileTool" +import { LegacyFileReader } from "../LegacyFileReader" +import { RooIgnoreController } from "../../../ignore/RooIgnoreController" + +// Call-through host instrumentation; filesystem behavior and approval policy remain real. +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, open: vi.fn(actual.open), readFile: vi.fn(actual.readFile), lstat: vi.fn(actual.lstat) } +}) + +vi.mock("vscode", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + RelativePattern: class { + constructor( + readonly base: string, + readonly pattern: string, + ) {} + }, + } +}) + +describe("file reader resolved-target permission boundary", () => { + let directory: string + let workspace: string + let external: string + let task: ReturnType + let manualApproval: ReturnType Promise>>>> + let approvalMessages: ClineSayTool[] + + function createTask() { + return { + cwd: workspace, + abort: false, + abandoned: false, + didToolFailInCurrentTurn: false, + didRejectTool: false, + rooIgnoreController: { validateAccess: vi.fn().mockReturnValue(true) }, + say: vi.fn().mockResolvedValue(undefined), + fileContextTracker: { trackFileContext: vi.fn().mockResolvedValue(undefined) }, + api: { getModel: () => ({ info: { supportsImages: true } }) }, + providerRef: { deref: () => ({ getState: async () => ({}) }) }, + ask: vi.fn(async (ask, text) => { + approvalMessages.push(JSON.parse(text ?? "{}")) + const policy = await checkAutoApproval({ + cwd: workspace, + ask, + text, + state: { + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: false, + allowedReadFiles: [], + alwaysAllowWrite: false, + alwaysAllowWriteOutsideWorkspace: false, + alwaysAllowWriteProtected: false, + alwaysAllowMcp: false, + alwaysAllowModeSwitch: false, + alwaysAllowSubtasks: false, + alwaysAllowExecute: false, + alwaysAllowFollowupQuestions: false, + }, + }) + return policy.decision === "approve" ? { response: "yesButtonClicked" } : manualApproval() + }), + } + } + + function read(file: string, textOnly = true) { + // The host double intentionally omits Task members unrelated to file reading. + return new ReadFileTool().readEntry({ path: file }, task as unknown as Task, { textOnly }) + } + + async function replaceAfterOpening(filePath: string, replacement: string) { + const { open } = await vi.importActual("fs/promises") + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + const file = await open(...args) + if (args[0] === filePath) { + await fs.rename(filePath, `${filePath}.original`) + await fs.symlink(replacement, filePath) + } + return file + }) + } + + beforeEach(async () => { + vi.clearAllMocks() + const actual = await vi.importActual("fs/promises") + vi.mocked(fs.open).mockImplementation(actual.open) + vi.mocked(fs.readFile).mockImplementation(actual.readFile) + vi.mocked(fs.lstat).mockImplementation(actual.lstat) + directory = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-symlink-reading-"))) + workspace = path.join(directory, "workspace") + external = path.join(directory, "external.txt") + await fs.mkdir(workspace) + await fs.writeFile(external, "synthetic external secret") + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(workspace), name: "workspace", index: 0 }, + ]) + approvalMessages = [] + manualApproval = vi.fn().mockResolvedValue({ response: "noButtonClicked" }) + task = createTask() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await fs.rm(directory, { recursive: true, force: true }) + }) + + it("requires manual approval for an internal symlink to an external file under the real policy", async () => { + await fs.symlink(external, path.join(workspace, "linked.txt")) + + const result = await read("linked.txt") + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + expect(result.status).toBe("denied") + expect(JSON.stringify(result)).not.toContain("synthetic external secret") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not disclose a new symlink target replacing the approved canonical filename while approval waits", async () => { + const unapproved = path.join(directory, "unapproved.txt") + await fs.writeFile(unapproved, "unapproved replacement secret") + await fs.symlink(external, path.join(workspace, "linked.txt")) + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.symlink(unapproved, external) + return { response: "yesButtonClicked" } + }) + + const result = await read("linked.txt") + + expect(manualApproval).toHaveBeenCalledOnce() + expect(JSON.stringify(result)).not.toContain("unapproved replacement secret") + expect(result.status).toBe("error") + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("extracts notebook bytes from the verified file even when its canonical name is replaced after opening", async () => { + const notebook = path.join(workspace, "example.ipynb") + const unapproved = path.join(directory, "unapproved.ipynb") + await fs.writeFile(notebook, JSON.stringify({ cells: [{ cell_type: "code", source: ["approved source"] }] })) + await fs.writeFile( + unapproved, + JSON.stringify({ cells: [{ cell_type: "code", source: ["unapproved source"] }] }), + ) + await replaceAfterOpening(notebook, unapproved) + + const result = await read("example.ipynb") + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("approved source") + expect(result.nativeContent).not.toContain("unapproved source") + }) + + it("ordinary XLSX extraction consumes only the approved descriptor bytes after a filename replacement", async () => { + const spreadsheet = path.join(workspace, "example.xlsx") + const unapproved = path.join(directory, "unapproved.xlsx") + for (const [file, text] of [ + [spreadsheet, "original sheet"], + [unapproved, "unapproved sheet"], + ]) { + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet("Data").addRow([text]) + await workbook.xlsx.writeFile(file) + } + await replaceAfterOpening(spreadsheet, unapproved) + + const result = await read("example.xlsx", false) + + expect(result.status).toBe("approved") + expect(result.nativeContent).toContain("original sheet") + expect(result.nativeContent).not.toContain("unapproved sheet") + }) + + it("returns image bytes only from the approved descriptor after a filename replacement", async () => { + const image = path.join(workspace, "image.png") + const replacement = path.join(directory, "unapproved.png") + const original = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aO1sAAAAASUVORK5CYII=", + "base64", + ) + await fs.writeFile(image, original) + await fs.writeFile(replacement, "unapproved image bytes") + await replaceAfterOpening(image, replacement) + + const result = await read("image.png", false) + + expect(result.status).toBe("approved") + expect(result.imageDataUrl).toBe(`data:image/png;base64,${original.toString("base64")}`) + }) + + it("requires the same manual external-target approval in the legacy reader without changing its string contract", async () => { + await fs.symlink(external, path.join(workspace, "linked.txt")) + // The host double intentionally omits Task members unrelated to file reading. + const result = await new LegacyFileReader().read([{ path: "linked.txt" }], task as unknown as Task) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + expect(result).toBe("File: linked.txt\nStatus: Denied by user") + expect(fs.open).not.toHaveBeenCalled() + }) + + it("keeps ordinary internal files auto-approved under the real policy", async () => { + await fs.writeFile(path.join(workspace, "normal.txt"), "normal workspace content") + + const result = await read("normal.txt") + + expect(manualApproval).not.toHaveBeenCalled() + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: false, path: "normal.txt" }) + expect(result.nativeContent).toBe("File: normal.txt\n1 | normal workspace content") + }) + + it("does no content I/O while a direct external read awaits manual approval, then reads the approved file", async () => { + let release!: (value: Awaited>) => void + let entered!: () => void + const waiting = new Promise((resolve) => { + entered = resolve + }) + manualApproval.mockImplementation(() => { + entered() + return new Promise((resolve) => { + release = resolve + }) + }) + const pending = read(external) + await waiting + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + release({ response: "yesButtonClicked" }) + + const result = await pending + + expect(result.nativeContent).toContain("synthetic external secret") + expect(approvalMessages[0]).toMatchObject({ isOutsideWorkspace: true, content: external }) + }) + + it("reads the approved original target if the requested symlink is retargeted while manual approval waits", async () => { + const linked = path.join(workspace, "linked.txt") + const unapproved = path.join(directory, "unapproved.txt") + await fs.writeFile(unapproved, "unapproved retargeted secret") + await fs.symlink(external, linked) + let release!: (value: Awaited>) => void + let entered!: () => void + const waiting = new Promise((resolve) => { + entered = resolve + }) + manualApproval.mockImplementation(() => { + entered() + return new Promise((resolve) => { + release = resolve + }) + }) + const pending = read("linked.txt") + await waiting + expect(fs.open).not.toHaveBeenCalled() + await fs.unlink(linked) + await fs.symlink(unapproved, linked) + release({ response: "yesButtonClicked" }) + + const result = await pending + + expect(result.nativeContent).toBe("File: linked.txt\n1 | synthetic external secret") + expect(JSON.stringify(result)).not.toContain("unapproved retargeted secret") + }) + + it("keeps a symlink to an ignored internal target blocked without approval or content I/O", async () => { + await fs.writeFile(path.join(workspace, "private.txt"), "ignored synthetic secret") + await fs.writeFile(path.join(workspace, ".rooignore"), "private.txt\n") + await fs.symlink(path.join(workspace, "private.txt"), path.join(workspace, "linked.txt")) + const ignore = new RooIgnoreController(workspace) + try { + await ignore.initialize() + task.rooIgnoreController.validateAccess.mockImplementation((file: string) => ignore.validateAccess(file)) + vi.mocked(fs.readFile).mockClear() + + const result = await read("linked.txt") + + expect(result.status).toBe("blocked") + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + expect(JSON.stringify(result)).not.toContain("ignored synthetic secret") + } finally { + ignore.dispose() + } + }) + + it("rejects a replacement regular file before reading bytes and closes the mismatched descriptor", async () => { + let opened: fs.FileHandle | undefined + const { open } = await vi.importActual("fs/promises") + const readBytes = vi.fn() + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + opened = await open(...args) + vi.spyOn(opened, "read").mockImplementation(readBytes) + vi.spyOn(opened, "readFile").mockImplementation(readBytes) + return opened + }) + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.writeFile(external, "unapproved regular replacement") + return { response: "yesButtonClicked" } + }) + + const result = await read(external) + + expect(result.status).toBe("error") + expect(result.error).toContain("File target changed after approval") + expect(readBytes).not.toHaveBeenCalled() + expect(opened?.fd).toBe(-1) + expect(JSON.stringify(result)).not.toContain("unapproved regular replacement") + }) + + it("legacy reading rejects a canonical filename replaced with a symlink during manual approval", async () => { + const replacement = path.join(directory, "replacement.txt") + await fs.writeFile(replacement, "unapproved legacy secret") + await fs.symlink(external, path.join(workspace, "linked.txt")) + manualApproval.mockImplementation(async () => { + await fs.rename(external, `${external}.original`) + await fs.symlink(replacement, external) + return { response: "yesButtonClicked" } + }) + // The host double intentionally omits Task members unrelated to file reading. + const result = await new LegacyFileReader().read([{ path: "linked.txt" }], task as unknown as Task) + + expect(manualApproval).toHaveBeenCalledOnce() + expect(result).toContain("File: linked.txt\nError:") + expect(result).not.toContain("unapproved legacy secret") + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("preserves missing-file diagnostics without opening or reading content", async () => { + const result = await read("missing.txt") + + expect(result.status).toBe("error") + expect(result.error).toContain("ENOENT") + expect(result.error).toContain("missing.txt") + expect(fs.open).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps normal internal files auto-approved when the workspace folder itself is a symlink", async () => { + await fs.writeFile(path.join(workspace, "normal.txt"), "normal workspace content") + const alias = path.join(directory, "workspace-alias") + await fs.symlink(workspace, alias, "dir") + workspace = alias + task.cwd = alias + vi.spyOn(vscode.workspace, "workspaceFolders", "get").mockReturnValue([ + { uri: vscode.Uri.file(alias), name: "workspace", index: 0 }, + ]) + + const result = await read("normal.txt") + + expect(manualApproval).not.toHaveBeenCalled() + expect(result.nativeContent).toBe("File: normal.txt\n1 | normal workspace content") + expect(approvalMessages[0].isOutsideWorkspace).toBe(false) + }) + + it("fails closed if a canonical parent directory is retargeted between resolution and identity capture", async () => { + const parent = path.join(workspace, "nested") + const outside = path.join(directory, "outside") + await fs.mkdir(parent) + await fs.mkdir(outside) + await fs.writeFile(path.join(parent, "file.txt"), "original internal source") + await fs.writeFile(path.join(outside, "file.txt"), "unapproved parent-retargeted secret") + const { lstat } = await vi.importActual("fs/promises") + vi.spyOn(fs, "lstat").mockImplementation(async (...args) => { + if (args[0] === path.join(parent, "file.txt")) { + await fs.rename(parent, `${parent}.original`) + await fs.symlink(outside, parent, "dir") + } + return lstat(...args) + }) + + const result = await read("nested/file.txt") + + expect(result.status).toBe("error") + expect(JSON.stringify(result)).not.toContain("unapproved parent-retargeted secret") + expect(task.ask).not.toHaveBeenCalled() + expect(fs.open).not.toHaveBeenCalled() + }) + + it("releases the approved descriptor when real notebook extraction fails", async () => { + await fs.writeFile(path.join(workspace, "broken.ipynb"), "invalid notebook JSON") + let opened: fs.FileHandle | undefined + const { open } = await vi.importActual("fs/promises") + vi.spyOn(fs, "open").mockImplementation(async (...args) => { + opened = await open(...args) + return opened + }) + + const result = await read("broken.ipynb") + + expect(result.status).toBe("error") + expect(opened?.fd).toBe(-1) + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts b/src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts new file mode 100644 index 0000000000..f077b4d41e --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileTextProcessor.spec.ts @@ -0,0 +1,63 @@ +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" + +describe("ReadFileTextProcessor", () => { + let processor: ReadFileTextProcessor + + beforeEach(() => { + processor = new ReadFileTextProcessor() + }) + + it("keeps 1-based slice offsets and continuation guidance", () => { + const output = processor.process("first\nsecond\nthird", { path: "a", offset: 2, limit: 1 }) + expect(output).toBe( + [ + "IMPORTANT: File content truncated.", + "\tStatus: Showing lines 2-2 of 3 total lines.", + "\tTo read more: Use the read_file tool with offset=3 and limit=1.", + "\t", + "\t2 | second", + ].join("\n"), + ) + expect(output).toContain("Showing lines 2-2 of 3 total lines") + expect(output).toContain("offset=3 and limit=1") + expect(output).toContain("2 | second") + expect(output).not.toContain("1 | first") + }) + + it("preserves the slice reader's existing representation of an empty source", () => { + expect(processor.process("", { path: "a" })).toBe("1 | ") + }) + + it("preserves an out-of-range error rather than reporting a nonempty file as empty", () => { + const output = processor.process("first\nsecond", { path: "a", offset: 50, limit: 1 }) + expect(output).toBe("Error: offset 49 is beyond file end (2 lines)") + }) + + it("uses the anchor and preserves structural range summaries", () => { + const output = processor.process("function sample() {\n return 1\n}\nconst tail = 2", { + path: "a", + mode: "indentation", + indentation: { anchor_line: 2, include_header: false }, + }) + expect(output).toContain("function sample()") + expect(output).toContain("return 1") + expect(output).toMatch(/Included ranges:|IMPORTANT: File content truncated/) + }) + + it("preserves indentation anchor errors without appending a range summary", () => { + const output = processor.process("first\nsecond", { + path: "a", + mode: "indentation", + indentation: { anchor_line: 50 }, + }) + expect(output).toBe("Error: anchor_line 50 is out of range (1-2)") + }) + + it("detects long-line clipping only for returned lines", () => { + const source = "short\n" + "x".repeat(3000) + expect(processor.hasClippedLines(source, "1 | short")).toBe(false) + expect(processor.hasClippedLines(source, "2 | xxx...")).toBe(true) + expect(processor.hasClippedLines(source, "Error: no lines returned")).toBe(false) + expect(processor.hasClippedLines(source, "3 | missing line")).toBe(false) + }) +}) diff --git a/src/core/tools/file-reading/__tests__/readFileTool.spec.ts b/src/core/tools/file-reading/__tests__/readFileTool.spec.ts new file mode 100644 index 0000000000..cebc7b821d --- /dev/null +++ b/src/core/tools/file-reading/__tests__/readFileTool.spec.ts @@ -0,0 +1,3081 @@ +/** + * Tests for ReadFileTool - Codex-inspired file reading with indentation mode support. + * + * These tests cover: + * - Input validation (missing path parameter) + * - RooIgnore blocking + * - Directory read error handling + * - Binary file handling (images, PDF, DOCX, unsupported) + * - Image memory limits + * - Approval flow (approve, deny, feedback) + * - Text file processing (slice and indentation modes) + * - Output structure formatting + */ + +import path from "path" +import type { Task } from "../../../task/Task" +import { AskIgnoredError } from "../../../task/AskIgnoredError" +import type { ToolUse } from "../../../../shared/tools" + +import { isBinaryFile } from "isbinaryfile" + +import { ReadFileTool } from "../ReadFileTool" +import { ReadFileResultFormatter } from "../ReadFileResultFormatter" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { LegacyFileReader } from "../LegacyFileReader" +import { ModernFileReader } from "../ModernFileReader" +import { ReadFileContentReader } from "../ReadFileContentReader" +import { ReadFileTextReader } from "../strategies/ReadFileTextReader" +import { ReadFileAccess } from "../ReadFileAccess" +import { ReadFileErrorReporter } from "../ReadFileErrorReporter" +import { ReadFileBinaryReader } from "../strategies/ReadFileBinaryReader" +import { ReadFileImageReader } from "../strategies/ReadFileImageReader" +import { ReadFileDocumentReader } from "../strategies/ReadFileDocumentReader" +import { ReadFileStrategy } from "../strategies/ReadFileStrategy" +import type { FileResult, ReadEntryOptions, ReadFileContext } from "../types" +import { formatResponse } from "../../../prompts/responses" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + validateImageForProcessing, + processImageFile, + isSupportedImageFormat, + ImageMemoryTracker, +} from "../../helpers/imageHelpers" +import { + extractTextFromFile, + extractRawTextFromFile, + addLineNumbers, + getSupportedBinaryFormats, +} from "../../../../integrations/misc/extract-text" +import { readWithIndentation, readWithSlice } from "../../../../integrations/misc/indentation-reader" + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("path", async () => { + const originalPath = await vi.importActual("path") + const platformPath = process.env.READ_FILE_TEST_PATH_STYLE === "win32" ? originalPath.win32 : originalPath + const mockedPath = { ...platformPath, resolve: vi.fn(platformPath.resolve) } + return { + ...mockedPath, + default: mockedPath, + } +}) + +vi.mock("fs/promises", () => ({ + readFile: vi.fn(), + stat: vi.fn(), + realpath: vi.fn(async (file: string) => file), + lstat: vi.fn(async () => ({ dev: 1n, ino: 1n, isSymbolicLink: () => false })), + open: vi.fn(async (file: string) => { + const fs = await import("fs/promises") + return { + stat: async () => ({ ...(await fs.stat(file)), dev: 1n, ino: 1n }), + read: async () => ({ bytesRead: 0 }), + readFile: async (encoding?: BufferEncoding) => (encoding ? fs.readFile(file, encoding) : fs.readFile(file)), + close: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +vi.mock("isbinaryfile") + +vi.mock("../../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn(), + extractRawTextFromFile: vi.fn(), + addLineNumbers: vi.fn().mockImplementation((text: string, startLine = 1) => { + if (!text) return "" + const lines = text.split("\n") + return lines.map((line, i) => `${startLine + i} | ${line}`).join("\n") + }), + getSupportedBinaryFormats: vi.fn(() => [".pdf", ".docx", ".ipynb"]), +})) + +vi.mock("../../../../integrations/misc/indentation-reader", () => ({ + readWithIndentation: vi.fn(), + readWithSlice: vi.fn(), +})) + +vi.mock("../../helpers/imageHelpers", async (importOriginal) => ({ + IMAGE_MIME_TYPES: (await importOriginal()).IMAGE_MIME_TYPES, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB: 20, + isSupportedImageFormat: vi.fn(), + validateImageForProcessing: vi.fn(), + processImageFile: vi.fn(), + ImageMemoryTracker: vi.fn().mockImplementation(function () { + return { + getTotalMemoryUsed: vi.fn().mockReturnValue(0), + addMemoryUsage: vi.fn(), + } + }), +})) + +vi.mock("../../../prompts/responses", () => ({ + formatResponse: { + toolDenied: vi.fn(() => "The user denied this operation."), + toolDeniedWithFeedback: vi.fn( + (feedback?: string) => + `The user denied this operation and responded with the message:\n\n${feedback}\n`, + ), + toolApprovedWithFeedback: vi.fn( + (feedback?: string) => + `The user approved this operation and responded with the message:\n\n${feedback}\n`, + ), + rooIgnoreError: vi.fn( + (filePath: string) => + `Access to ${filePath} 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: 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 + }), + imageBlocks: 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 } } + }) + : [] + }), + }, +})) + +// Mock fs/promises +const fsPromises = await import("fs/promises") +const nativePath = await vi.importActual("path") +const nativeTextReader = await vi.importActual( + "../../../../integrations/misc/indentation-reader", +) +const mockedFsReadFile = vi.mocked(fsPromises.readFile) +const mockedFsStat = vi.mocked(fsPromises.stat) + +const mockedIsBinaryFile = vi.mocked(isBinaryFile) +const mockedExtractTextFromFile = vi.mocked(extractTextFromFile) +const mockedExtractRawTextFromFile = vi.mocked(extractRawTextFromFile) +const mockedReadWithSlice = vi.mocked(readWithSlice) +const mockedReadWithIndentation = vi.mocked(readWithIndentation) +const mockedIsSupportedImageFormat = vi.mocked(isSupportedImageFormat) +const mockedValidateImageForProcessing = vi.mocked(validateImageForProcessing) +const mockedProcessImageFile = vi.mocked(processImageFile) + +// ─── Test Helpers ───────────────────────────────────────────────────────────── + +interface MockTaskOptions { + supportsImages?: boolean + rooIgnoreAllowed?: boolean + maxImageFileSize?: number + maxTotalImageSize?: number +} + +function createMockTask(options: MockTaskOptions = {}) { + const { supportsImages = false, rooIgnoreAllowed = true, maxImageFileSize = 5, maxTotalImageSize = 20 } = options + + return { + cwd: "/test/workspace", + api: { + getModel: vi.fn().mockReturnValue({ + info: { supportsImages }, + }), + }, + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + didRejectTool: false, + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }), + say: vi.fn().mockResolvedValue(undefined), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing required parameter: path"), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(rooIgnoreAllowed), + }, + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + maxImageFileSize, + maxTotalImageSize, + }), + }), + }, + } +} + +function createMockCallbacks() { + return { + pushToolResult: vi.fn(), + askApproval: vi.fn(), + handleError: vi.fn(), + } +} + +function expectNoReadSideEffects(task: ReturnType) { + expect(task.ask).not.toHaveBeenCalled() + expect(task.rooIgnoreController.validateAccess).not.toHaveBeenCalled() + expect(path.resolve).not.toHaveBeenCalled() + expect(fsPromises.realpath).not.toHaveBeenCalled() + expect(fsPromises.lstat).not.toHaveBeenCalled() + expect(fsPromises.open).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(task.fileContextTracker.trackFileContext).not.toHaveBeenCalled() +} + +function textResultContaining(text: string) { + return expect.arrayContaining([expect.objectContaining({ type: "text", text: expect.stringContaining(text) })]) +} + +class TestReadStrategy extends ReadFileStrategy { + canRead = vi.fn().mockReturnValue(false) + read = vi.fn() +} + +function createDeferred() { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ReadFileTool", () => { + let readFileTool: ReadFileTool + beforeEach(() => { + readFileTool = new ReadFileTool() + vi.clearAllMocks() + vi.mocked(path.resolve).mockImplementation( + process.env.READ_FILE_TEST_PATH_STYLE === "win32" ? path.win32.resolve : nativePath.resolve, + ) + + // Default mock implementations + mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | test content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + }) + + it("reuses the injected formatter for successive reads", async () => { + const formatter = new ReadFileResultFormatter() + const formatted = [{ type: "text" as const, text: "formatted file response" }] + const format = vi.spyOn(formatter, "format").mockReturnValue(formatted) + const tool = new ReadFileTool(formatter) + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + await tool.execute({ path: "first.ts" }, task, callbacks) + await tool.execute({ path: "second.ts" }, task, callbacks) + + expect(format).toHaveBeenCalledTimes(2) + expect(format).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ path: "first.ts", status: "approved" }), + true, + ) + expect(format).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ path: "second.ts", status: "approved" }), + true, + ) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, formatted) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, formatted) + }) + + it("reuses the injected text processor for successive reads", async () => { + const processor = new ReadFileTextProcessor() + const process = vi.spyOn(processor, "process").mockReturnValue("1 | processed content") + const contentReader = new ReadFileContentReader(undefined, [new ReadFileTextReader(processor)]) + const tool = new ReadFileTool(undefined, new ModernFileReader(undefined, undefined, contentReader)) + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + await tool.execute({ path: "first.ts" }, task, callbacks) + await tool.execute({ path: "second.ts" }, task, callbacks) + + expect(process).toHaveBeenCalledTimes(2) + expect(process).toHaveBeenNthCalledWith(1, "test content", { path: "first.ts" }) + expect(process).toHaveBeenNthCalledWith(2, "test content", { path: "second.ts" }) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, [ + { type: "text", text: "File: first.ts\n1 | processed content" }, + ]) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, [ + { type: "text", text: "File: second.ts\n1 | processed content" }, + ]) + }) + + it("uses the injected text processor to detect clipped lines in text-only reads", async () => { + const processor = new ReadFileTextProcessor() + vi.spyOn(processor, "process").mockReturnValue("1 | clipped content") + const hasClippedLines = vi.spyOn(processor, "hasClippedLines").mockReturnValue(true) + const contentReader = new ReadFileContentReader(undefined, [new ReadFileTextReader(processor)]) + const tool = new ReadFileTool(undefined, new ModernFileReader(undefined, undefined, contentReader)) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await tool.readEntry({ path: "test.ts" }, mockTask as unknown as Task, { textOnly: true }) + + expect(hasClippedLines).toHaveBeenCalledExactlyOnceWith("test content", "1 | clipped content") + expect(result).toMatchObject({ status: "approved", longLinesTruncated: true }) + }) + + it("delegates readEntry parameters and options without changing the result", async () => { + const reader = new ModernFileReader() + const result: FileResult = { path: "test.ts", status: "approved", longLinesTruncated: true } + const read = vi.spyOn(reader, "read").mockResolvedValue(result) + const tool = new ReadFileTool(undefined, reader) + const params = { path: "test.ts", offset: 2, limit: 1 } + const options: ReadEntryOptions = { textOnly: true } + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await tool.readEntry(params, task, options)).toBe(result) + expect(read).toHaveBeenCalledExactlyOnceWith(params, task, options) + expect(mockTask.ask).not.toHaveBeenCalled() + }) + + it("rejects a zero limit at readEntry before approval or filesystem access", async () => { + const mockTask = createMockTask() + mockedFsReadFile.mockResolvedValue(Buffer.from("one\ntwo\nthree\nfour")) + mockedReadWithSlice.mockImplementation(nativeTextReader.readWithSlice) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts", limit: 0 }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "error", error: expect.stringContaining("limit") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each([-1, 1.5, NaN, Infinity, -Infinity, "1", true, [], {}])( + "rejects invalid runtime limit %j at readEntry", + async (limit) => { + const mockTask = createMockTask() + const params = { path: "test.ts" } + // Deliberately inject values that untyped runtime callers can supply. + Object.defineProperty(params, "limit", { value: limit }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry(params, mockTask as unknown as Task) + + expect(result).toMatchObject({ + status: "error", + error: expect.stringContaining("limit must be a positive integer"), + }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }, + ) + + it("rejects an unused indentation max_lines of zero in slice-mode document reads", async () => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { path: "test.ipynb", mode: "slice", indentation: { max_lines: 0 } }, + mockTask as unknown as Task, + ) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining("max_lines") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it("rejects a fractional offset at readEntry without clamping it", async () => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts", offset: 1.5 }, mockTask as unknown as Task) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining("offset must be a 1-indexed") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each([ + { indentation: { anchor_line: 0 }, diagnostic: "anchor_line must be a 1-indexed" }, + { indentation: { max_levels: -1 }, diagnostic: "max_levels must be a nonnegative integer" }, + ])("rejects malformed unused indentation knobs: $diagnostic", async ({ indentation, diagnostic }) => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts", indentation }, mockTask as unknown as Task) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining(diagnostic) }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each( + ["offset", "anchor_line", "max_levels", "max_lines"].flatMap((name) => + [-1, 1.5, NaN, Infinity, "1", true, [], {}].map((value) => ({ name, value })), + ), + )("validates supplied $name=$value independently of reading mode", async ({ name, value }) => { + const mockTask = createMockTask() + const indentation = {} + const params = { path: "test.ipynb", indentation } + // Deliberately inject values that untyped runtime callers can supply. + Object.defineProperty(name === "offset" ? params : indentation, name, { value }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry(params, mockTask as unknown as Task, { textOnly: true }) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining(name) }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }) + + it.each([false, true])("retains defaults/nulls and one-line slice reads (textOnly=%s)", async (textOnly) => { + const mockTask = createMockTask() + mockedFsReadFile.mockResolvedValue(Buffer.from("one\ntwo")) + mockedReadWithSlice.mockImplementation(nativeTextReader.readWithSlice) + const params = { path: "test.ts" } + Object.defineProperties(params, { + offset: { value: null }, + limit: { value: null }, + indentation: { value: { anchor_line: null, max_levels: null, max_lines: null } }, + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + const defaults = await readFileTool.readEntry({ path: "test.ts" }, task, { textOnly }) + const nulls = await readFileTool.readEntry(params, task, { textOnly }) + const singleLine = await readFileTool.readEntry({ path: "test.ts", offset: 1, limit: 1 }, task, { textOnly }) + + expect(defaults).toMatchObject({ + status: "approved", + approvalStatus: "approved", + nativeContent: "File: test.ts\n1 | one\n2 | two", + }) + expect(nulls.nativeContent).toBe(defaults.nativeContent) + expect(singleLine).toMatchObject({ status: "approved", approvalStatus: "approved" }) + expect(singleLine.nativeContent).toContain("1 | one") + expect(singleLine.nativeContent).not.toContain("2 | two") + }) + + it("allows one-line indentation limits and zero unlimited depth", async () => { + const mockTask = createMockTask() + mockedFsReadFile.mockResolvedValue(Buffer.from("function example() {\n return true\n}")) + mockedReadWithIndentation.mockImplementation(nativeTextReader.readWithIndentation) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { + path: "test.ts", + mode: "indentation", + limit: 1, + indentation: { anchor_line: 2, max_levels: 0, max_lines: 1 }, + }, + mockTask as unknown as Task, + { textOnly: true }, + ) + + expect(result).toMatchObject({ status: "approved", approvalStatus: "approved" }) + expect(result.nativeContent).toContain("2 | return true") + expect(result.nativeContent).not.toContain("1 | function example()") + }) + + it.each(["slice", "indentation"] as const)( + "rejects a top-level zero limit despite a valid hard cap in %s mode", + async (mode) => { + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { path: "test.ipynb", mode, limit: 0, indentation: { anchor_line: 1, max_lines: 1 } }, + mockTask as unknown as Task, + ) + + expect(result).toMatchObject({ status: "error", error: expect.stringContaining("limit") }) + expect(result.approvalStatus).toBeUndefined() + expectNoReadSideEffects(mockTask) + }, + ) + + it("keeps missing-path adapter diagnostics ahead of numeric validation", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "", limit: 0 }, mockTask as unknown as Task, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith("Error: Missing required parameter: path") + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledExactlyOnceWith("read_file", "path") + expect(mockTask.recordToolError).toHaveBeenCalledExactlyOnceWith("read_file") + expect(mockTask.consecutiveMistakeCount).toBe(1) + expectNoReadSideEffects(mockTask) + }) + + it("reuses the injected modern reader for successive tool calls", async () => { + const reader = new ModernFileReader() + const firstResult: FileResult = { path: "first.ts", status: "approved", nativeContent: "first content" } + const secondResult: FileResult = { path: "second.ts", status: "approved", nativeContent: "second content" } + const read = vi.spyOn(reader, "read").mockResolvedValueOnce(firstResult).mockResolvedValueOnce(secondResult) + const tool = new ReadFileTool(undefined, reader) + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + await tool.execute({ path: "first.ts" }, task, callbacks) + await tool.execute({ path: "second.ts" }, task, callbacks) + + expect(read).toHaveBeenCalledTimes(2) + expect(read).toHaveBeenNthCalledWith(1, { path: "first.ts" }, task, {}) + expect(read).toHaveBeenNthCalledWith(2, { path: "second.ts" }, task, {}) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, [{ type: "text", text: "first content" }]) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, [{ type: "text", text: "second content" }]) + }) + + it.each([ + { status: "approved", failed: false }, + { status: "denied", failed: false }, + { status: "blocked", failed: true }, + { status: "error", failed: true }, + { status: "pending", failed: false }, + { status: "cancelled", failed: false }, + { status: "unsupported", failed: false }, + ] as const)( + "formats the delegated $status result and preserves the tool failure flag", + async ({ status, failed }) => { + const result: FileResult = { path: "test.ts", status, nativeContent: "reader result" } + const reader = new ModernFileReader() + vi.spyOn(reader, "read").mockResolvedValue(result) + const formatter = new ReadFileResultFormatter() + const formatted = [{ type: "text" as const, text: "formatted result" }] + const format = vi.spyOn(formatter, "format").mockReturnValue(formatted) + const tool = new ReadFileTool(formatter, reader) + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + await tool.execute({ path: "test.ts" }, mockTask as unknown as Task, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(failed) + expect(format).toHaveBeenCalledExactlyOnceWith(result, true) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith(formatted) + }, + ) + + describe("ModernFileReader composition", () => { + it("reports pre-read failures with an unknown path when no usable path is available", async () => { + const access = new ReadFileAccess() + vi.spyOn(access, "check").mockRejectedValue(new Error("Access check failed")) + const reader = new ModernFileReader(undefined, access) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "" }, mockTask as unknown as Task) + + expect(result).toEqual({ + path: "unknown", + status: "error", + error: "Error reading file: Access check failed", + nativeContent: "File: unknown\nError: Access check failed", + }) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file unknown: Access check failed", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("reports ordinary approval failures as errors rather than silently cancelling", async () => { + const mockTask = createMockTask() + mockTask.ask.mockRejectedValue(new Error("Approval ask failed")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask as unknown as Task) + + expect(result).toEqual({ + path: "test.ts", + status: "error", + error: "Error reading file: Approval ask failed", + nativeContent: "File: test.ts\nError: Approval ask failed", + }) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file test.ts: Approval ask failed", + ) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("treats an access failure after cancellation as cancellation rather than a tool error", async () => { + const access = new ReadFileAccess() + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + vi.spyOn(access, "check").mockImplementationOnce(async () => { + mockTask.abort = true + throw new Error("Access check interrupted") + }) + const errorReporter = new ReadFileErrorReporter() + const report = vi.spyOn(errorReporter, "report") + const reader = new ModernFileReader(errorReporter, access) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask as unknown as Task, { textOnly: true }) + + expect(result).toEqual({ path: "test.ts", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(report).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + + it.each(["blocked", "denied"] as const)("does not read content after %s", async (status) => { + const access = new ReadFileAccess() + const stopped: FileResult = { path: "test.ts", status } + const check = vi.spyOn(access, "check").mockResolvedValue(status === "blocked" ? stopped : undefined) + const approval = vi.spyOn(access, "requestApproval").mockResolvedValue(stopped) + const contentReader = new ReadFileContentReader() + const readContent = vi.spyOn(contentReader, "read") + const reader = new ModernFileReader(undefined, access, contentReader) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await reader.read({ path: "test.ts" }, task)).toBe(stopped) + expect(check).toHaveBeenNthCalledWith(1, "test.ts", task) + if (status !== "blocked") { + expect(check).toHaveBeenNthCalledWith(2, path.resolve(task.cwd, "test.ts"), task) + } + expect(approval).toHaveBeenCalledTimes(status === "blocked" ? 0 : 1) + expect(readContent).not.toHaveBeenCalled() + }) + + it.each(["approved", "error", "cancelled"] as const)( + "preserves approval feedback when content reading returns %s", + async (status) => { + const params = { path: "test.ts" } + const options: ReadEntryOptions = { textOnly: true } + const access = new ReadFileAccess() + vi.spyOn(access, "check").mockResolvedValue(undefined) + vi.spyOn(access, "requestApproval").mockResolvedValue({ + path: params.path, + status: "approved", + entry: params, + feedbackText: "inspect carefully", + feedbackImages: ["data:image/png;base64,aW1hZ2U="], + }) + const contentReader = new ReadFileContentReader() + const readContent = vi.spyOn(contentReader, "read").mockResolvedValue({ + path: params.path, + status, + nativeContent: "content result", + }) + const reader = new ModernFileReader(undefined, access, contentReader) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await reader.read(params, task, options)).toEqual({ + path: params.path, + status, + approvalStatus: "approved", + entry: params, + feedbackText: "inspect carefully", + feedbackImages: ["data:image/png;base64,aW1hZ2U="], + nativeContent: "content result", + }) + expect(readContent).toHaveBeenCalledExactlyOnceWith( + params, + task, + options, + expect.objectContaining({ fullPath: path.resolve(task.cwd, params.path) }), + ) + }, + ) + + it("retains approval feedback when the content-reader boundary rejects rather than returning a result", async () => { + const instruction = "Inspect without changing files" + const mockTask = createMockTask() + mockTask.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: instruction, images: undefined }) + const contentReader = new ReadFileContentReader() + vi.spyOn(contentReader, "read").mockRejectedValueOnce(new Error("Reader operation failed")) + const reader = new ModernFileReader(undefined, undefined, contentReader) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "source.ts" }, mockTask as unknown as Task) + + expect(result).toMatchObject({ + status: "error", + approvalStatus: "approved", + feedbackText: instruction, + }) + expect(new ReadFileResultFormatter().format(result, false)).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback(instruction) }, + { type: "text", text: "File: source.ts\nError: Reader operation failed" }, + ]) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("uses the injected error reporter when access checking fails", async () => { + const error = new Error("Access check failed") + const access = new ReadFileAccess() + vi.spyOn(access, "check").mockRejectedValue(error) + const errorReporter = new ReadFileErrorReporter() + const result: FileResult = { path: "test.ts", status: "error", error: error.message } + const report = vi.spyOn(errorReporter, "report").mockResolvedValue(result) + const reader = new ModernFileReader(errorReporter, access) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await reader.read({ path: "test.ts" }, task)).toBe(result) + expect(report).toHaveBeenCalledExactlyOnceWith("test.ts", task, error) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("ReadFileContentReader routing", () => { + it("describes extensionless binary files without loading their payload", async () => { + const mockTask = createMockTask() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "binary" }, mockTask as unknown as Task) + + expect(result).toMatchObject({ + path: "binary", + status: "approved", + notice: "Binary file format: bin", + nativeContent: "File: binary\nBinary file (bin) - content not displayed", + }) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + }) + + it("reports empty extracted documents without inventing a line range", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedExtractRawTextFromFile.mockResolvedValueOnce("") + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "document.pdf" }, mockTask as unknown as Task, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: document.pdf\nNote: File is empty" }, + ]) + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "document.pdf", + "read_tool", + ) + }) + + it.each([ + { path: "source.ts", binary: false, textOnly: false, expected: "text" }, + { path: "image.png", binary: true, textOnly: false, expected: "image" }, + { path: "document.pdf", binary: true, textOnly: false, expected: "document" }, + { path: "document.docx", binary: true, textOnly: false, expected: "document" }, + { path: "notebook.ipynb", binary: true, textOnly: false, expected: "document" }, + { path: "archive.bin", binary: true, textOnly: false, expected: "binary" }, + { path: "binary", binary: true, textOnly: false, expected: "binary" }, + { path: "document.pdf", binary: true, textOnly: true, expected: "text" }, + { path: "archive.bin", binary: true, textOnly: true, expected: "text" }, + ] as const)( + "selects only the $expected strategy for $path, textOnly=$textOnly", + async ({ path: relPath, binary, textOnly, expected }) => { + mockedIsBinaryFile.mockResolvedValue(binary) + mockedIsSupportedImageFormat.mockImplementation((extension) => extension === ".png") + const strategies = { + text: new ReadFileTextReader(), + image: new ReadFileImageReader(), + document: new ReadFileDocumentReader(), + binary: new ReadFileBinaryReader(), + } + const result: FileResult = { path: relPath, status: "approved", nativeContent: "strategy result" } + const readText = vi.spyOn(strategies.text, "read").mockResolvedValue(result) + const readImage = vi.spyOn(strategies.image, "read").mockResolvedValue(result) + const readDocument = vi.spyOn(strategies.document, "read").mockResolvedValue(result) + const readBinary = vi.spyOn(strategies.binary, "read").mockResolvedValue(result) + const readers = { text: readText, image: readImage, document: readDocument, binary: readBinary } + const reader = new ReadFileContentReader(undefined, Object.values(strategies)) + const params = { path: relPath } + const options: ReadEntryOptions = { textOnly } + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + const context: ReadFileContext = { + params, + task, + fullPath: path.resolve(mockTask.cwd, relPath), + extension: path.extname(relPath), + binary, + options, + } + expect( + Object.entries(strategies) + .filter(([, strategy]) => strategy.canRead(context)) + .map(([name]) => name), + ).toEqual([expected]) + expect(await reader.read(params, task, options)).toBe(result) + expect(readers[expected]).toHaveBeenCalledExactlyOnceWith({ ...context, file: expect.any(Object) }) + for (const [name, read] of Object.entries(readers)) { + if (name !== expected) expect(read).not.toHaveBeenCalled() + } + }, + ) + + it.each([ + { binary: false, textOnly: false }, + { binary: false, textOnly: true }, + { binary: true, textOnly: false }, + { binary: true, textOnly: true }, + ] as const)("routes binary=$binary, textOnly=$textOnly to the correct reader", async ({ binary, textOnly }) => { + mockedIsBinaryFile.mockResolvedValue(binary) + mockedIsSupportedImageFormat.mockReturnValue(false) + const result: FileResult = { path: "test.ts", status: "approved", nativeContent: "delegated content" } + const textReader = new ReadFileTextReader() + const readText = vi.spyOn(textReader, "read").mockResolvedValue(result) + const binaryReader = new ReadFileBinaryReader() + const readBinary = vi.spyOn(binaryReader, "read").mockResolvedValue(result) + const reader = new ReadFileContentReader(undefined, [textReader, binaryReader]) + const mockTask = createMockTask() + const params = { path: "test.ts" } + const options: ReadEntryOptions = { textOnly } + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await reader.read(params, task, options)).toBe(result) + const context: ReadFileContext = { + params, + task, + fullPath: path.resolve(mockTask.cwd, params.path), + extension: ".ts", + binary, + options, + } + if (binary && !textOnly) { + expect(readBinary).toHaveBeenCalledExactlyOnceWith({ ...context, file: expect.any(Object) }) + expect(readText).not.toHaveBeenCalled() + } else { + expect(readText).toHaveBeenCalledExactlyOnceWith({ ...context, file: expect.any(Object) }) + expect(readBinary).not.toHaveBeenCalled() + } + }) + + it("routes text-only documents through text extraction instead of binary rendering", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + const result: FileResult = { path: "document.pdf", status: "approved" } + const textReader = new ReadFileTextReader() + const readText = vi.spyOn(textReader, "read").mockResolvedValue(result) + const binaryReader = new ReadFileBinaryReader() + const readBinary = vi.spyOn(binaryReader, "read") + const reader = new ReadFileContentReader(undefined, [textReader, binaryReader]) + const mockTask = createMockTask() + const params = { path: "document.pdf" } + const options: ReadEntryOptions = { textOnly: true } + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await reader.read(params, task, options)).toBe(result) + expect(readText).toHaveBeenCalledExactlyOnceWith({ + params, + task, + fullPath: path.resolve(mockTask.cwd, params.path), + file: expect.any(Object), + extension: ".pdf", + binary: true, + options, + }) + expect(readBinary).not.toHaveBeenCalled() + }) + + it("rejects unsupported text-only binaries without reading their contents", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + const textReader = new ReadFileTextReader() + const readText = vi.spyOn(textReader, "read") + const binaryReader = new ReadFileBinaryReader() + const readBinary = vi.spyOn(binaryReader, "read") + const reader = new ReadFileContentReader(undefined, [textReader, binaryReader]) + const tool = new ReadFileTool(undefined, new ModernFileReader(undefined, undefined, reader)) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await tool.readEntry({ path: "program.exe" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toEqual({ + path: "program.exe", + status: "unsupported", + approvalStatus: "approved", + error: "Unsupported batch binary format; use read_file.", + entry: { path: "program.exe" }, + feedbackText: undefined, + feedbackImages: undefined, + }) + expect(readText).toHaveBeenCalledTimes(1) + expect(readBinary).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("selects the first applicable injected strategy through the common contract", async () => { + const skipped = new TestReadStrategy() + const selected = new TestReadStrategy() + selected.canRead.mockReturnValue(true) + const result: FileResult = { path: "test.ts", status: "approved", nativeContent: "custom content" } + selected.read.mockResolvedValue(result) + const unused = new TestReadStrategy() + unused.canRead.mockReturnValue(true) + const reader = new ReadFileContentReader(undefined, [skipped, selected, unused]) + const params = { path: "test.ts" } + const options: ReadEntryOptions = {} + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + const context: ReadFileContext = { + params, + task, + fullPath: path.resolve(mockTask.cwd, params.path), + extension: ".ts", + binary: false, + options, + } + expect(await reader.read(params, task, options)).toBe(result) + expect(skipped.canRead).toHaveBeenCalledExactlyOnceWith({ ...context, file: expect.any(Object) }) + expect(selected.canRead).toHaveBeenCalledExactlyOnceWith({ ...context, file: expect.any(Object) }) + expect(selected.read).toHaveBeenCalledExactlyOnceWith({ ...context, file: expect.any(Object) }) + expect(skipped.read).not.toHaveBeenCalled() + expect(unused.canRead).not.toHaveBeenCalled() + expect(unused.read).not.toHaveBeenCalled() + }) + + it("reports an error when no injected strategy can read the file", async () => { + const strategy = new TestReadStrategy() + const reader = new ReadFileContentReader(undefined, [strategy]) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask as unknown as Task, {}) + + expect(result).toMatchObject({ + status: "error", + error: "Error reading file: No file-reading strategy supports 'test.ts'.", + }) + expect(strategy.read).not.toHaveBeenCalled() + }) + + it("reports errors thrown by the selected strategy", async () => { + const strategy = new TestReadStrategy() + strategy.canRead.mockReturnValue(true) + strategy.read.mockRejectedValue(new Error("Strategy read failed")) + const reader = new ReadFileContentReader(undefined, [strategy]) + const mockTask = createMockTask() + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask as unknown as Task, {}) + + expect(result).toMatchObject({ status: "error", error: "Error reading file: Strategy read failed" }) + }) + + it("shares the injected error reporter with binary image handling", async () => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + const error = new Error("Image validation failed") + mockedValidateImageForProcessing.mockRejectedValue(error) + const errorReporter = new ReadFileErrorReporter() + const result: FileResult = { path: "image.png", status: "error", error: error.message } + const report = vi.spyOn(errorReporter, "report").mockResolvedValue(result) + const reader = new ReadFileContentReader(errorReporter) + const mockTask = createMockTask({ supportsImages: true }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + expect(await reader.read({ path: "image.png" }, task, {})).toBe(result) + expect(report).toHaveBeenCalledExactlyOnceWith("image.png", task, error, { + prefix: "Error reading image file: ", + action: "Error reading image file", + }) + }) + }) + + describe("ModernFileReader ordinary cancellation", () => { + it("does not load content or register context when cancellation occurs during descriptor inspection", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + const stats = await mockedFsStat("fixture") + mockedFsStat.mockClear() + const inspection = createDeferred() + const started = createDeferred() + mockedFsStat.mockImplementationOnce(() => { + started.resolve() + return inspection.promise + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pending = readFileTool.readEntry({ path: "source.ts" }, mockTask as unknown as Task) + await started.promise + expect(mockedFsReadFile).not.toHaveBeenCalled() + mockTask.abort = true + inspection.resolve(stats) + + expect(await pending).toMatchObject({ path: "source.ts", status: "cancelled" }) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("does not process an image when cancellation occurs while validation is pending", async () => { + const mockTask = { ...createMockTask({ supportsImages: true }), abort: false, abandoned: false } + mockedIsBinaryFile.mockResolvedValueOnce(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + const validation = createDeferred>>() + const started = createDeferred() + mockedValidateImageForProcessing.mockImplementationOnce(() => { + started.resolve() + return validation.promise + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,aW1hZ2U=", + buffer: Buffer.from("image"), + sizeInKB: 1, + sizeInMB: 0.001, + notice: "Image", + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pending = readFileTool.readEntry({ path: "image.png" }, mockTask as unknown as Task) + await started.promise + mockTask.abandoned = true + validation.resolve({ isValid: true }) + + expect(await pending).toMatchObject({ path: "image.png", status: "cancelled" }) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("discards ordinary document extraction completed after cancellation without formatting or tracking", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockedIsBinaryFile.mockResolvedValueOnce(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + const extraction = createDeferred() + const started = createDeferred() + mockedExtractRawTextFromFile.mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pending = readFileTool.readEntry({ path: "document.pdf" }, mockTask as unknown as Task) + await started.promise + mockTask.abort = true + extraction.resolve("Late document content") + + expect(await pending).toMatchObject({ path: "document.pdf", status: "cancelled" }) + expect(mockedReadWithSlice).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.say).not.toHaveBeenCalled() + }) + }) + + describe("ModernFileReader text-only reads", () => { + it("reports technical approval failures in text-only mode instead of falsely cancelling", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask.ask.mockRejectedValueOnce(new Error("Provider state unavailable")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ + path: "source.ts", + status: "error", + error: "Error reading file: Provider state unavailable", + nativeContent: "File: source.ts\nError: Provider state unavailable", + }) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file source.ts: Provider state unavailable", + ) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("reports feedback-publication failures after approval instead of treating them as cancellation", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: "Read only", images: undefined }) + mockTask.say.mockRejectedValueOnce(new Error("Feedback publication failed")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ status: "error", error: "Error reading file: Feedback publication failed" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.say).toHaveBeenNthCalledWith(1, "user_feedback", "Read only", undefined) + expect(mockTask.say).toHaveBeenNthCalledWith( + 2, + "error", + "Error reading file source.ts: Feedback publication failed", + ) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "preserves genuine cancellation when approval rejects after %s", + async (flag) => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask.ask.mockImplementationOnce(async () => { + mockTask[flag] = true + throw new Error("Task is ending") + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toEqual({ path: "source.ts", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }, + ) + + it("does not report a directory error when inspection finishes after cancellation", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + const stats = await mockedFsStat("fixture") + stats.isDirectory = () => true + mockedFsStat.mockClear() + mockedFsStat.mockImplementationOnce(async () => { + mockTask.abort = true + return stats + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "folder" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "folder", status: "cancelled" }) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + }) + + it("keeps pending extraction failures visible when the task is not cancelled", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + const extraction = createDeferred() + const started = createDeferred() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedExtractRawTextFromFile.mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + const params = { path: "document.pdf" } + + // This reader-boundary double omits Task fields unrelated to file reading. + const pendingRead = readFileTool.readEntry(params, mockTask as unknown as Task, { textOnly: true }) + await started.promise + expect(mockTask.say).not.toHaveBeenCalled() + extraction.reject(new Error("Document extraction failed")) + + expect(await pendingRead).toEqual({ + path: "document.pdf", + status: "error", + approvalStatus: "approved", + entry: params, + feedbackText: undefined, + feedbackImages: undefined, + error: "Error reading file: Document extraction failed", + nativeContent: "File: document.pdf\nError: Document extraction failed", + }) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file document.pdf: Document extraction failed", + ) + expect(mockedFsReadFile).toHaveBeenCalledOnce() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "preserves cancellation when pending document extraction rejects after %s", + async (flag) => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + const extraction = createDeferred() + const started = createDeferred() + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedExtractRawTextFromFile.mockImplementationOnce(() => { + started.resolve() + return extraction.promise + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const pendingRead = readFileTool.readEntry({ path: "document.pdf" }, mockTask as unknown as Task, { + textOnly: true, + }) + await started.promise + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + mockTask[flag] = true + extraction.reject(new Error("Extractor interrupted")) + + expect(await pendingRead).toMatchObject({ path: "document.pdf", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedFsReadFile).toHaveBeenCalledOnce() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }, + ) + + it("extracts and slices document text from pinned bytes in text-only mode", async () => { + const mockTask = createMockTask() + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedIsBinaryFile.mockResolvedValue(true) + mockedExtractRawTextFromFile.mockResolvedValue("first\nsecond\nthird") + mockedReadWithSlice.mockReturnValue({ + content: "2 | second", + returnedLines: 1, + totalLines: 3, + wasTruncated: false, + includedRanges: [[2, 2]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry( + { path: "document.pdf", offset: 2, limit: 1 }, + mockTask as unknown as Task, + { textOnly: true }, + ) + + expect(result).toMatchObject({ + path: "document.pdf", + status: "approved", + nativeContent: "File: document.pdf\n2 | second", + longLinesTruncated: false, + }) + expect(mockedExtractRawTextFromFile).toHaveBeenCalledExactlyOnceWith( + "document.pdf", + Buffer.from("test content"), + ) + expect(mockedReadWithSlice).toHaveBeenCalledExactlyOnceWith("first\nsecond\nthird", 1, 1) + expect(mockedFsReadFile).toHaveBeenCalledOnce() + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "document.pdf", + "read_tool", + ) + }) + + it("discards late file content after cancellation without processing or tracking it", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedFsReadFile.mockImplementationOnce(async () => { + mockTask.abort = true + return Buffer.from("late content") + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "cancelled" }) + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "test.ts")) + expect(mockedReadWithSlice).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("stops before content reading when cancellation arrives during binary detection", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedIsBinaryFile.mockImplementationOnce(async () => { + mockTask.abandoned = true + return false + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "cancelled" }) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("stops before binary detection when cancellation arrives during file inspection", async () => { + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + const stats = await mockedFsStat("fixture") + mockedFsStat.mockClear() + mockedFsStat.mockImplementationOnce(async () => { + mockTask.abort = true + return stats + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "test.ts" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ path: "test.ts", status: "cancelled" }) + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("rejects images in text-only reads before inspecting or reading their contents", async () => { + const mockTask = createMockTask({ supportsImages: true }) + mockedIsSupportedImageFormat.mockImplementation((extension) => extension === ".png") + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "image.png" }, mockTask as unknown as Task, { + textOnly: true, + }) + + expect(result).toMatchObject({ + path: "image.png", + status: "unsupported", + error: "Batch images are not supported; use read_file.", + }) + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it.each(["abort", "abandoned"] as const)( + "does not request approval for a text-only read after %s", + async (flag) => { + const reader = new ModernFileReader() + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask[flag] = true + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask as unknown as Task, { textOnly: true }) + + expect(result).toEqual({ path: "test.ts", status: "cancelled" }) + expect(mockTask.ask).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + }, + ) + + it("cancels a text-only read when its approval is withdrawn", async () => { + const reader = new ModernFileReader() + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask.ask.mockRejectedValueOnce(new AskIgnoredError("superseded")) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask as unknown as Task, { textOnly: true }) + + expect(result).toEqual({ path: "test.ts", status: "cancelled" }) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + expect(mockTask.say).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractRawTextFromFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("does not read a file when cancellation arrives during approval", async () => { + const reader = new ModernFileReader() + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask.ask.mockImplementation(async () => { + mockTask.abort = true + return { response: "yesButtonClicked", text: undefined, images: undefined } + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await reader.read({ path: "test.ts" }, mockTask as unknown as Task, { textOnly: true }) + + expect(result).toEqual({ + path: "test.ts", + status: "cancelled", + approvalStatus: "approved", + feedbackText: undefined, + feedbackImages: undefined, + }) + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + }) + }) + + describe("input validation", () => { + it("should return error when path is missing", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "" } as any, mockTask as any, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") + expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("read_file", "path") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should return error when path is undefined", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({} as any, mockTask as any, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should return error when offset is 0 or negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "test.txt", offset: 0 }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("offset must be a 1-indexed line number"), + ) + }) + + it("should return error when offset is negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "test.txt", offset: -5 }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("offset must be a 1-indexed line number"), + ) + }) + + it("should return error when anchor_line is 0 or negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute( + { + path: "test.txt", + mode: "indentation", + indentation: { anchor_line: 0 }, + }, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("anchor_line must be a 1-indexed line number"), + ) + }) + + it("should return error when anchor_line is negative", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute( + { + path: "test.txt", + mode: "indentation", + indentation: { anchor_line: -10 }, + }, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("anchor_line must be a 1-indexed line number"), + ) + }) + }) + + describe("RooIgnore handling", () => { + it("blocks access before approval or file inspection when the ignore controller is unavailable", async () => { + const mockTask = { ...createMockTask(), rooIgnoreController: undefined } + const error = + "Access to secret.env 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." + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "secret.env" }, mockTask as unknown as Task) + + expect(result).toEqual({ + path: "secret.env", + status: "blocked", + error, + nativeContent: `File: secret.env\nError: ${error}`, + }) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith("rooignore_error", "secret.env") + expect(mockTask.ask).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedIsBinaryFile).not.toHaveBeenCalled() + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("should block access to rooignore-protected files", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "secret.env" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") + expect(formatResponse.rooIgnoreError).toHaveBeenCalledWith("secret.env") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("blocked by the .rooignore")) + }) + }) + + describe("directory handling", () => { + it("should return error when trying to read a directory", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) + + await readFileTool.execute({ path: "src/utils" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith( + "error", + expect.stringContaining("Cannot read 'src/utils' because it is a directory"), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("it is a directory")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("image handling", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + }) + + it("should process image file when model supports images", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,abc123", + buffer: Buffer.from("test"), + sizeInKB: 512, + sizeInMB: 0.5, + notice: "Image processed successfully", + }) + + await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalled() + expect(mockedProcessImageFile).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalled() + }) + + it("should skip image when model does not support images", async () => { + const mockTask = createMockTask({ supportsImages: false }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "unsupported_model", + notice: "Model does not support image processing", + }) + + await readFileTool.execute({ path: "image.png" }, mockTask as any, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + textResultContaining("Model does not support image processing"), + ) + }) + + it("should skip image when file exceeds size limit", async () => { + const mockTask = createMockTask({ supportsImages: true, maxImageFileSize: 1 }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image file size (10 MB) exceeds the maximum allowed size (1 MB)", + }) + + await readFileTool.execute({ path: "large-image.png" }, mockTask as any, callbacks) + + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("exceeds the maximum allowed")) + }) + + it("should skip image when total memory limit exceeded", async () => { + const mockTask = createMockTask({ supportsImages: true, maxTotalImageSize: 5 }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "memory_limit", + notice: "Skipping image: would exceed total memory limit", + }) + + await readFileTool.execute({ path: "another-image.png" }, mockTask as any, callbacks) + + expect(mockedProcessImageFile).not.toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("would exceed total memory")) + }) + + it("should handle image read errors gracefully", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockRejectedValue(new Error("Failed to read image")) + + await readFileTool.execute({ path: "corrupt.png" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading image file")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Error")) + }) + }) + + describe("binary file handling", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + mockedReadWithSlice.mockImplementation(nativeTextReader.readWithSlice) + }) + + it("should extract text from PDF files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedExtractRawTextFromFile.mockResolvedValueOnce("PDF content here") + + await readFileTool.execute({ path: "document.pdf" }, mockTask as any, callbacks) + + expect(mockedExtractRawTextFromFile).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("PDF content here")) + }) + + it("should extract text from DOCX files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedExtractRawTextFromFile.mockResolvedValueOnce("DOCX content here") + + await readFileTool.execute({ path: "document.docx" }, mockTask as any, callbacks) + + expect(mockedExtractRawTextFromFile).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("DOCX content here")) + }) + + it("rejects an out-of-range empty document instead of bypassing range processing", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedExtractRawTextFromFile.mockResolvedValueOnce("") + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "empty.pdf", offset: 2 }, mockTask as unknown as Task, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: empty.pdf\nError: offset 1 is beyond file end (1 lines)" }, + ]) + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle unsupported binary formats", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + // Return empty array to indicate .exe is not supported + vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx"]) + + await readFileTool.execute({ path: "program.exe" }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Binary file")) + }) + + it("should handle extraction errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedExtractRawTextFromFile.mockRejectedValueOnce(new Error("Extraction failed")) + + await readFileTool.execute({ path: "corrupt.pdf" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error extracting text")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("text file processing", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should read text file with slice mode (default)", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + const content = "line 1\nline 2\nline 3" + mockedFsReadFile.mockResolvedValue(Buffer.from(content)) + mockedReadWithSlice.mockReturnValue({ + content: "1 | line 1\n2 | line 2\n3 | line 3", + returnedLines: 3, + totalLines: 3, + wasTruncated: false, + includedRanges: [[1, 3]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockedReadWithSlice).toHaveBeenCalled() + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("line 1")) + }) + + it("should read text file with offset and limit", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("line 1\nline 2\nline 3\nline 4\nline 5")) + mockedReadWithSlice.mockReturnValue({ + content: "2 | line 2\n3 | line 3", + returnedLines: 2, + totalLines: 5, + wasTruncated: true, + includedRanges: [[2, 3]], + }) + + await readFileTool.execute( + { path: "test.ts", mode: "slice", offset: 2, limit: 2 }, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 1, 2) // offset converted to 0-based + }) + + it("should read text file with indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + const content = "class Foo {\n method() {\n return 42\n }\n}" + mockedFsReadFile.mockResolvedValue(Buffer.from(content)) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | class Foo {\n2 | method() {\n3 | return 42\n4 | }\n5 | }", + returnedLines: 5, + totalLines: 5, + wasTruncated: false, + includedRanges: [[1, 5]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 3 }, + }, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + content, + expect.objectContaining({ + anchorLine: 3, + }), + ) + }) + + it("should show truncation notice when content is truncated", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content...")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | truncated content", + returnedLines: 100, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 100]], + }) + + await readFileTool.execute({ path: "large.ts" }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("truncated")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("To read more")) + }) + + it("should handle empty files", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("")) + mockedReadWithSlice.mockReturnValue({ + content: "", + returnedLines: 0, + totalLines: 0, + wasTruncated: false, + includedRanges: [], + }) + + await readFileTool.execute({ path: "empty.ts" }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("empty")) + }) + }) + + describe("approval flow", () => { + it("preserves received approval instructions when cancellation wins immediately after the response", async () => { + const instruction = "Do not modify any files; only inspect" + const mockTask = { ...createMockTask(), abort: false, abandoned: false } + mockTask.ask.mockImplementationOnce(async () => { + mockTask.abort = true + return { response: "yesButtonClicked", text: instruction, images: undefined } + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + const result = await readFileTool.readEntry({ path: "source.ts" }, mockTask as unknown as Task) + + expect(result.status).toBe("cancelled") + expect(new ReadFileResultFormatter().format(result, false)).toEqual([ + { type: "text", text: formatResponse.toolApprovedWithFeedback(instruction) }, + ]) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("keeps approval instructions in the final model-facing blocks when file reading fails", async () => { + const instruction = "Do not modify any files; only inspect" + const mockTask = createMockTask() + mockTask.ask.mockResolvedValueOnce({ response: "yesButtonClicked", text: instruction, images: undefined }) + mockedFsReadFile.mockRejectedValueOnce(new Error("Missing source file")) + const formatter = new ReadFileResultFormatter() + const format = vi.spyOn(formatter, "format") + const tool = new ReadFileTool(formatter) + const callbacks = createMockCallbacks() + + // This reader-boundary double omits Task fields unrelated to file reading. + await tool.execute({ path: "source.ts" }, mockTask as unknown as Task, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: formatResponse.toolApprovedWithFeedback(instruction) }, + { type: "text", text: "File: source.ts\nError: Missing source file" }, + ]) + expect(format).toHaveBeenCalledWith( + expect.objectContaining({ status: "error", feedbackText: instruction }), + false, + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("should approve file read when user clicks yes", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.any(String), false) + expect(mockTask.didRejectTool).toBe(false) + }) + + it("should deny file read when user clicks no", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockTask.didRejectTool).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Denied by user")) + }) + + it("should include user feedback when provided with approval", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "Please be careful with this file", + images: undefined, + }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Please be careful with this file", undefined) + expect(formatResponse.toolApprovedWithFeedback).toHaveBeenCalledWith("Please be careful with this file") + }) + + it("should include user feedback when provided with denial", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "This file contains secrets", + images: undefined, + }) + + await readFileTool.execute({ path: "secrets.env" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) + expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") + }) + }) + + describe("output structure", () => { + it("should include file path in output", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "src/app.ts" }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("File: src/app.ts")) + }) + + it("should track file context after successful read", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("test.ts", "read_tool") + }) + }) + + describe("error handling", () => { + it("should handle file read errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT: no such file or directory")) + + await readFileTool.execute({ path: "nonexistent.ts" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle stat errors gracefully", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockRejectedValue(new Error("Permission denied")) + + await readFileTool.execute({ path: "protected.ts" }, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) + + describe("getReadFileToolDescription", () => { + it("should return description with path when nativeArgs provided", () => { + const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) + + expect(description).toBe("[read_file for 'src/app.ts']") + }) + + it("should return description with path when params provided", () => { + const description = readFileTool.getReadFileToolDescription("read_file", { path: "src/app.ts" }) + + expect(description).toBe("[read_file for 'src/app.ts']") + }) + + it("should return description indicating missing path", () => { + const description = readFileTool.getReadFileToolDescription("read_file", {}) + + expect(description).toBe("[read_file with missing path]") + }) + }) + + describe("legacy format handling", () => { + it("reports failed legacy image processing without dropping the file or stopping subsequent reads", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + mockedIsBinaryFile.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValueOnce({ isValid: true }) + mockedProcessImageFile.mockRejectedValueOnce(new Error("Image decoding failed")) + mockedFsReadFile.mockResolvedValueOnce("legacy content") + mockedReadWithSlice.mockReturnValueOnce({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute( + { files: [{ path: "image.png" }, { path: "legacy.ts" }], _legacyFormat: true }, + mockTask as unknown as Task, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: image.png\nError: Image decoding failed\n\n---\n\nFile: legacy.ts\n1 | legacy content", + ) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file image.png: Image decoding failed", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockedProcessImageFile).toHaveBeenCalledExactlyOnceWith("image.png", expect.any(Object)) + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "legacy.ts"), "utf8") + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "legacy.ts", + "read_tool", + ) + }) + + it("does not process legacy images when the model does not declare image support", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockTask.api.getModel.mockReturnValue({ info: {} }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute( + { files: [{ path: "image.png" }], _legacyFormat: true }, + mockTask as unknown as Task, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: image.png\nError: Cannot read binary file", + ) + expect(mockedValidateImageForProcessing).not.toHaveBeenCalled() + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + + it("uses legacy image limits and a useful notice when provider config and validation notice are absent", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + mockTask.providerRef.deref.mockReturnValue(undefined) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ isValid: false }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute( + { files: [{ path: "image.png" }], _legacyFormat: true }, + mockTask as unknown as Task, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: image.png\nNotice: Image validation failed", + ) + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + true, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, + expect.any(Object), + ) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + + it("retains legacy file content when context tracking fails afterward", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockTask.fileContextTracker.trackFileContext.mockRejectedValue(new Error("Context tracking failed")) + mockedFsReadFile.mockResolvedValue("legacy content") + mockedReadWithSlice.mockReturnValue({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute( + { files: [{ path: "legacy.ts" }], _legacyFormat: true }, + mockTask as unknown as Task, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith( + "File: legacy.ts\n1 | legacy content\n\n---\n\nFile: legacy.ts\nError: Context tracking failed", + ) + expect(mockTask.say).toHaveBeenCalledExactlyOnceWith( + "error", + "Error reading file legacy.ts: Context tracking failed", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("falls back to legacy text reading when binary detection fails", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedIsBinaryFile.mockRejectedValueOnce(new Error("Binary detection unavailable")) + mockedFsReadFile.mockResolvedValue("legacy content") + mockedReadWithSlice.mockReturnValue({ + content: "1 | legacy content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute( + { files: [{ path: "legacy.ts" }], _legacyFormat: true }, + mockTask as unknown as Task, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith("File: legacy.ts\n1 | legacy content") + expect(mockedFsReadFile).toHaveBeenCalledExactlyOnceWith(path.resolve(mockTask.cwd, "legacy.ts"), "utf8") + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledExactlyOnceWith( + "legacy.ts", + "read_tool", + ) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("reuses the injected legacy reader for successive calls", async () => { + const legacyReader = new LegacyFileReader() + const read = vi.spyOn(legacyReader, "read").mockResolvedValue("legacy file response") + const tool = new ReadFileTool(undefined, undefined, legacyReader) + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + const firstFiles = [{ path: "first.ts" }] + const secondFiles = [{ path: "second.ts" }] + + // This reader-boundary double omits Task fields unrelated to file reading. + const task = mockTask as unknown as Task + await tool.execute({ files: firstFiles, _legacyFormat: true }, task, callbacks) + await tool.execute({ files: secondFiles, _legacyFormat: true }, task, callbacks) + + expect(read).toHaveBeenCalledTimes(2) + expect(read).toHaveBeenNthCalledWith(1, firstFiles, task) + expect(read).toHaveBeenNthCalledWith(2, secondFiles, task) + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(1, "legacy file response") + expect(callbacks.pushToolResult).toHaveBeenNthCalledWith(2, "legacy file response") + }) + + it("should detect legacy format and use backward compatibility path", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("legacy content")) + + await readFileTool.execute({ files: [{ path: "legacy.ts" }] } as any, mockTask as any, callbacks) + + // The legacy path emits "File: " entries — proof the backward-compat branch ran. + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File: legacy.ts")) + }) + + it("should return error when legacy files array is empty", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + await readFileTool.execute({ files: [] } as any, mockTask as any, callbacks) + + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Error:")) + }) + + it("should handle multiple files in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file1 content")) + mockedFsReadFile.mockResolvedValueOnce(Buffer.from("file2 content")) + + // Override readWithSlice to return content that reflects the actual file data + mockedReadWithSlice + .mockReturnValueOnce({ + content: "1 | file1 content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + .mockReturnValueOnce({ + content: "1 | file2 content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { files: [{ path: "file1.ts" }, { path: "file2.ts" }] } as any, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file1 content")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("file2 content")) + }) + + it("should block rooignore-protected files in legacy format", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ files: [{ path: "secret.env" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("rooignore_error", "secret.env") + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("blocked by the .rooignore")) + // Consistent with the native path: a blocked file fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should deny legacy file read when user clicks no", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) + + await readFileTool.execute({ files: [{ path: "protected.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.didRejectTool).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Denied by user")) + }) + + it("should handle directory path in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) + + await readFileTool.execute({ files: [{ path: "src/utils" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("it is a directory")) + // Consistent with the native path: a failed read fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle line ranges in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + // fs.readFile with "utf8" encoding returns a string, not a Buffer + mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) + + await readFileTool.execute( + { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("2 | line2")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("4 | line4")) + }) + + it("should handle binary image files in legacy format with image support", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: true, + sizeInMB: 0.5, + }) + mockedProcessImageFile.mockResolvedValue({ + dataUrl: "data:image/png;base64,abc123", + buffer: Buffer.from("test"), + sizeInKB: 512, + sizeInMB: 0.5, + notice: "Image processed successfully", + }) + + await readFileTool.execute({ files: [{ path: "image.png" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Image file - content processed"), + ) + }) + + it("should handle binary image validation failure in legacy format", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + reason: "size_limit", + notice: "Image too large", + }) + + await readFileTool.execute({ files: [{ path: "large.png" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Image too large")) + }) + + it("should handle unsupported binary files in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(false) + + await readFileTool.execute({ files: [{ path: "program.exe" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("Cannot read binary file")) + }) + + it("should handle file read errors in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + await readFileTool.execute({ files: [{ path: "missing.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("Error reading file")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("ENOENT")) + // Consistent with the native path: a failed read fails the tool turn. + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should handle user feedback on approval in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "yesButtonClicked", + text: "Read carefully", + images: undefined, + }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + await readFileTool.execute({ files: [{ path: "test.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Read carefully", undefined) + }) + + it("should handle user feedback on denial in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ + response: "noButtonClicked", + text: "Not allowed", + images: undefined, + }) + + await readFileTool.execute({ files: [{ path: "secret.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "Not allowed", undefined) + }) + + it("should handle truncation in legacy format when no line ranges", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 100, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 100]], + }) + + await readFileTool.execute({ files: [{ path: "large.ts" }] } as any, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("File truncated")) + }) + + it("should track file context in legacy format", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + + await readFileTool.execute({ files: [{ path: "tracked.ts" }] } as any, mockTask as any, callbacks) + + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("tracked.ts", "read_tool") + }) + }) + + describe("handlePartial", () => { + it.each([{}, { path: "" }, undefined])( + "renders streamed native arguments before their path arrives: %j", + async (nativeArgs) => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + const streamedBlock = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs, + partial: true, + } + + // Streaming JSON is incomplete even though finalized native arguments require a path. + await Reflect.apply(readFileTool.handlePartial, readFileTool, [mockTask, streamedBlock]) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), + true, + ) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + }, + ) + + it("renders incomplete legacy arguments without a file entry or outside-workspace warning", async () => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + const block: ToolUse<"read_file"> = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs: { files: [], _legacyFormat: true }, + partial: true, + } + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.handlePartial(mockTask as unknown as Task, block) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: "", isOutsideWorkspace: false }), + true, + ) + expect(mockedFsReadFile).not.toHaveBeenCalled() + expect(mockedFsStat).not.toHaveBeenCalled() + expect(mockedExtractTextFromFile).not.toHaveBeenCalled() + }) + + it.each(["src/app.ts", "src/файл.ts", "src/e\u0301-📚.ts"])( + "should preserve partial display for %s", + async (filePath) => { + const mockTask = createMockTask() + mockTask.cwd = path.resolve(mockTask.cwd) + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block: ToolUse<"read_file"> = { + type: "tool_use", + name: "read_file", + params: {}, + nativeArgs: { path: filePath }, + partial: true, + } + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.handlePartial(mockTask as unknown as Task, block) + + expect(mockTask.ask).toHaveBeenCalledExactlyOnceWith( + "tool", + JSON.stringify({ tool: "readFile", path: filePath, isOutsideWorkspace: true }), + true, + ) + }, + ) + + it("should handle partial display for legacy format", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { files: [{ path: "legacy.ts" }] }, + partial: false, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalledWith("tool", expect.stringContaining("legacy.ts"), false) + }) + + it("should handle partial display with empty path", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: { path: "" }, + partial: true, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it("should handle partial display with no nativeArgs", async () => { + const mockTask = createMockTask() + mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) + + const block = { + nativeArgs: undefined, + partial: true, + } + + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalled() + }) + + it.each(["Cancelled", "superseded"])("should gracefully handle %s ask rejection in partial", async (reason) => { + const mockTask = createMockTask() + mockTask.ask.mockRejectedValue(new Error(reason)) + + const block = { + nativeArgs: { path: "test.ts" }, + partial: true, + } + + // Should not throw + await readFileTool.handlePartial(mockTask as any, block as any) + + expect(mockTask.ask).toHaveBeenCalled() + }) + }) + + describe("processTextFile indentation mode edge cases", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should use offset as fallback when anchor_line is not provided in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "5 | line 5 content", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[5, 5]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + offset: 5, + } as any, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ anchorLine: 5 }), + ) + }) + + it("should default anchorLine to 1 when neither anchor_line nor offset in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | first line", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + } as any, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ anchorLine: 1 }), + ) + }) + + it("should show truncation notice in indentation mode", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "1 | truncated block", + returnedLines: 50, + totalLines: 200, + wasTruncated: true, + includedRanges: [[1, 50]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 1 }, + }, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("File content truncated")) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("To read more")) + }) + + it("should include range information in indentation mode when not truncated", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "5 | function foo() { ... }", + returnedLines: 10, + totalLines: 100, + wasTruncated: false, + includedRanges: [[5, 14]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 5 }, + }, + mockTask as any, + callbacks, + ) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Included ranges: 5-14")) + }) + + it("should pass all indentation options to readWithIndentation", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { + anchor_line: 10, + max_levels: 2, + include_siblings: true, + include_header: false, + max_lines: 200, + }, + limit: 500, + }, + mockTask as any, + callbacks, + ) + + expect(mockedReadWithIndentation).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + anchorLine: 10, + maxLevels: 2, + includeSiblings: true, + includeHeader: false, + limit: 500, + maxLines: 200, + }), + ) + }) + }) + + describe("slice mode edge cases", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should convert 1-based offset to 0-based for readWithSlice", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | line4", + returnedLines: 1, + totalLines: 10, + wasTruncated: false, + includedRanges: [[4, 4]], + }) + + await readFileTool.execute( + { path: "test.ts", mode: "slice", offset: 4, limit: 1 }, + mockTask as any, + callbacks, + ) + + // offset=4 (1-based) should become 3 (0-based) passed to readWithSlice + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 3, 1) + }) + + it("should use default offset of 1 when not specified", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + // Default offset=1 -> 0-based = 0 + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), 0, expect.any(Number)) + }) + + it("should use default limit when not specified", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | content", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + // Should use DEFAULT_LINE_LIMIT (which is typically 2000) + expect(mockedReadWithSlice).toHaveBeenCalledWith(expect.any(String), expect.any(Number), expect.any(Number)) + }) + + it("should show correct line range in truncation notice", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("lots of content")) + mockedReadWithSlice.mockReturnValue({ + content: "5 | partial", + returnedLines: 100, + totalLines: 500, + wasTruncated: true, + includedRanges: [[5, 104]], + }) + + await readFileTool.execute({ path: "test.ts", offset: 5, limit: 100 }, mockTask as any, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Showing lines 5-104")) + }) + }) + + describe("getLineSnippet and getStartLine", () => { + beforeEach(() => { + mockedIsBinaryFile.mockResolvedValue(false) + }) + + it("should show indentation mode snippet in approval message", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + indentation: { anchor_line: 42 }, + }, + mockTask as any, + callbacks, + ) + + // The approval message should contain the indentation mode info + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("indentation mode at line 42") + }) + + it("should show line range in approval when offset > 1", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts", offset: 10, limit: 50 }, mockTask as any, callbacks) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("lines 10-59") + expect(message.startLine).toBe(10) + }) + + it("should show up to N lines in approval when offset is default", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithSlice.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("up to") + expect(message.reason).toContain("lines") + expect(message.startLine).toBeUndefined() + }) + + it("should show indentation mode snippet with offset fallback", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockResolvedValue(Buffer.from("content")) + mockedReadWithIndentation.mockReturnValue({ + content: "result", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + await readFileTool.execute( + { + path: "test.ts", + mode: "indentation", + offset: 7, + } as any, + mockTask as any, + callbacks, + ) + + const askCall = mockTask.ask.mock.calls[0] + const message = JSON.parse(askCall[1]) + expect(message.reason).toContain("indentation mode at line 7") + }) + }) + + describe("provider state handling", () => { + it("should use default image limits when state is null", async () => { + const mockTask = createMockTask({ supportsImages: true }) + const callbacks = createMockCallbacks() + mockTask.providerRef.deref.mockReturnValue(null) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + notice: "Default image limits applied", + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "image.png" }, mockTask as unknown as Task, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + true, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, + expect.any(Object), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: image.png\nNote: Default image limits applied" }, + ]) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + + it("uses safe image defaults when provider config and model capability are unset", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockTask.api.getModel.mockReturnValue({ info: {} }) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({}), + }) + mockedIsBinaryFile.mockResolvedValue(true) + mockedIsSupportedImageFormat.mockReturnValue(true) + mockedValidateImageForProcessing.mockResolvedValue({ + isValid: false, + notice: "Model lacks image capability", + }) + + // This reader-boundary double omits Task fields unrelated to file reading. + await readFileTool.execute({ path: "image.png" }, mockTask as unknown as Task, callbacks) + + expect(mockedValidateImageForProcessing).toHaveBeenCalledExactlyOnceWith( + path.resolve(mockTask.cwd, "image.png"), + false, + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + 0, + expect.any(Object), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledExactlyOnceWith([ + { type: "text", text: "File: image.png\nNote: Model lacks image capability" }, + ]) + expect(mockedProcessImageFile).not.toHaveBeenCalled() + }) + }) + + describe("error handling edge cases", () => { + it("returns structured range errors and marks the tool turn as failed", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + mockedReadWithSlice.mockReturnValue({ + content: "Error: offset 99 is beyond file end (2 lines)", + returnedLines: 0, + totalLines: 2, + wasTruncated: false, + includedRanges: [], + }) + // Task lifecycle fields are irrelevant to this reader-boundary double. + await readFileTool.execute({ path: "test.ts", offset: 100 }, mockTask as unknown as Task, callbacks) + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith([ + { type: "text", text: "File: test.ts\nError: offset 99 is beyond file end (2 lines)" }, + ]) + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + }) + + it("should handle unknown error types (non-Error)", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockRejectedValue("string error") + + await readFileTool.execute({ path: "test.ts" }, mockTask as any, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(textResultContaining("Error")) + }) + + it("should set didToolFailInCurrentTurn on rooignore block", async () => { + const mockTask = createMockTask({ rooIgnoreAllowed: false }) + const callbacks = createMockCallbacks() + + await readFileTool.execute({ path: "blocked.ts" }, mockTask as any, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + + it("should set didToolFailInCurrentTurn on directory read", async () => { + const mockTask = createMockTask() + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ isDirectory: () => true } as any) + + await readFileTool.execute({ path: "src/" }, mockTask as any, callbacks) + + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + }) + }) +}) diff --git a/src/core/tools/file-reading/readFileCancellation.ts b/src/core/tools/file-reading/readFileCancellation.ts new file mode 100644 index 0000000000..deaf8ee045 --- /dev/null +++ b/src/core/tools/file-reading/readFileCancellation.ts @@ -0,0 +1,12 @@ +import type { Task } from "../../task/Task" +import type { ReadEntryOptions } from "./types" + +/** Cancellation belongs to the task, not to the requested response format. */ +export function isReadFileCancelled(task: Task, _options: ReadEntryOptions): boolean { + return task.abort || task.abandoned +} + +/** For metadata/descriptor boundaries; the reader catches this using the task flags. */ +export function throwIfReadFileCancelled(task: Task, options: ReadEntryOptions): void { + if (isReadFileCancelled(task, options)) throw new Error("File read cancelled") +} diff --git a/src/core/tools/file-reading/readFileConstants.ts b/src/core/tools/file-reading/readFileConstants.ts new file mode 100644 index 0000000000..f3067c671c --- /dev/null +++ b/src/core/tools/file-reading/readFileConstants.ts @@ -0,0 +1,8 @@ +/** Default maximum lines returned by a modern single-file read. */ +export const DEFAULT_LINE_LIMIT = 2000 + +/** Maximum characters displayed per source line. */ +export const MAX_LINE_LENGTH = 2000 + +/** Default indentation levels above the anchor (0 means unlimited). */ +export const DEFAULT_MAX_LEVELS = 0 diff --git a/src/core/tools/file-reading/readFileValidation.ts b/src/core/tools/file-reading/readFileValidation.ts new file mode 100644 index 0000000000..fd9b0f33db --- /dev/null +++ b/src/core/tools/file-reading/readFileValidation.ts @@ -0,0 +1,49 @@ +/** Numeric constraints shared by runtime validation and the native tool schema. */ +export const READ_FILE_NUMERIC_CONSTRAINTS = { + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 }, + anchor_line: { type: "integer", minimum: 1 }, + max_levels: { type: "integer", minimum: 0 }, + max_lines: { type: "integer", minimum: 1 }, +} as const + +export function validateReadFileNumber( + name: keyof typeof READ_FILE_NUMERIC_CONSTRAINTS, + value: unknown, + minimum: number = READ_FILE_NUMERIC_CONSTRAINTS[name].minimum, + allowUnset = true, +): string | undefined { + // Strict provider schemas use null for omitted optional values. + if (allowUnset && (value === undefined || value === null)) return undefined + if (typeof value === "number" && Number.isInteger(value) && value >= minimum) return undefined + if (minimum === 1 && (name === "offset" || name === "anchor_line")) { + return `${name} must be a 1-indexed line number (got ${String(value)}). Line numbers start at 1 and must be positive integers.` + } + return `${name} must be a ${minimum === 0 ? "nonnegative" : "positive"} integer (got ${String(value)}).` +} + +export function validateReadFileNumbers(params: { + offset?: unknown + limit?: unknown + indentation?: { anchor_line?: unknown; max_levels?: unknown; max_lines?: unknown } | null +}): string | undefined { + return ( + validateReadFileNumber("offset", params.offset, READ_FILE_NUMERIC_CONSTRAINTS.offset.minimum) ?? + validateReadFileNumber("limit", params.limit, READ_FILE_NUMERIC_CONSTRAINTS.limit.minimum) ?? + validateReadFileNumber( + "anchor_line", + params.indentation?.anchor_line, + READ_FILE_NUMERIC_CONSTRAINTS.anchor_line.minimum, + ) ?? + validateReadFileNumber( + "max_levels", + params.indentation?.max_levels, + READ_FILE_NUMERIC_CONSTRAINTS.max_levels.minimum, + ) ?? + validateReadFileNumber( + "max_lines", + params.indentation?.max_lines, + READ_FILE_NUMERIC_CONSTRAINTS.max_lines.minimum, + ) + ) +} diff --git a/src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts b/src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts new file mode 100644 index 0000000000..6fc5cfc524 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileBinaryReader.ts @@ -0,0 +1,26 @@ +import { getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { isSupportedImageFormat } from "../../helpers/imageHelpers" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileBinaryReader extends ReadFileStrategy { + canRead({ binary, extension, options }: ReadFileContext): boolean { + return ( + binary && + options.textOnly !== true && + !isSupportedImageFormat(extension) && + !getSupportedBinaryFormats().includes(extension) + ) + } + + async read({ params, extension }: ReadFileContext): Promise { + const relPath = params.path + const format = extension.slice(1) || "bin" + return { + path: relPath, + status: "approved", + notice: `Binary file format: ${format}`, + nativeContent: `File: ${relPath}\nBinary file (${format}) - content not displayed`, + } + } +} diff --git a/src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts b/src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts new file mode 100644 index 0000000000..3de132c42b --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileDocumentReader.ts @@ -0,0 +1,53 @@ +import { extractRawTextFromFile, getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { isSupportedImageFormat } from "../../helpers/imageHelpers" +import { ReadFileErrorReporter } from "../ReadFileErrorReporter" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { isReadFileCancelled } from "../readFileCancellation" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileDocumentReader extends ReadFileStrategy { + constructor( + private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter(), + private readonly textProcessor: ReadFileTextProcessor = new ReadFileTextProcessor(), + ) { + super() + } + + canRead({ binary, extension, options }: ReadFileContext): boolean { + return ( + binary && + options.textOnly !== true && + !isSupportedImageFormat(extension) && + getSupportedBinaryFormats().includes(extension) + ) + } + + async read({ params, task, fullPath, file, options }: ReadFileContext): Promise { + const relPath = params.path + try { + const bytes = file ? await file.readFile() : undefined + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + const content = file + ? await extractRawTextFromFile(params.path, bytes) + : await extractRawTextFromFile(fullPath) + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + const text = this.textProcessor.process(content, params) + if (text.startsWith("Error:")) { + return { path: relPath, status: "error", error: text, nativeContent: `File: ${relPath}\n${text}` } + } + await task.fileContextTracker.trackFileContext(relPath, "read_tool") + return { + path: relPath, + status: "approved", + nativeContent: `File: ${relPath}\n${content.length === 0 ? "Note: File is empty" : text}`, + } + } catch (error) { + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + return this.errorReporter.report(relPath, task, error, { + prefix: "Error extracting text: ", + action: "Error extracting text from", + }) + } + } +} diff --git a/src/core/tools/file-reading/strategies/ReadFileImageReader.ts b/src/core/tools/file-reading/strategies/ReadFileImageReader.ts new file mode 100644 index 0000000000..8085ffbfb6 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileImageReader.ts @@ -0,0 +1,84 @@ +import path from "path" +import { + DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + IMAGE_MIME_TYPES, + isSupportedImageFormat, + validateImageForProcessing, + processImageFile, + ImageMemoryTracker, +} from "../../helpers/imageHelpers" +import { getImageMimeType, isSupportedImageMimeType } from "../../../../utils/imageMime" +import { ReadFileErrorReporter } from "../ReadFileErrorReporter" +import { isReadFileCancelled, throwIfReadFileCancelled } from "../readFileCancellation" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileImageReader extends ReadFileStrategy { + constructor(private readonly errorReporter: ReadFileErrorReporter = new ReadFileErrorReporter()) { + super() + } + + canRead({ binary, extension, options }: ReadFileContext): boolean { + return binary && options.textOnly !== true && isSupportedImageFormat(extension) + } + + async read({ params, task, fullPath, file, options }: ReadFileContext): Promise { + const relPath = params.path + try { + const state = await task.providerRef.deref()?.getState() + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + const tracker = new ImageMemoryTracker() + const supportsImages = task.api.getModel().info.supportsImages ?? false + const targetMimeType = IMAGE_MIME_TYPES[path.extname(fullPath).toLowerCase()] + if (supportsImages && !isSupportedImageMimeType(targetMimeType)) + return this.unsupported(relPath, targetMimeType) + const validation = await validateImageForProcessing( + fullPath, + supportsImages, + state?.maxImageFileSize ?? DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + state?.maxTotalImageSize ?? DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + tracker.getTotalMemoryUsed(), + file, + ) + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + if (!validation.isValid) { + await task.fileContextTracker.trackFileContext(relPath, "read_tool") + return { + path: relPath, + status: "approved", + nativeContent: `File: ${relPath}\nNote: ${validation.notice}`, + } + } + // Label the verified target, not a symlink alias with a different extension. + const image = await processImageFile(fullPath, file, () => throwIfReadFileCancelled(task, options)) + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + const mimeType = getImageMimeType(image.dataUrl) + if (!isSupportedImageMimeType(mimeType)) return this.unsupported(relPath, mimeType) + tracker.addMemoryUsage(image.sizeInMB) + await task.fileContextTracker.trackFileContext(relPath, "read_tool") + return { + path: relPath, + status: "approved", + nativeContent: `File: ${relPath}\nNote: ${image.notice}`, + imageDataUrl: image.dataUrl, + } + } catch (error) { + if (isReadFileCancelled(task, options)) return { path: relPath, status: "cancelled" } + return this.errorReporter.report(relPath, task, error, { + prefix: "Error reading image file: ", + action: "Error reading image file", + }) + } + } + + private unsupported(relPath: string, mimeType: string | undefined): FileResult { + const notice = `Unsupported image MIME type: ${mimeType ?? "unknown"}. Use JPEG, PNG, GIF, or WebP.` + return { + path: relPath, + status: "unsupported", + error: notice, + nativeContent: `File: ${relPath}\nNote: ${notice}`, + } + } +} diff --git a/src/core/tools/file-reading/strategies/ReadFileStrategy.ts b/src/core/tools/file-reading/strategies/ReadFileStrategy.ts new file mode 100644 index 0000000000..9f2c39a587 --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileStrategy.ts @@ -0,0 +1,6 @@ +import type { FileResult, ReadFileContext } from "../types" + +export abstract class ReadFileStrategy { + abstract canRead(context: ReadFileContext): boolean + abstract read(context: ReadFileContext): Promise +} diff --git a/src/core/tools/file-reading/strategies/ReadFileTextReader.ts b/src/core/tools/file-reading/strategies/ReadFileTextReader.ts new file mode 100644 index 0000000000..bcdf528cff --- /dev/null +++ b/src/core/tools/file-reading/strategies/ReadFileTextReader.ts @@ -0,0 +1,51 @@ +import * as fs from "fs/promises" +import { extractRawTextFromFile, getSupportedBinaryFormats } from "../../../../integrations/misc/extract-text" +import { ReadFileTextProcessor } from "../ReadFileTextProcessor" +import { isReadFileCancelled } from "../readFileCancellation" +import type { FileResult, ReadFileContext } from "../types" +import { ReadFileStrategy } from "./ReadFileStrategy" + +export class ReadFileTextReader extends ReadFileStrategy { + constructor(private readonly textProcessor: ReadFileTextProcessor = new ReadFileTextProcessor()) { + super() + } + + canRead({ binary, options }: ReadFileContext): boolean { + return options.textOnly === true || !binary + } + + async read({ params, task, fullPath, extension, binary, options, file }: ReadFileContext): Promise { + const extract = options.textOnly === true && getSupportedBinaryFormats().includes(extension) + if (options.textOnly && binary && !extract) { + return { + path: params.path, + status: "unsupported", + error: "Unsupported batch binary format; use read_file.", + } + } + // Buffer decoding deliberately tolerates non-UTF8 bytes, as before. + let content: string + if (extract) { + const bytes = file ? await file.readFile() : undefined + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + content = file ? await extractRawTextFromFile(params.path, bytes) : await extractRawTextFromFile(fullPath) + } else { + const bytes = await (file ? file.readFile() : fs.readFile(fullPath)) + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + content = bytes.toString("utf-8") + } + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + const text = this.textProcessor.process(content, params) + if (text.startsWith("Error:")) { + return { path: params.path, status: "error", error: text, nativeContent: `File: ${params.path}\n${text}` } + } + await task.fileContextTracker.trackFileContext(params.path, "read_tool") + if (isReadFileCancelled(task, options)) return { path: params.path, status: "cancelled" } + return { + path: params.path, + status: "approved", + nativeContent: `File: ${params.path}\n${text}`, + longLinesTruncated: options.textOnly ? this.textProcessor.hasClippedLines(content, text) : undefined, + } + } +} diff --git a/src/core/tools/file-reading/types.ts b/src/core/tools/file-reading/types.ts new file mode 100644 index 0000000000..d7d35de209 --- /dev/null +++ b/src/core/tools/file-reading/types.ts @@ -0,0 +1,39 @@ +import type { ReadFileParams } from "@roo-code/types" +import type { Task } from "../../task/Task" +import type { FileHandle } from "fs/promises" + +export interface FileResult { + path: string + status: "approved" | "denied" | "blocked" | "error" | "pending" | "cancelled" | "unsupported" + /** User decision is independent of the subsequent content-read outcome. */ + approvalStatus?: "approved" | "denied" + content?: string + error?: string + notice?: string + nativeContent?: string + imageDataUrl?: string + feedbackText?: string + feedbackImages?: string[] + longLinesTruncated?: boolean + entry?: ReadFileParams +} + +export interface ReadEntryOptions { + textOnly?: boolean +} + +export interface ReadFileContext { + params: ReadFileParams + task: Task + fullPath: string + extension: string + binary: boolean + options: ReadEntryOptions + /** Present at the public reader boundary; strategies must not reopen a pathname when supplied. */ + file?: FileHandle +} + +export interface ReadErrorContext { + prefix?: string + action?: string +} diff --git a/src/core/tools/helpers/imageHelpers.ts b/src/core/tools/helpers/imageHelpers.ts index a1adb078e6..8500578003 100644 --- a/src/core/tools/helpers/imageHelpers.ts +++ b/src/core/tools/helpers/imageHelpers.ts @@ -1,5 +1,6 @@ import path from "path" import * as fs from "fs/promises" +import type { FileHandle } from "fs/promises" import { t } from "../../../i18n" import prettyBytes from "pretty-bytes" @@ -72,8 +73,13 @@ export interface ImageProcessingResult { /** * 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) +export async function readImageAsDataUrlWithBuffer( + filePath: string, + file?: FileHandle, + checkCancelled?: () => void, +): Promise<{ dataUrl: string; buffer: Buffer }> { + const fileBuffer = await (file ? file.readFile() : fs.readFile(filePath)) + checkCancelled?.() const base64 = fileBuffer.toString("base64") const ext = path.extname(filePath).toLowerCase() @@ -99,6 +105,7 @@ export async function validateImageForProcessing( maxImageFileSize: number, maxTotalImageSize: number, currentTotalMemoryUsed: number, + file?: FileHandle, ): Promise { // Check if model supports images if (!supportsImages) { @@ -109,7 +116,7 @@ export async function validateImageForProcessing( } } - const imageStats = await fs.stat(fullPath) + const imageStats = await (file ? file.stat() : fs.stat(fullPath)) const imageSizeInMB = imageStats.size / (1024 * 1024) // Check individual file size limit @@ -147,9 +154,15 @@ export async function validateImageForProcessing( /** * 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) +export async function processImageFile( + fullPath: string, + file?: FileHandle, + checkCancelled?: () => void, +): Promise { + const imageStats = await (file ? file.stat() : fs.stat(fullPath)) + checkCancelled?.() + const { dataUrl, buffer } = await readImageAsDataUrlWithBuffer(fullPath, file, checkCancelled) + checkCancelled?.() const imageSizeInKB = Math.round(imageStats.size / 1024) const imageSizeInMB = imageStats.size / (1024 * 1024) const noticeText = t("tools:readFile.imageWithSize", { size: imageSizeInKB }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index e4b15aa27e..34170814d8 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -894,11 +894,6 @@ "count": 1 } }, - "core/tools/ReadFileTool.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "core/tools/ToolRepetitionDetector.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -974,11 +969,6 @@ "count": 26 } }, - "core/tools/__tests__/readFileTool.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 96 - } - }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -1014,6 +1004,11 @@ "count": 5 } }, + "core/tools/file-reading/__tests__/readFileTool.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 94 + } + }, "core/tools/helpers/toolResultFormatting.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/integrations/misc/__tests__/extract-raw-text.spec.ts b/src/integrations/misc/__tests__/extract-raw-text.spec.ts new file mode 100644 index 0000000000..1c1b01b1ec --- /dev/null +++ b/src/integrations/misc/__tests__/extract-raw-text.spec.ts @@ -0,0 +1,57 @@ +import fs from "fs/promises" +import { extractRawTextFromFile, extractTextFromFile } from "../extract-text" + +const { parsePdf, parseDocx } = vi.hoisted(() => ({ + parsePdf: vi.fn<(buffer: Buffer) => Promise<{ text: string }>>(), + parseDocx: vi.fn(), +})) + +vi.mock("pdf-parse/lib/pdf-parse", () => ({ default: parsePdf })) +vi.mock("mammoth", () => ({ default: { extractRawText: parseDocx } })) +vi.mock("fs/promises", () => ({ default: { access: vi.fn(), readFile: vi.fn() } })) + +describe("raw document extraction compatibility", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from("document bytes")) + }) + + it("returns raw PDF text while preserving the existing formatted PDF API", async () => { + parsePdf.mockResolvedValue({ text: "12 | genuine text\n indented text" }) + + expect(await extractRawTextFromFile("document.pdf")).toBe("12 | genuine text\n indented text") + expect(await extractTextFromFile("document.pdf")).toBe("1 | 12 | genuine text\n2 | indented text\n") + }) + + it("preserves raw DOCX whitespace and trailing newlines without changing formatted DOCX output", async () => { + parseDocx.mockResolvedValue({ value: "12 | genuine text\n indented text\n\n", messages: [] }) + + expect(await extractRawTextFromFile("document.docx")).toBe("12 | genuine text\n indented text\n\n") + expect(await extractTextFromFile("document.docx")).toBe("1 | 12 | genuine text\n2 | indented text\n3 | \n") + }) + + it("does not replace a document parser failure with a missing-file or formatting error", async () => { + const failure = new Error("Corrupt DOCX archive") + parseDocx.mockRejectedValueOnce(failure) + + await expect(extractRawTextFromFile("document.docx")).rejects.toBe(failure) + }) + + it("reports a missing document before invoking its parser", async () => { + vi.mocked(fs.access).mockRejectedValueOnce(new Error("ENOENT")) + + await expect(extractRawTextFromFile("missing.pdf")).rejects.toThrow("File not found: missing.pdf") + expect(parsePdf).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("rejects unsupported document formats without trying to interpret their bytes", async () => { + await expect(extractRawTextFromFile("archive.bin")).rejects.toThrow( + "Cannot extract document text for file type: .bin", + ) + expect(fs.readFile).not.toHaveBeenCalled() + expect(parsePdf).not.toHaveBeenCalled() + expect(parseDocx).not.toHaveBeenCalled() + }) +}) diff --git a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts index 90d5007f16..f678f74673 100644 --- a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts @@ -134,7 +134,8 @@ describe("Unicode clipping through the existing readers", () => { wasTruncated: false, }) expect(readWithIndentation(longLine, { anchorLine: 0 })).toEqual({ - content: "Error: anchor_line 0 is out of range (1-1)", + content: + "Error: anchor_line must be a 1-indexed line number (got 0). Line numbers start at 1 and must be positive integers.", includedRanges: [], totalLines: 1, returnedLines: 0, diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index d46cb54277..cfaab4886b 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -255,6 +255,38 @@ describe("formatWithLineNumbers", () => { // ─── readWithSlice Tests ────────────────────────────────────────────────────── describe("readWithSlice", () => { + it("returns an error result for a directly supplied zero limit", () => { + const result = readWithSlice(SIMPLE_CODE, 0, 0) + + expect(result).toMatchObject({ + content: expect.stringContaining("Error: limit must be a positive integer"), + includedRanges: [], + totalLines: 7, + returnedLines: 0, + wasTruncated: false, + }) + }) + + it.each([-1, 0.5, NaN, Infinity])("rejects a malformed zero-based offset %s without clamping", (offset) => { + const result = readWithSlice(SIMPLE_CODE, offset, 1) + + expect(result.content).toContain("Error: offset must be a nonnegative integer") + expect(result.includedRanges).toEqual([]) + expect(result.returnedLines).toBe(0) + }) + + it("treats null optional slice numbers as defaults for untyped callers", () => { + expect(Reflect.apply(readWithSlice, undefined, [SIMPLE_CODE, null, null])).toEqual(readWithSlice(SIMPLE_CODE)) + }) + + it.each([-1, 1.5, NaN, Infinity])("returns an error result for invalid limit %s", (limit) => { + expect(readWithSlice(SIMPLE_CODE, 0, limit)).toMatchObject({ + content: expect.stringContaining("Error: limit"), + includedRanges: [], + returnedLines: 0, + }) + }) + it("should read from beginning with default offset", () => { const result = readWithSlice(SIMPLE_CODE, 0, 10) @@ -286,17 +318,69 @@ describe("readWithSlice", () => { expect(result.content).toContain("Error") }) - it("should handle negative offset", () => { + it("should return an error for negative offset", () => { const result = readWithSlice(SIMPLE_CODE, -5, 10) - // Should normalize to 0 - expect(result.includedRanges[0][0]).toBe(1) + expect(result.content).toContain("Error: offset must be a nonnegative integer") + expect(result.includedRanges).toEqual([]) }) }) // ─── readWithIndentation Tests ──────────────────────────────────────────────── describe("readWithIndentation", () => { + it("returns an error result instead of throwing when a direct caller omits the required anchor", () => { + const options = { anchorLine: 1 } + Object.defineProperty(options, "anchorLine", { value: undefined }) + + expect(readWithIndentation(SIMPLE_CODE, options)).toMatchObject({ + content: expect.stringContaining("Error: anchor_line"), + includedRanges: [], + returnedLines: 0, + }) + }) + + it("returns an error for a zero hard cap even when the expansion limit is valid", () => { + const result = readWithIndentation(SIMPLE_CODE, { anchorLine: 3, limit: 10, maxLines: 0 }) + + expect(result).toMatchObject({ + content: expect.stringContaining("Error: max_lines must be a positive integer"), + includedRanges: [], + totalLines: 7, + returnedLines: 0, + wasTruncated: false, + }) + }) + + it("treats null optional indentation numbers as defaults", () => { + const options = { anchorLine: 3, includeHeader: false } + Object.defineProperties(options, { + maxLevels: { value: null }, + limit: { value: null }, + maxLines: { value: null }, + }) + + expect(readWithIndentation(SIMPLE_CODE, options)).toEqual( + readWithIndentation(SIMPLE_CODE, { anchorLine: 3, includeHeader: false, maxLevels: 0 }), + ) + }) + + it.each( + ["anchorLine", "maxLevels", "limit", "maxLines"].flatMap((name) => + [-1, 1.5, NaN, Infinity, "1"].map((value) => ({ name, value })), + ), + )("returns an error result for malformed $name=$value without throwing", ({ name, value }) => { + const options = { anchorLine: 3 } + Object.defineProperty(options, name, { value }) + + expect(readWithIndentation(SIMPLE_CODE, options)).toMatchObject({ + content: expect.stringContaining("Error:"), + includedRanges: [], + returnedLines: 0, + wasTruncated: false, + }) + }) + describe("basic block extraction", () => { it("should extract content around the anchor line", () => { const result = readWithIndentation(PYTHON_CODE, { diff --git a/src/integrations/misc/extract-text-from-xlsx.ts b/src/integrations/misc/extract-text-from-xlsx.ts index 82e1d4db9a..870dba6b22 100644 --- a/src/integrations/misc/extract-text-from-xlsx.ts +++ b/src/integrations/misc/extract-text-from-xlsx.ts @@ -1,4 +1,5 @@ import ExcelJS from "exceljs" +import { Readable } from "stream" const ROW_LIMIT = 50000 @@ -40,13 +41,17 @@ function formatCellValue(cell: ExcelJS.Cell): string { return value.toString() } -export async function extractTextFromXLSX(filePathOrWorkbook: string | ExcelJS.Workbook): Promise { +export async function extractTextFromXLSX( + filePathOrWorkbook: string | ExcelJS.Workbook, + source?: Buffer, +): Promise { let workbook: ExcelJS.Workbook let excelText = "" if (typeof filePathOrWorkbook === "string") { workbook = new ExcelJS.Workbook() - await workbook.xlsx.readFile(filePathOrWorkbook) + if (source) await workbook.xlsx.read(Readable.from([source])) + else await workbook.xlsx.readFile(filePathOrWorkbook) } else { workbook = filePathOrWorkbook } diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index 1ff98d273b..b37ff33590 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -8,29 +8,31 @@ import { extractTextFromXLSX } from "./extract-text-from-xlsx" import { readWithSlice } from "./indentation-reader" import { DEFAULT_LINE_LIMIT } from "../../core/prompts/tools/native-tools/read_file" -async function extractTextFromPDF(filePath: string): Promise { - const dataBuffer = await fs.readFile(filePath) +async function extractTextFromPDF(filePath: string, source?: Buffer, lineNumbers = true): Promise { + const dataBuffer = source ?? (await fs.readFile(filePath)) const data = await pdf(dataBuffer) - return addLineNumbers(data.text) + return lineNumbers ? addLineNumbers(data.text) : data.text } -async function extractTextFromDOCX(filePath: string): Promise { - const result = await mammoth.extractRawText({ path: filePath }) - return addLineNumbers(result.value) +async function extractTextFromDOCX(filePath: string, source?: Buffer, lineNumbers = true): Promise { + const result = await mammoth.extractRawText(source ? { buffer: source } : { path: filePath }) + return lineNumbers ? addLineNumbers(result.value) : result.value } -async function extractTextFromIPYNB(filePath: string): Promise { - const data = await fs.readFile(filePath, "utf8") +async function extractTextFromIPYNB(filePath: string, source?: Buffer, lineNumbers = true): Promise { + const data = source ? source.toString("utf8") : await fs.readFile(filePath, "utf8") const notebook = JSON.parse(data) - let extractedText = "" + const cells: string[] = [] for (const cell of notebook.cells) { if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) { - extractedText += cell.source.join("\n") + "\n" + cells.push(cell.source.join("\n")) } } - return addLineNumbers(extractedText) + const content = cells.join("\n") + // Keep the historical formatted API, but do not append a display-only line to raw source. + return lineNumbers ? addLineNumbers(cells.length ? `${content}\n` : "") : content } /** @@ -78,11 +80,14 @@ export interface ExtractTextResult { export async function extractTextFromFileWithMetadata( filePath: string, limit: number = DEFAULT_LINE_LIMIT, + source?: Buffer, ): Promise { - try { - await fs.access(filePath) - } catch (error) { - throw new Error(`File not found: ${filePath}`) + if (!source) { + try { + await fs.access(filePath) + } catch (error) { + throw new Error(`File not found: ${filePath}`) + } } const fileExtension = path.extname(filePath).toLowerCase() @@ -91,7 +96,7 @@ export async function extractTextFromFileWithMetadata( const extractor = SUPPORTED_BINARY_FORMATS[fileExtension as keyof typeof SUPPORTED_BINARY_FORMATS] if (extractor) { // For binary formats, extract and count lines - const content = await extractor(filePath) + const content = await extractor(filePath, source) const lines = content.split("\n") return { content, @@ -102,10 +107,10 @@ export async function extractTextFromFileWithMetadata( } // Handle other files - const isBinary = await isBinaryFile(filePath).catch(() => false) + const isBinary = await isBinaryFile(source ?? filePath).catch(() => false) if (!isBinary) { - const rawContent = await fs.readFile(filePath, "utf8") + const rawContent = source ? source.toString("utf8") : await fs.readFile(filePath, "utf8") const result = readWithSlice(rawContent, 0, limit) return { @@ -128,11 +133,36 @@ export async function extractTextFromFileWithMetadata( * @returns Promise resolving to the extracted text content with line numbers * @throws {Error} If file not found or unsupported binary format */ -export async function extractTextFromFile(filePath: string): Promise { - const result = await extractTextFromFileWithMetadata(filePath) +export async function extractTextFromFile(filePath: string, source?: Buffer): Promise { + const result = await extractTextFromFileWithMetadata(filePath, DEFAULT_LINE_LIMIT, source) return result.content } +/** Extracts supported document content without adding display line numbers. */ +export async function extractRawTextFromFile(filePath: string, source?: Buffer): Promise { + if (!source) { + try { + await fs.access(filePath) + } catch { + throw new Error(`File not found: ${filePath}`) + } + } + + const extension = path.extname(filePath).toLowerCase() + switch (extension) { + case ".pdf": + return extractTextFromPDF(filePath, source, false) + case ".docx": + return extractTextFromDOCX(filePath, source, false) + case ".ipynb": + return extractTextFromIPYNB(filePath, source, false) + case ".xlsx": + return extractTextFromXLSX(filePath, source) + default: + throw new Error(`Cannot extract document text for file type: ${extension}`) + } +} + export function addLineNumbers(content: string, startLine: number = 1): string { // If content is empty, return empty string - empty files should not have line numbers // If content is empty but startLine > 1, return "startLine | " because we know the file is not empty diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index ca3ba1b569..d1f5b153aa 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -17,7 +17,8 @@ import { DEFAULT_LINE_LIMIT, DEFAULT_MAX_LEVELS, MAX_LINE_LENGTH, -} from "../../core/prompts/tools/native-tools/read_file" +} from "../../core/tools/file-reading/readFileConstants" +import { validateReadFileNumber, validateReadFileNumbers } from "../../core/tools/file-reading/readFileValidation" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -290,18 +291,30 @@ function computeIncludedRanges(lines: LineRecord[]): Array<[number, number]> { * @returns The extracted content with metadata */ export function readWithIndentation(content: string, options: IndentationReadOptions): IndentationReadResult { - const { - anchorLine, - maxLevels = DEFAULT_MAX_LEVELS, - includeSiblings = false, - includeHeader = true, - limit = DEFAULT_LINE_LIMIT, - maxLines, - } = options + const { anchorLine, includeSiblings = false, includeHeader = true, maxLines } = options + const maxLevels = options.maxLevels ?? DEFAULT_MAX_LEVELS + const limit = options.limit ?? DEFAULT_LINE_LIMIT const lines = parseLines(content) const totalLines = lines.length + // Unlike the optional read_file anchor, this numerical API requires an anchor. + const validationError = + validateReadFileNumber("anchor_line", anchorLine, undefined, false) ?? + validateReadFileNumbers({ + limit, + indentation: { max_levels: maxLevels, max_lines: maxLines }, + }) + if (validationError) { + return { + content: `Error: ${validationError}`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + // Validate anchor line if (anchorLine < 1 || anchorLine > totalLines) { return { @@ -440,11 +453,24 @@ export function readWithSlice( offset: number = 0, limit: number = DEFAULT_LINE_LIMIT, ): IndentationReadResult { + offset = offset ?? 0 + limit = limit ?? DEFAULT_LINE_LIMIT const lines = parseLines(content) const totalLines = lines.length + // This lower-level slice API uses a zero-based offset, unlike read_file. + const validationError = validateReadFileNumber("offset", offset, 0) ?? validateReadFileNumbers({ limit }) + if (validationError) { + return { + content: `Error: ${validationError}`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + // Validate offset - if (offset < 0) offset = 0 if (offset >= totalLines) { return { content: `Error: offset ${offset} is beyond file end (${totalLines} lines)`,