Skip to content

Commit ec797d4

Browse files
committed
Handle directory read attempts in ReadFileTool
Adds a check in ReadFileTool to detect when a requested path is a directory and returns a helpful error message, instructing users to use the list_files tool instead. Updates tests to mock fs.stat accordingly and adds a test to verify the new error handling for directory paths.
1 parent 6f166b9 commit ec797d4

2 files changed

Lines changed: 84 additions & 14 deletions

File tree

extensions/roopik-roo/src/core/tools/ReadFileTool.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from "path"
2+
import * as fs from "fs/promises"
23
import { isBinaryFile } from "isbinaryfile"
34
import type { FileEntry, LineRange } from "@roo-code/types"
45
import { isNativeProtocol, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
@@ -350,6 +351,20 @@ export class ReadFileTool extends BaseTool<"read_file"> {
350351
const fullPath = path.resolve(task.cwd, relPath)
351352

352353
try {
354+
// Check if the path is a directory before attempting to read it
355+
const stats = await fs.stat(fullPath)
356+
if (stats.isDirectory()) {
357+
const errorMsg = `Cannot read '${relPath}' because it is a directory. To view the contents of a directory, use the list_files tool instead.`
358+
updateFileResult(relPath, {
359+
status: "error",
360+
error: errorMsg,
361+
xmlContent: `<file><path>${relPath}</path><error>Error reading file: ${errorMsg}</error></file>`,
362+
nativeContent: `File: ${relPath}\nError: Error reading file: ${errorMsg}`,
363+
})
364+
await task.say("error", `Error reading file ${relPath}: ${errorMsg}`)
365+
continue
366+
}
367+
353368
const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)])
354369

