Skip to content
Closed
476 changes: 476 additions & 0 deletions src/core/tools/__tests__/contextValidator.test.ts

Large diffs are not rendered by default.

221 changes: 212 additions & 9 deletions src/core/tools/__tests__/readFileTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@ import * as path from "path"

import { countFileLines } from "../../../integrations/misc/line-counter"
import { readLines } from "../../../integrations/misc/read-lines"
import { extractTextFromFile } from "../../../integrations/misc/extract-text"
import { readPartialContent } from "../../../integrations/misc/read-partial-content"
import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text"
import { parseSourceCodeDefinitionsForFile } from "../../../services/tree-sitter"
import { isBinaryFile } from "isbinaryfile"
import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/tools"
import { readFileTool } from "../readFileTool"
import { formatResponse } from "../../prompts/responses"
import * as contextValidatorModule from "../contextValidator"
import { DEFAULT_MAX_IMAGE_FILE_SIZE_MB, DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB } from "../helpers/imageHelpers"

vi.mock("../../../i18n", () => ({
t: vi.fn((key: string) => key),
}))

vi.mock("path", async () => {
const originalPath = await vi.importActual("path")
return {
Expand All @@ -26,11 +32,25 @@ vi.mock("path", async () => {
vi.mock("isbinaryfile")

vi.mock("../../../integrations/misc/line-counter")
vi.mock("../../../integrations/misc/read-lines")
vi.mock("../../../integrations/misc/read-lines", () => ({
readLines: vi.fn().mockResolvedValue("mocked line content"),
}))
vi.mock("../../../integrations/misc/read-partial-content", () => ({
readPartialSingleLineContent: vi.fn().mockResolvedValue("mocked partial content"),
readPartialContent: vi.fn().mockResolvedValue({
content: "mocked partial content",
charactersRead: 100,
totalCharacters: 1000,
linesRead: 5,
totalLines: 50,
lastLineRead: 5,
}),
}))
vi.mock("../contextValidator")

// Mock fs/promises readFile for image tests
const fsPromises = vi.hoisted(() => ({
readFile: vi.fn(),
readFile: vi.fn().mockResolvedValue(Buffer.from("mock file content")),
stat: vi.fn().mockResolvedValue({ size: 1024 }),
}))
vi.mock("fs/promises", () => fsPromises)
Expand Down Expand Up @@ -115,7 +135,7 @@ vi.mock("../../ignore/RooIgnoreController", () => ({
}))

vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn().mockReturnValue(true),
fileExistsAtPath: vi.fn().mockResolvedValue(true),
}))

// Global beforeEach to ensure clean mock state between all test suites
Expand Down Expand Up @@ -263,6 +283,12 @@ describe("read_file tool with maxReadFileLine setting", () => {
mockedPathResolve.mockReturnValue(absoluteFilePath)
mockedIsBinaryFile.mockResolvedValue(false)

// Default mock for validateFileSizeForContext - no limit
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: false,
safeContentLimit: -1,
})

mockInputContent = fileContent

// Setup the extractTextFromFile mock implementation with the current mockInputContent
Expand Down Expand Up @@ -382,8 +408,7 @@ describe("read_file tool with maxReadFileLine setting", () => {
expect(result).toContain(`<list_code_definition_names>`)

// Verify XML structure
expect(result).toContain("<notice>Showing only 0 of 5 total lines")
expect(result).toContain("</notice>")
expect(result).toContain("<notice>tools:readFile.showingOnlyLines</notice>")
expect(result).toContain("<list_code_definition_names>")
expect(result).toContain(sourceCodeDef.trim())
expect(result).toContain("</list_code_definition_names>")
Expand All @@ -409,7 +434,7 @@ describe("read_file tool with maxReadFileLine setting", () => {
expect(result).toContain(`<file><path>${testFilePath}</path>`)
expect(result).toContain(`<content lines="1-3">`)
expect(result).toContain(`<list_code_definition_names>`)
expect(result).toContain("<notice>Showing only 3 of 5 total lines")
expect(result).toContain("<notice>tools:readFile.showingOnlyLines</notice>")
})
})

Expand Down Expand Up @@ -523,6 +548,7 @@ describe("read_file tool XML output structure", () => {

mockedPathResolve.mockReturnValue(absoluteFilePath)
mockedIsBinaryFile.mockResolvedValue(false)
mockedCountFileLines.mockResolvedValue(5) // Default line count

// Set default implementation for extractTextFromFile
mockedExtractTextFromFile.mockImplementation((filePath) => {
Expand Down Expand Up @@ -1326,6 +1352,171 @@ describe("read_file tool XML output structure", () => {
)
})
})

describe("line range instructions", () => {
beforeEach(() => {
// Reset mocks
vi.clearAllMocks()

// Mock file system functions
vi.mocked(isBinaryFile).mockResolvedValue(false)
vi.mocked(countFileLines).mockResolvedValue(10000) // Large file
vi.mocked(readLines).mockResolvedValue("line content")
vi.mocked(extractTextFromFile).mockResolvedValue("file content")

// Mock addLineNumbers
vi.mocked(addLineNumbers).mockImplementation((content, start) => `${start || 1} | ${content}`)
})

it("should always include inline line_range instructions when shouldLimit is true", async () => {
// Mock a large file
vi.mocked(countFileLines).mockResolvedValue(10000)

// Mock contextValidator to return shouldLimit true
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeContentLimit: 2000,
reason: "File exceeds available context space. Can read 2000 of 500000 characters (40%). Context usage: 10000/100000 tokens (10%).",
})

// Mock readPartialContent to return truncated content
vi.mocked(readPartialContent).mockResolvedValue({
content: "Line 1\nLine 2\n...truncated...",
charactersRead: 2000,
totalCharacters: 500000,
linesRead: 100,
totalLines: 10000,
lastLineRead: 100,
})

const result = await executeReadFileTool(
{ args: `<file><path>large-file.ts</path></file>` },
{ totalLines: 10000, maxReadFileLine: -1 },
)

// Verify the result contains the partial read notice for multi-line files
expect(result).toContain("<notice>")
expect(result).toContain("tools:readFile.partialReadMultiLine")
// The current implementation doesn't include contextLimitInstructions
expect(result).not.toContain("tools:readFile.contextLimitInstructions")
})

it("should not show any special notice when file fits in context", async () => {
// Mock small file that fits in context
vi.mocked(countFileLines).mockResolvedValue(100)
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: false,
safeContentLimit: -1,
})

const result = await executeReadFileTool({ args: `<file><path>small-file.ts</path></file>` })

// Should have file content but no notice about limits
expect(result).toContain("<file>")
expect(result).toContain("<path>small-file.ts</path>")
expect(result).toContain("<content")
expect(result).not.toContain("Use line_range")
expect(result).not.toContain("File exceeds available context space")
})

it("should not include line_range instructions for single-line files", async () => {
// Mock a single-line file that exceeds context
vi.mocked(countFileLines).mockResolvedValue(1)

// Mock contextValidator to return shouldLimit true with single-line file message
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeContentLimit: 5000,
reason: "Large single-line file (likely minified) exceeds available context space. Only the first 50% (5000 of 10000 characters) can be loaded. This is a hard limit - no additional content from this file can be accessed.",
})

// Mock readPartialContent to return truncated content for single-line file
vi.mocked(readPartialContent).mockResolvedValue({
content: "const a=1;const b=2;...truncated",
charactersRead: 5000,
totalCharacters: 10000,
linesRead: 1,
totalLines: 1,
lastLineRead: 1,
})

const result = await executeReadFileTool(
{ args: `<file><path>minified.js</path></file>` },
{ totalLines: 1, maxReadFileLine: -1 },
)

// Verify the result contains the notice but NOT the line_range instructions
expect(result).toContain("<notice>")
expect(result).toContain("tools:readFile.partialReadSingleLine")
expect(result).not.toContain("tools:readFile.contextLimitInstructions")
expect(result).not.toContain("Use line_range")
})