355370
if (isBinary) {

extensions/roopik-roo/src/core/tools/__tests__/readFileTool.spec.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,13 @@ describe("read_file tool with maxReadFileLine setting", () => {
305305
mockedPathResolve.mockReturnValue(absoluteFilePath)
306306
mockedIsBinaryFile.mockResolvedValue(false)
307307

308+
// Mock fsPromises.stat to return a file (not directory) by default
309+
fsPromises.stat.mockResolvedValue({
310+
isDirectory: () => false,
311+
isFile: () => true,
312+
isSymbolicLink: () => false,
313+
} as any)
314+
308315
mockInputContent = fileContent
309316

310317
// Setup the extractTextFromFile mock implementation with the current mockInputContent
@@ -612,7 +619,12 @@ describe("read_file tool output structure", () => {
612619

613620
// CRITICAL: Reset fsPromises mocks to prevent cross-test contamination
614621
fsPromises.stat.mockClear()
615-
fsPromises.stat.mockResolvedValue({ size: 1024 })
622+
fsPromises.stat.mockResolvedValue({
623+
size: 1024,
624+
isDirectory: () => false,
625+
isFile: () => true,
626+
isSymbolicLink: () => false,
627+
} as any)
616628
fsPromises.readFile.mockClear()
617629

618630
// Use shared mock setup function
@@ -852,7 +864,7 @@ describe("read_file tool output structure", () => {
852864
fsPromises.stat = vi.fn().mockImplementation((filePath) => {
853865
const normalizedFilePath = path.normalize(filePath.toString())
854866
const image = smallImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
855-
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 })
867+
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
856868
})
857869

858870
// Mock path.resolve for each image
@@ -928,7 +940,7 @@ describe("read_file tool output structure", () => {
928940
fsPromises.stat = vi.fn().mockImplementation((filePath) => {
929941
const normalizedFilePath = path.normalize(filePath.toString())
930942
const image = largeImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
931-
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 })
943+
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
932944
})
933945

934946
// Mock path.resolve for each image
@@ -1012,9 +1024,9 @@ describe("read_file tool output structure", () => {
10121024
const normalizedFilePath = path.normalize(filePath.toString())
10131025
const image = exactLimitImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
10141026
if (image) {
1015-
return Promise.resolve({ size: image.sizeKB * 1024 })
1027+
return Promise.resolve({ size: image.sizeKB * 1024, isDirectory: () => false })
10161028
}
1017-
return Promise.resolve({ size: 1024 * 1024 }) // Default 1MB
1029+
return Promise.resolve({ size: 1024 * 1024, isDirectory: () => false }) // Default 1MB
10181030
})
10191031

10201032
// Mock path.resolve
@@ -1085,7 +1097,7 @@ describe("read_file tool output structure", () => {
10851097
const fileName = path.basename(filePath)
10861098
const baseName = path.parse(fileName).name
10871099
const image = mixedImages.find((img) => img.path.includes(baseName))
1088-
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 })
1100+
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
10891101
})
10901102

10911103
// Mock provider state with 5MB individual limit
@@ -1139,9 +1151,9 @@ describe("read_file tool output structure", () => {
11391151
const normalizedFilePath = path.normalize(filePath.toString())
11401152
const file = testImages.find((f) => normalizedFilePath.includes(path.normalize(f.path)))
11411153
if (file) {
1142-
return { size: file.sizeMB * 1024 * 1024 }
1154+
return { size: file.sizeMB * 1024 * 1024, isDirectory: () => false }
11431155
}
1144-
return { size: 1024 * 1024 } // Default 1MB
1156+
return { size: 1024 * 1024, isDirectory: () => false } // Default 1MB
11451157
})
11461158

11471159
const imagePaths = testImages.map((img) => img.path)
@@ -1201,7 +1213,7 @@ describe("read_file tool output structure", () => {
12011213
// Setup - first call with images that use memory
12021214
const firstBatch = [{ path: "test/first.png", sizeKB: 10240 }] // 10MB
12031215

1204-
fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024 })
1216+
fsPromises.stat = vi.fn().mockResolvedValue({ size: 10240 * 1024, isDirectory: () => false })
12051217
mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
12061218

12071219
// Execute first batch
@@ -1254,7 +1266,7 @@ describe("read_file tool output structure", () => {
12541266
mockedCountFileLines.mockClear()
12551267

12561268
// Reset mocks for second batch
1257-
fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 })
1269+
fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false })
12581270
fsPromises.readFile.mockResolvedValue(
12591271
Buffer.from(
12601272
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
@@ -1303,7 +1315,7 @@ describe("read_file tool output structure", () => {
13031315
fsPromises.stat = vi.fn().mockImplementation((filePath) => {
13041316
const normalizedFilePath = path.normalize(filePath.toString())
13051317
const image = manyImages.find((img) => normalizedFilePath.includes(path.normalize(img.path)))
1306-
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024 })
1318+
return Promise.resolve({ size: (image?.sizeKB || 1024) * 1024, isDirectory: () => false })
13071319
})
13081320

13091321
// Mock path.resolve
@@ -1350,7 +1362,7 @@ describe("read_file tool output structure", () => {
13501362
// First invocation - use 15MB of memory
13511363
const firstBatch = [{ path: "test/large1.png", sizeKB: 15360 }] // 15MB
13521364

1353-
fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024 })
1365+
fsPromises.stat = vi.fn().mockResolvedValue({ size: 15360 * 1024, isDirectory: () => false })
13541366
mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
13551367

13561368
// Execute first batch
@@ -1371,7 +1383,7 @@ describe("read_file tool output structure", () => {
13711383
fsPromises.readFile.mockClear()
13721384
mockedPathResolve.mockClear()
13731385

1374-
fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024 })
1386+
fsPromises.stat = vi.fn().mockResolvedValue({ size: 18432 * 1024, isDirectory: () => false })
13751387
fsPromises.readFile.mockResolvedValue(imageBuffer)
13761388
mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`)
13771389

@@ -1429,6 +1441,37 @@ describe("read_file tool output structure", () => {
14291441
`File: ${testFilePath}\nError: Access to ${testFilePath} 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.`,
14301442
)
14311443
})
1444+
1445+
it("should provide helpful error when trying to read a directory", async () => {
1446+
// Setup - mock fsPromises.stat to indicate the path is a directory
1447+
const dirPath = "test/my-directory"
1448+
const absoluteDirPath = "/test/my-directory"
1449+
1450+
mockedPathResolve.mockReturnValue(absoluteDirPath)
1451+
1452+
// Mock fs/promises stat to return directory
1453+
fsPromises.stat.mockResolvedValue({
1454+
isDirectory: () => true,
1455+
isFile: () => false,
1456+
isSymbolicLink: () => false,
1457+
} as any)
1458+
1459+
// Mock isBinaryFile won't be called since we check directory first
1460+
mockedIsBinaryFile.mockResolvedValue(false)
1461+
1462+
// Execute
1463+
const result = await executeReadFileTool({ args: `<file><path>${dirPath}</path></file>` })
1464+
1465+
// Verify - native format for error
1466+
expect(result).toContain(`File: ${dirPath}`)
1467+
expect(result).toContain(`Error: Error reading file: Cannot read '${dirPath}' because it is a directory`)
1468+
expect(result).toContain("use the list_files tool instead")
1469+
1470+
// Verify that task.say was called with the error
1471+
expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("Cannot read"))
1472+
expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("is a directory"))
1473+
expect(mockCline.say).toHaveBeenCalledWith("error", expect.stringContaining("list_files tool"))
1474+
})
14321475
})
14331476
})
14341477

@@ -1460,7 +1503,12 @@ describe("read_file tool with image support", () => {
14601503

14611504
// CRITICAL: Reset fsPromises.stat to prevent cross-test contamination
14621505
fsPromises.stat.mockClear()
1463-
fsPromises.stat.mockResolvedValue({ size: 1024 })
1506+
fsPromises.stat.mockResolvedValue({
1507+
size: 1024,
1508+
isDirectory: () => false,
1509+
isFile: () => true,
1510+
isSymbolicLink: () => false,
1511+
} as any)
14641512

14651513
// Use shared mock setup function with local variables
14661514
const mocks = createMockCline()
@@ -1801,6 +1849,13 @@ describe("read_file tool concurrent file reads limit", () => {
18011849
mockedIsBinaryFile.mockResolvedValue(false)
18021850
mockedCountFileLines.mockResolvedValue(10)
18031851

1852+
// Mock fsPromises.stat to return a file (not directory) by default
1853+
fsPromises.stat.mockResolvedValue({
1854+
isDirectory: () => false,
1855+
isFile: () => true,
1856+
isSymbolicLink: () => false,
1857+
} as any)
1858+
18041859
toolResult = undefined
18051860
})
18061861

0 commit comments

Comments
 (0)