it("should include line_range instructions for multi-line files that exceed context", async () => {
// Mock a multi-line file that exceeds context
vi.mocked(countFileLines).mockResolvedValue(5000)

// Mock contextValidator to return shouldLimit true with multi-line file message
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeContentLimit: 50000,
reason: "File exceeds available context space. Can read 50000 of 250000 characters (20%). Context usage: 50000/100000 tokens (50%).",
})

// Mock readPartialContent to return truncated content
vi.mocked(readPartialContent).mockResolvedValue({
content: "Line 1\nLine 2\n...truncated...",
charactersRead: 50000,
totalCharacters: 250000,
linesRead: 1000,
totalLines: 5000,
lastLineRead: 1000,
})

const result = await executeReadFileTool(
{ args: `<file><path>large-file.ts</path></file>` },
{ totalLines: 5000, maxReadFileLine: -1 },
)

// Verify the result contains the partial read notice for multi-line files
expect(result).toContain("<notice>")
expect(result).toContain("tools:readFile.partialReadMultiLine")
// The current implementation doesn't include contextLimitInstructions
expect(result).not.toContain("tools:readFile.contextLimitInstructions")
})

it("should handle normal file read section for single-line files with validation notice", async () => {
// Mock a single-line file that has shouldLimit true but fits after truncation
vi.mocked(countFileLines).mockResolvedValue(1)

// Mock contextValidator to return shouldLimit true with a single-line file notice
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeContentLimit: 8000,
reason: "Large single-line file (likely minified) exceeds available context space. Only the first 80% (8000 of 10000 characters) can be loaded.",
})

// Mock readPartialContent for single-line file
vi.mocked(readPartialContent).mockResolvedValue({
content: "const a=1;const b=2;const c=3;",
charactersRead: 8000,
totalCharacters: 10000,
linesRead: 1,
totalLines: 1,
lastLineRead: 1,
})

const result = await executeReadFileTool(
{ args: `<file><path>semi-large.js</path></file>` },
{ totalLines: 1, maxReadFileLine: -1 },
)

// Verify single-line file notice doesn't include line_range instructions
expect(result).toContain("<notice>")
expect(result).toContain("tools:readFile.partialReadSingleLine")
expect(result).not.toContain("tools:readFile.contextLimitInstructions")
})
})
})

describe("read_file tool with image support", () => {
Expand Down Expand Up @@ -1591,12 +1782,24 @@ describe("read_file tool with image support", () => {
mockedPathResolve.mockReturnValue(absolutePath)
mockedExtractTextFromFile.mockResolvedValue("PDF content extracted")

// Ensure the file is treated as binary and PDF is in supported formats
mockedIsBinaryFile.mockResolvedValue(true)
mockedCountFileLines.mockResolvedValue(0)
vi.mocked(getSupportedBinaryFormats).mockReturnValue([".pdf", ".docx", ".ipynb"])

// Mock contextValidator to not interfere with PDF processing
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: false,
safeContentLimit: -1,
})

// Execute
const result = await executeReadImageTool(binaryPath)

// Verify it uses extractTextFromFile instead
// Verify it doesn't treat the PDF as an image
expect(result).not.toContain("<image_data>")
// Make the test platform-agnostic by checking the call was made (path normalization can vary)

// Should call extractTextFromFile for PDF processing
expect(mockedExtractTextFromFile).toHaveBeenCalledTimes(1)
const callArgs = mockedExtractTextFromFile.mock.calls[0]
expect(callArgs[0]).toMatch(/[\\\/]test[\\\/]document\.pdf$/)
Expand Down
Loading
Loading