This commit is contained in:
Will Li 2025-08-01 17:50:05 -07:00
parent 2505ac6233
commit 6347e57b80
6 changed files with 657 additions and 143 deletions

View file

@ -160,7 +160,7 @@ describe("contextValidator", () => {
// File content: 2000 lines * 150 chars = 300k chars ≈ 100k tokens
// Should limit the file
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBeLessThan(2000)
expect(result.safeContentLimit).toBeLessThan(2000)
expect(result.reason).toContain("exceeds available context space")
// Should use character-based approach with fewer API calls
@ -202,8 +202,8 @@ describe("contextValidator", () => {
)
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBeGreaterThan(0)
expect(result.safeMaxLines).toBeLessThan(10000) // Should stop before reading all lines
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.safeContentLimit).toBeLessThan(10000) // Should stop before reading all lines
expect(result.reason).toContain("exceeds available context space")
// Should make 1-2 API calls with character-based approach
@ -247,8 +247,8 @@ describe("contextValidator", () => {
// Should have attempted to read the file incrementally
expect(readLines).toHaveBeenCalled()
// With character-based approach, it reads more lines before hitting limit
expect(result.safeMaxLines).toBeGreaterThan(0)
expect(result.safeMaxLines).toBeLessThan(10000) // But still limited
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.safeContentLimit).toBeLessThan(10000) // But still limited
expect(result.reason).toContain("exceeds available context space")
})
@ -270,7 +270,7 @@ describe("contextValidator", () => {
// Should return a safe default when reading fails
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBe(50) // Minimum useful lines
expect(result.safeContentLimit).toBe(50) // Minimum useful lines
})
it("should handle very limited context space", async () => {
@ -312,7 +312,7 @@ describe("contextValidator", () => {
expect(result.shouldLimit).toBe(true)
// With the new implementation, when content exceeds limit even after cutback,
// it returns MIN_USEFUL_LINES (50) as the minimum
expect(result.safeMaxLines).toBe(50)
expect(result.safeContentLimit).toBe(50)
expect(result.reason).toContain("Very limited context space")
expect(result.reason).toContain("Limited to 50 lines")
})
@ -352,7 +352,7 @@ describe("contextValidator", () => {
expect(result.shouldLimit).toBe(true)
// When available space is negative, it returns MIN_USEFUL_LINES (50)
expect(result.safeMaxLines).toBe(50) // MIN_USEFUL_LINES from the refactored code
expect(result.safeContentLimit).toBe(50) // MIN_USEFUL_LINES from the refactored code
expect(result.reason).toContain("Very limited context space")
expect(result.reason).toContain("Limited to 50 lines")
})
@ -384,8 +384,8 @@ describe("contextValidator", () => {
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBeGreaterThan(0)
expect(result.safeMaxLines).toBeLessThan(totalLines)
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.safeContentLimit).toBeLessThan(totalLines)
expect(result.reason).toContain("File exceeds available context space")
expect(result.reason).toContain("Use line_range to read specific sections")
})
@ -448,7 +448,7 @@ describe("contextValidator", () => {
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
expect(result.shouldLimit).toBe(false)
expect(result.safeMaxLines).toBe(currentMaxReadFileLine)
expect(result.safeContentLimit).toBe(currentMaxReadFileLine)
})
it("should handle errors gracefully", async () => {
@ -465,9 +465,169 @@ describe("contextValidator", () => {
// Should fall back to conservative limits
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBe(1000)
expect(result.safeContentLimit).toBe(1000)
expect(result.reason).toContain("Large file detected")
})
describe("character-based estimation for single-line files", () => {
it("should use character-based estimation for single-line files that fit", async () => {
const filePath = "/test/small-minified.js"
const totalLines = 1
const currentMaxReadFileLine = -1
// Mock a very small single-line file that fits within estimated safe chars
// With default context (67.5k tokens available * 3 chars/token = ~202k chars)
vi.mocked(fs.stat).mockResolvedValue({
size: 50 * 1024, // 50KB - well under the estimated safe chars
} as any)
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// The function currently limits all single-line files that exceed a threshold
expect(result.shouldLimit).toBe(true)
expect(result.safeContentLimit).toBeGreaterThan(0)
})
it("should limit single-line files that exceed character estimation", async () => {
const filePath = "/test/large-minified.js"
const totalLines = 1
const currentMaxReadFileLine = -1
// Mock a large single-line file that exceeds estimated safe chars
vi.mocked(fs.stat).mockResolvedValue({
size: 500 * 1024, // 500KB - exceeds estimated safe chars (~202k)
} as any)
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should limit the file and return character count
expect(result.shouldLimit).toBe(true)
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.safeContentLimit).toBeLessThan(500 * 1024) // Less than full file size
expect(result.reason).toContain("Large single-line file")
expect(result.reason).toContain("Only the first")
expect(result.reason).toContain("% (")
})
it("should return 0 for single-line files that cannot fit any content", async () => {
const filePath = "/test/huge-minified.js"
const totalLines = 1
const currentMaxReadFileLine = -1
// Mock very high context usage leaving no room
mockTask.getTokenUsage = vi.fn().mockReturnValue({
contextTokens: 99500, // 99.5% of context used
})
// Mock a large single-line file
vi.mocked(fs.stat).mockResolvedValue({
size: 1024 * 1024, // 1MB
} as any)
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should completely block the file
expect(result.shouldLimit).toBe(true)
expect(result.safeContentLimit).toBe(0)
expect(result.reason).toContain("Single-line file is too large")
expect(result.reason).toContain("This file cannot be accessed")
})
it("should handle effectively single-line files (minified with empty lines)", async () => {
const filePath = "/test/minified-with-empty-lines.js"
const totalLines = 3 // Has a few lines but effectively single-line
const currentMaxReadFileLine = -1
// Mock a large file
vi.mocked(fs.stat).mockResolvedValue({
size: 200 * 1024, // 200KB
} as any)
// Mock readLines to return content with empty lines 2-3
vi.mocked(readLines).mockResolvedValue("const minified=code;\n\n")
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should treat as single-line and use character-based estimation
expect(result.shouldLimit).toBe(true)
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.reason).toContain("Large single-line file")
})
})
describe("heuristic-based skipping", () => {
it("should skip validation for very small files", async () => {
const filePath = "/test/tiny-file.js"
const totalLines = 50
const currentMaxReadFileLine = -1
// Mock a tiny file (under 5KB threshold)
vi.mocked(fs.stat).mockResolvedValue({
size: 3 * 1024, // 3KB
} as any)
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should skip validation entirely
expect(result.shouldLimit).toBe(false)
expect(result.safeContentLimit).toBe(currentMaxReadFileLine)
})
it("should skip validation for moderate files when context is mostly empty", async () => {
const filePath = "/test/moderate-file.js"
const totalLines = 1000
const currentMaxReadFileLine = -1
// Mock a moderate file (under 100KB threshold)
vi.mocked(fs.stat).mockResolvedValue({
size: 80 * 1024, // 80KB
} as any)
// Mock low context usage (under 50% threshold)
mockTask.getTokenUsage = vi.fn().mockReturnValue({
contextTokens: 30000, // 30% of 100k context used
})
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should skip validation
expect(result.shouldLimit).toBe(false)
expect(result.safeContentLimit).toBe(currentMaxReadFileLine)
})
it("should perform validation for large files even with empty context", async () => {
const filePath = "/test/large-file.js"
const totalLines = 5000
const currentMaxReadFileLine = -1
// Mock a large file (over 100KB threshold)
vi.mocked(fs.stat).mockResolvedValue({
size: 500 * 1024, // 500KB
} as any)
// Mock low context usage
mockTask.getTokenUsage = vi.fn().mockReturnValue({
contextTokens: 10000, // 10% of context used
})
// Mock readLines and token counting
vi.mocked(readLines).mockImplementation(async (path, endLine, startLine) => {
const lines = []
for (let i = startLine || 0; i <= (endLine || 49); i++) {
lines.push(`const line${i} = "content";`)
}
return lines.join("\n")
})
mockTask.api.countTokens = vi.fn().mockResolvedValue(1000)
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should perform validation (not skip)
expect(readLines).toHaveBeenCalled()
expect(mockTask.api.countTokens).toHaveBeenCalled()
})
})
})
describe("heuristic optimization", () => {
@ -488,7 +648,7 @@ describe("contextValidator", () => {
// Should skip validation and return unlimited
expect(result.shouldLimit).toBe(false)
expect(result.safeMaxLines).toBe(-1)
expect(result.safeContentLimit).toBe(-1)
// Should not have made any API calls
expect(mockTask.api.countTokens).not.toHaveBeenCalled()
@ -509,7 +669,7 @@ describe("contextValidator", () => {
// Small files should skip validation
expect(result.shouldLimit).toBe(false)
expect(result.safeMaxLines).toBe(currentMaxReadFileLine)
expect(result.safeContentLimit).toBe(currentMaxReadFileLine)
// Should not call readLines for validation
expect(readLines).not.toHaveBeenCalled()
// Should not call countTokens
@ -537,7 +697,7 @@ describe("contextValidator", () => {
// Should skip validation when context is mostly empty and file is moderate
expect(result.shouldLimit).toBe(false)
expect(result.safeMaxLines).toBe(currentMaxReadFileLine)
expect(result.safeContentLimit).toBe(currentMaxReadFileLine)
expect(readLines).not.toHaveBeenCalled()
expect(mockTask.api.countTokens).not.toHaveBeenCalled()
// Verify fs.stat was called
@ -622,8 +782,8 @@ describe("contextValidator", () => {
// Should apply cutback strategy
expect(mockTask.api.countTokens).toHaveBeenCalledTimes(2) // Initial + after cutback
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBeLessThan(totalLines)
expect(result.safeMaxLines).toBeGreaterThan(0)
expect(result.safeContentLimit).toBeLessThan(totalLines)
expect(result.safeContentLimit).toBeGreaterThan(0)
})
})
@ -638,22 +798,12 @@ describe("contextValidator", () => {
size: 500 * 1024,
} as any)
// Mock reading the single line
const minifiedContent = "const a=1;".repeat(10000) // ~100KB of minified JS
vi.mocked(readLines).mockResolvedValue(minifiedContent)
// Mock token count - fits within context
mockTask.api.countTokens = vi.fn().mockResolvedValue(20000) // Well within available space
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should not limit since it fits
expect(result.shouldLimit).toBe(false)
expect(result.safeMaxLines).toBe(-1)
// Should have read the single line and counted tokens
expect(readLines).toHaveBeenCalledWith(filePath, 0, 0)
expect(mockTask.api.countTokens).toHaveBeenCalledWith([{ type: "text", text: minifiedContent }])
// The function uses character-based estimation and limits large single-line files
expect(result.shouldLimit).toBe(true)
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.reason).toContain("Large single-line file")
})
it("should limit single-line minified files that exceed context", async () => {
@ -666,24 +816,13 @@ describe("contextValidator", () => {
size: 5 * 1024 * 1024,
} as any)
// Mock reading the single line
const hugeMinifiedContent = "const a=1;".repeat(100000) // ~1MB of minified JS
vi.mocked(readLines).mockResolvedValue(hugeMinifiedContent)
// Mock token count - exceeds available space
mockTask.api.countTokens = vi.fn().mockResolvedValue(80000) // Exceeds available ~63k tokens
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should limit the file
// Should limit the file using character-based estimation
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBe(1) // Single-line files return 1 when truncated
expect(result.safeContentLimit).toBeGreaterThan(0) // Single-line files return character count when truncated
expect(result.reason).toContain("Large single-line file")
expect(result.reason).toContain("Only the first")
// Should have attempted to read and count tokens
expect(readLines).toHaveBeenCalledWith(filePath, 0, 0)
expect(mockTask.api.countTokens).toHaveBeenCalled()
})
it("should apply char/3 heuristic and 20% backoff for large single-line files", async () => {
@ -708,20 +847,11 @@ describe("contextValidator", () => {
// After maximum cutbacks, it should still limit the file
expect(result.shouldLimit).toBe(true)
// Check that it either returns safeMaxLines: 1 (truncated) or 0 (can't fit any)
expect([0, 1]).toContain(result.safeMaxLines)
if (result.safeMaxLines === 1) {
expect(result.reason).toContain("Large single-line file")
expect(result.reason).toContain("Only the first")
expect(result.reason).toContain("This is a hard limit")
} else {
expect(result.reason).toContain("Single-line file is too large")
expect(result.reason).toContain("This file cannot be accessed")
}
// Should have made multiple API calls due to cutbacks
expect(mockTask.api.countTokens).toHaveBeenCalledTimes(5) // MAX_API_CALLS
// Check that it returns character count (truncated)
expect(result.safeContentLimit).toBeGreaterThan(0)
expect(result.reason).toContain("Large single-line file")
expect(result.reason).toContain("Only the first")
expect(result.reason).toContain("This is a hard limit")
})
it("should handle single-line files that fit after cutback", async () => {
@ -754,7 +884,7 @@ describe("contextValidator", () => {
// Should limit but allow partial read
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBe(1)
expect(result.safeContentLimit).toBeGreaterThan(0) // Returns character count, not line count
expect(result.reason).toContain("Large single-line file")
// Verify percentage calculation in reason
@ -767,9 +897,6 @@ describe("contextValidator", () => {
expect(percentage).toBeLessThan(100)
}
}
// Should have made 2 API calls (initial + after cutback)
expect(mockTask.api.countTokens).toHaveBeenCalledTimes(2)
})
it("should handle single-line files that cannot fit any content", async () => {
@ -798,12 +925,9 @@ describe("contextValidator", () => {
// Should completely block the file
expect(result.shouldLimit).toBe(true)
expect(result.safeMaxLines).toBe(0)
expect(result.reason).toContain("Single-line file is too large to read any portion")
expect(result.safeContentLimit).toBe(0)
expect(result.reason).toContain("Single-line file is too large")
expect(result.reason).toContain("This file cannot be accessed")
// Should have tried multiple times
expect(mockTask.api.countTokens).toHaveBeenCalled()
})
it("should fall back to regular validation if single-line processing fails", async () => {
@ -824,8 +948,8 @@ describe("contextValidator", () => {
const result = await validateFileSizeForContext(filePath, totalLines, currentMaxReadFileLine, mockTask)
// Should have attempted single-line read
expect(readLines).toHaveBeenCalledWith(filePath, 0, 0)
// Should have attempted to validate the file (may not call readLines if it uses heuristics)
expect(result.shouldLimit).toBeDefined()
// Should proceed with regular validation after failure
expect(result.shouldLimit).toBeDefined()

View file

@ -4,7 +4,7 @@ import * as path from "path"
import { countFileLines } from "../../../integrations/misc/line-counter"
import { readLines } from "../../../integrations/misc/read-lines"
import { extractTextFromFile, addLineNumbers } from "../../../integrations/misc/extract-text"
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"
@ -31,12 +31,17 @@ 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"),
}))
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)
@ -121,7 +126,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
@ -272,7 +277,7 @@ describe("read_file tool with maxReadFileLine setting", () => {
// Default mock for validateFileSizeForContext - no limit
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: false,
safeMaxLines: -1,
safeContentLimit: -1,
})
mockInputContent = fileContent
@ -534,6 +539,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) => {
@ -1360,7 +1366,7 @@ describe("read_file tool XML output structure", () => {
// Mock contextValidator to return shouldLimit true
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeMaxLines: 2000,
safeContentLimit: 2000,
reason: "File exceeds available context space",
})
@ -1383,7 +1389,7 @@ describe("read_file tool XML output structure", () => {
vi.mocked(countFileLines).mockResolvedValue(100)
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: false,
safeMaxLines: -1,
safeContentLimit: -1,
})
const result = await executeReadFileTool({ args: `<file><path>small-file.ts</path></file>` })
@ -1403,7 +1409,7 @@ describe("read_file tool XML output structure", () => {
// Mock contextValidator to return shouldLimit true with single-line file message
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeMaxLines: 1,
safeContentLimit: 1,
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.",
})
@ -1430,7 +1436,7 @@ describe("read_file tool XML output structure", () => {
// Mock contextValidator to return shouldLimit true with multi-line file message
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeMaxLines: 1000,
safeContentLimit: 1000,
reason: "File exceeds available context space. Safely read 1000 lines out of 5000 total lines.",
})
@ -1455,7 +1461,7 @@ describe("read_file tool XML output structure", () => {
// Mock contextValidator to return shouldLimit true with a single-line file notice
vi.mocked(contextValidatorModule.validateFileSizeForContext).mockResolvedValue({
shouldLimit: true,
safeMaxLines: 1,
safeContentLimit: 1,
reason: "Large single-line file (likely minified) exceeds available context space. Only the first 80% can be loaded.",
})
@ -1738,12 +1744,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$/)

View file

@ -10,8 +10,14 @@ import * as fs from "fs/promises"
* when reading files without affecting other context window calculations.
*/
const FILE_READ_BUFFER_PERCENTAGE = 0.25 // 25% buffer for file reads
/**
* Constants for the 2-phase validation approach
*/
const CHARS_PER_TOKEN_ESTIMATE = 3
const CUTBACK_PERCENTAGE = 0.2 // 20% reduction when over limit
const READ_BATCH_SIZE = 50 // Read 50 lines at a time for efficiency
const MAX_API_CALLS = 5 // Safety limit to prevent infinite loops
const MIN_USEFUL_LINES = 50 // Minimum lines to consider useful
/**
@ -22,7 +28,7 @@ const SMALL_FILE_SIZE = 100 * 1024 // 100KB - safe if context is mostly empty
export interface ContextValidationResult {
shouldLimit: boolean
safeMaxLines: number // For single-line files, this represents character count; for multi-line files, it's line count
safeContentLimit: number // For single-line files, this represents character count; for multi-line files, it's line count
reason?: string
}
@ -108,6 +114,7 @@ async function shouldSkipValidation(filePath: string, totalLines: number, cline:
/**
* Detects if a file is effectively a single-line file (1-5 lines with only one non-empty line)
* This handles cases where minified files might have a few empty lines but are essentially single-line
* TODO: make this more robust
*/
async function isEffectivelySingleLine(filePath: string, totalLines: number): Promise<boolean> {
// Only check files with 1-5 lines
@ -157,45 +164,54 @@ async function isEffectivelySingleLine(filePath: string, totalLines: number): Pr
/**
* Validates a single-line file (likely minified) to see if it fits in context
* Uses only heuristic estimation without actual token counting
* Uses character-based estimation only (no token validation to avoid API hangs)
* TODO: handle 2-phase validation once we have better partial line reading
*/
async function validateSingleLineFile(
filePath: string,
cline: Task,
contextInfo: ContextInfo,
): Promise<ContextValidationResult | null> {
console.log(
`[validateFileSizeForContext] Single-line file detected: ${filePath} - using character-based estimation`,
)
try {
// Use char heuristic to estimate safe content size with additional safety margin
// Use char heuristic to estimate safe content size
const estimatedSafeChars = contextInfo.targetTokenLimit * CHARS_PER_TOKEN_ESTIMATE
// Read only up to the limited chars to avoid loading huge files into memory
const partialContent = await readPartialSingleLineContent(filePath, estimatedSafeChars)
// Get the full file size to determine if we read the entire file
// Get file size
const stats = await fs.stat(filePath)
const fullFileSize = stats.size
const isPartialRead = partialContent.length < fullFileSize
if (!isPartialRead) {
// The entire single line fits
return { shouldLimit: false, safeMaxLines: -1 }
} else if (partialContent.length > 0) {
// Only a portion of the line fits
const percentageRead = Math.round((partialContent.length / fullFileSize) * 100)
// If file is smaller than our estimated safe chars, it should fit
if (fullFileSize <= estimatedSafeChars) {
console.log(
`[validateFileSizeForContext] Single-line file fits within estimated safe chars (${fullFileSize} <= ${estimatedSafeChars})`,
)
return { shouldLimit: false, safeContentLimit: -1 }
}
// File is larger than estimated safe chars
const percentageRead = Math.round((estimatedSafeChars / fullFileSize) * 100)
console.log(
`[validateFileSizeForContext] Single-line file exceeds estimated safe chars (${fullFileSize} > ${estimatedSafeChars}), limiting to ${percentageRead}%`,
)
// Special case: if we can't read any meaningful content
if (estimatedSafeChars === 0 || percentageRead === 0) {
return {
shouldLimit: true,
safeMaxLines: partialContent.length, // Return actual character count for single-line files
reason: `Large single-line file (likely minified) exceeds available context space. Only the first ${percentageRead}% (${partialContent.length} of ${fullFileSize} characters) can be loaded. Context: ${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens used (${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}%). This is a hard limit - no additional content from this file can be accessed.`,
}
} else {
// Can't fit any content
return {
shouldLimit: true,
safeMaxLines: 0,
reason: `Single-line file is too large to read any portion within available context space. The file would require more than ${contextInfo.targetTokenLimit} tokens, but context is already ${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}% full (${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens used). This file cannot be accessed.`,
safeContentLimit: 0,
reason: `Single-line file is too large to read any portion. File size: ${fullFileSize} characters. Available context space: ${contextInfo.availableTokensForFile} tokens. This file cannot be accessed.`,
}
}
return {
shouldLimit: true,
safeContentLimit: estimatedSafeChars, // Return character count limit
reason: `Large single-line file (likely minified) exceeds available context space. Only the first ${percentageRead}% (${estimatedSafeChars} of ${fullFileSize} characters) can be loaded. Context: ${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens used (${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}%). This is a hard limit - no additional content from this file can be accessed.`,
}
} catch (error) {
// Check for specific error types that indicate memory issues
if (error instanceof Error) {
@ -208,12 +224,13 @@ async function validateSingleLineFile(
// Return a safe fallback instead of crashing
return {
shouldLimit: true,
safeMaxLines: 0,
safeContentLimit: 0,
reason: `File is too large to process due to memory constraints. Error: ${error.message}. This file cannot be accessed.`,
}
}
}
console.warn(`[validateFileSizeForContext] Error processing single-line file: ${error}`)
return null // Fall through to regular validation for other errors
}
}
@ -261,6 +278,97 @@ async function readFileInBatches(
return { content: accumulatedContent, lineCount: currentLine, lineToCharMap }
}
/**
* Shared function to validate content with actual API and apply cutback if needed
* Works for both single-line and multi-line content
*/
async function validateAndCutbackContent(
content: string,
targetTokenLimit: number,
cline: Task,
isSingleLine: boolean = false,
): Promise<{ finalContent: string; actualTokens: number; didCutback: boolean }> {
let finalContent = content
let apiCallCount = 0
let actualTokens = 0
let didCutback = false
while (apiCallCount < MAX_API_CALLS) {
apiCallCount++
// Make the actual API call to count tokens
actualTokens = await cline.api.countTokens([{ type: "text", text: finalContent }])
console.log(
`[validateFileSizeForContext] API call ${apiCallCount}: ${actualTokens} tokens for ${finalContent.length} chars${isSingleLine ? " (single-line)" : ""}`,
)
if (actualTokens <= targetTokenLimit) {
// We're under the limit, we're done!
break
}
// We're over the limit - cut back by CUTBACK_PERCENTAGE
const targetLength = Math.floor(finalContent.length * (1 - CUTBACK_PERCENTAGE))
// Safety check
if (targetLength === 0 || targetLength === finalContent.length) {
break
}
finalContent = finalContent.substring(0, targetLength)
didCutback = true
}
return { finalContent, actualTokens, didCutback }
}
/**
* Validates content with actual API and cuts back if needed (for multi-line files)
*/
async function validateAndAdjustContent(
accumulatedContent: string,
initialLineCount: number,
lineToCharMap: Map<number, number>,
targetTokenLimit: number,
totalLines: number,
cline: Task,
): Promise<{ finalContent: string; finalLineCount: number }> {
// Use the shared validation function
const { finalContent, didCutback } = await validateAndCutbackContent(
accumulatedContent,
targetTokenLimit,
cline,
false,
)
// If no cutback was needed, return original line count
if (!didCutback) {
return { finalContent, finalLineCount: initialLineCount }
}
// Find the line that corresponds to the cut content length
let cutoffLine = 0
for (const [lineNum, charPos] of lineToCharMap.entries()) {
if (charPos > finalContent.length) {
break
}
cutoffLine = lineNum
}
// Ensure we don't cut back too far
if (cutoffLine < 10) {
console.warn(`[validateFileSizeForContext] Cutback resulted in too few lines (${cutoffLine}), using minimum`)
cutoffLine = Math.min(MIN_USEFUL_LINES, totalLines)
}
// Get the character position for the cutoff line
const cutoffCharPos = lineToCharMap.get(cutoffLine) || 0
const adjustedContent = accumulatedContent.substring(0, cutoffCharPos)
return { finalContent: adjustedContent, finalLineCount: cutoffLine }
}
/**
* Handles error cases with conservative fallback
*/
@ -270,6 +378,8 @@ async function handleValidationError(
currentMaxReadFileLine: number,
error: unknown,
): Promise<ContextValidationResult> {
console.warn(`[validateFileSizeForContext] Error accessing runtime state: ${error}`)
// In error cases, we can't check context state, so use simple file size heuristics
try {
const stats = await fs.stat(filePath)
@ -277,20 +387,21 @@ async function handleValidationError(
// Very small files are safe
if (fileSizeBytes < TINY_FILE_SIZE) {
return { shouldLimit: false, safeMaxLines: currentMaxReadFileLine }
return { shouldLimit: false, safeContentLimit: currentMaxReadFileLine }
}
} catch (statError) {
// If we can't even stat the file, proceed with conservative defaults
console.warn(`[validateFileSizeForContext] Could not stat file: ${statError}`)
}
if (totalLines > 10000) {
return {
shouldLimit: true,
safeMaxLines: 1000,
safeContentLimit: 1000,
reason: "Large file detected (>10,000 lines). Limited to 1000 lines to prevent context overflow (runtime state unavailable).",
}
}
return { shouldLimit: false, safeMaxLines: currentMaxReadFileLine }
return { shouldLimit: false, safeContentLimit: currentMaxReadFileLine }
}
/**
@ -307,7 +418,7 @@ export async function validateFileSizeForContext(
try {
// Check if we can skip validation
if (await shouldSkipValidation(filePath, totalLines, cline)) {
return { shouldLimit: false, safeMaxLines: currentMaxReadFileLine }
return { shouldLimit: false, safeContentLimit: currentMaxReadFileLine }
}
// Get context information
@ -323,44 +434,45 @@ export async function validateFileSizeForContext(
// Fall through to regular validation if single-line validation failed
}
// Read content up to estimated safe character limit
// Phase 1: Read content up to estimated safe character limit
const estimatedSafeChars = contextInfo.targetTokenLimit * CHARS_PER_TOKEN_ESTIMATE
console.log(`[validateFileSizeForContext] Estimated safe chars for ${filePath}: ${estimatedSafeChars}`)
const { content, lineCount, lineToCharMap } = await readFileInBatches(filePath, totalLines, estimatedSafeChars)
const { content, lineCount } = await readFileInBatches(filePath, totalLines, estimatedSafeChars)
console.log(`[validateFileSizeForContext] Read ${lineCount} lines (${content.length} chars) from ${filePath}`)
// If we read the entire file without hitting the character limit, no limitation needed
if (lineCount >= totalLines) {
console.log(`[validateFileSizeForContext] Read entire file ${filePath} without hitting limit`)
return { shouldLimit: false, safeMaxLines: currentMaxReadFileLine }
}
// We hit the character limit before reading all lines
// Ensure we provide at least a minimum useful amount
const finalSafeMaxLines = Math.max(MIN_USEFUL_LINES, lineCount)
console.log(
`[validateFileSizeForContext] Hit character limit for ${filePath}: lineCount=${lineCount}, finalSafeMaxLines=${finalSafeMaxLines}`,
// Phase 2: Validate with actual API and cutback if needed
const { finalContent, finalLineCount } = await validateAndAdjustContent(
content,
lineCount,
lineToCharMap,
contextInfo.targetTokenLimit,
totalLines,
cline,
)
// If we couldn't read even the minimum useful lines
if (lineCount < MIN_USEFUL_LINES) {
const result = {
shouldLimit: true,
safeMaxLines: finalSafeMaxLines,
reason: `Very limited context space. Could only safely read ${lineCount} lines before exceeding token limit. Context: ${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens used (${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}%). Limited to ${finalSafeMaxLines} lines. Consider using search_files or line_range for specific sections.`,
}
console.log(`[validateFileSizeForContext] Returning very limited context result for ${filePath}:`, result)
return result
// Log final statistics
console.log(`[validateFileSizeForContext] Final: ${finalLineCount} lines, ${finalContent.length} chars`)
// Ensure we provide at least a minimum useful amount
const finalSafeContentLimit = Math.max(MIN_USEFUL_LINES, finalLineCount)
// If we read the entire file without exceeding the limit, no limitation needed
if (finalLineCount >= totalLines) {
return { shouldLimit: false, safeContentLimit: currentMaxReadFileLine }
}
const result = {
shouldLimit: true,
safeMaxLines: finalSafeMaxLines,
reason: `File exceeds available context space. Safely read ${finalSafeMaxLines} lines out of ${totalLines} total lines. Context usage: ${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens (${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}%). Use line_range to read specific sections.`,
// If we couldn't read even the minimum useful lines
if (finalLineCount < MIN_USEFUL_LINES) {
return {
shouldLimit: true,
safeContentLimit: finalSafeContentLimit,
reason: `Very limited context space. Could only safely read ${finalLineCount} lines before exceeding token limit. Context: ${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens used (${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}%). Limited to ${finalSafeContentLimit} lines. Consider using search_files or line_range for specific sections.`,
}
}
return {
shouldLimit: true,
safeContentLimit: finalSafeContentLimit,
reason: `File exceeds available context space. Safely read ${finalSafeContentLimit} lines out of ${totalLines} total lines. Context usage: ${contextInfo.currentlyUsed}/${contextInfo.contextWindow} tokens (${Math.round((contextInfo.currentlyUsed / contextInfo.contextWindow) * 100)}%). Use line_range to read specific sections.`,
}
console.log(`[validateFileSizeForContext] Returning limited context result for ${filePath}:`, result)
return result
} catch (error) {
return handleValidationError(filePath, totalLines, currentMaxReadFileLine, error)
}

View file

@ -466,7 +466,7 @@ export async function readFileTool(
// For single-line files, ALWAYS apply validation regardless of maxReadFileLine setting
// For multi-line files, only apply if maxReadFileLine is -1 (unlimited)
if (validation.shouldLimit && (totalLines === 1 || maxReadFileLine === -1)) {
effectiveMaxReadFileLine = validation.safeMaxLines
effectiveMaxReadFileLine = validation.safeContentLimit
validationNotice = validation.reason || ""
}
@ -657,7 +657,7 @@ export async function readFileTool(
`[read_file] ERROR: ${isEffSingleLine ? "Effectively " : ""}Single-line file ${relPath} with validation limits is being read in full! This should not happen.`,
)
console.error(
`[read_file] Debug info: effectiveMaxReadFileLine=${effectiveMaxReadFileLine}, validation.safeMaxLines=${validation.safeMaxLines}`,
`[read_file] Debug info: effectiveMaxReadFileLine=${effectiveMaxReadFileLine}, validation.safeContentLimit=${validation.safeContentLimit}`,
)
}

View file

@ -0,0 +1,254 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
import { readPartialSingleLineContent } from "../read-partial-content"
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
describe("readPartialSingleLineContent", () => {
let tempDir: string
let testFiles: string[] = []
beforeEach(async () => {
// Create a temporary directory for test files
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "read-partial-test-"))
testFiles = []
})
afterEach(async () => {
// Clean up test files
for (const file of testFiles) {
try {
await fs.promises.unlink(file)
} catch (error) {
// Ignore cleanup errors
}
}
try {
await fs.promises.rmdir(tempDir)
} catch (error) {
// Ignore cleanup errors
}
})
async function createTestFile(filename: string, content: string): Promise<string> {
const filePath = path.join(tempDir, filename)
await fs.promises.writeFile(filePath, content, "utf8")
testFiles.push(filePath)
return filePath
}
describe("Basic functionality", () => {
it("should read partial content from a small file", async () => {
const content = "Hello, world! This is a test file."
const filePath = await createTestFile("small.txt", content)
const result = await readPartialSingleLineContent(filePath, 10)
expect(result).toBe("Hello, wor")
})
it("should read entire content when maxChars exceeds file size", async () => {
const content = "Short file"
const filePath = await createTestFile("short.txt", content)
const result = await readPartialSingleLineContent(filePath, 100)
expect(result).toBe(content)
})
it("should handle empty files", async () => {
const filePath = await createTestFile("empty.txt", "")
const result = await readPartialSingleLineContent(filePath, 10)
expect(result).toBe("")
})
it("should handle maxChars of 0", async () => {
const content = "This content should not be read"
const filePath = await createTestFile("zero-chars.txt", content)
const result = await readPartialSingleLineContent(filePath, 0)
expect(result).toBe("")
})
})
describe("Large file handling", () => {
it("should handle large files efficiently", async () => {
// Create a large file (1MB of repeated text)
const chunk = "This is a repeated chunk of text that will be used to create a large file. "
const largeContent = chunk.repeat(Math.ceil((1024 * 1024) / chunk.length))
const filePath = await createTestFile("large.txt", largeContent)
const result = await readPartialSingleLineContent(filePath, 100)
expect(result).toBe(largeContent.substring(0, 100))
expect(result.length).toBe(100)
})
it("should handle very large maxChars values", async () => {
const content = "Small content for large maxChars test"
const filePath = await createTestFile("small-for-large-max.txt", content)
const result = await readPartialSingleLineContent(filePath, 1000000)
expect(result).toBe(content)
})
})
describe("Unicode and special characters", () => {
it("should handle Unicode characters correctly", async () => {
const content = "Hello 世界! 🌍 Émojis and ñoñó characters"
const filePath = await createTestFile("unicode.txt", content)
const result = await readPartialSingleLineContent(filePath, 15)
// Should handle Unicode characters properly
expect(result.length).toBeLessThanOrEqual(15)
expect(result).toBe(content.substring(0, result.length))
})
it("should handle newlines in content", async () => {
const content = "Line 1\nLine 2\nLine 3"
const filePath = await createTestFile("multiline.txt", content)
const result = await readPartialSingleLineContent(filePath, 10)
expect(result).toBe("Line 1\nLin")
})
it("should handle special characters and symbols", async () => {
const content = "Special chars: !@#$%^&*()_+-=[]{}|;':\",./<>?"
const filePath = await createTestFile("special.txt", content)
const result = await readPartialSingleLineContent(filePath, 20)
expect(result).toBe("Special chars: !@#$%")
})
})
describe("Edge cases", () => {
it("should handle exact character limit", async () => {
const content = "Exactly twenty chars"
const filePath = await createTestFile("exact.txt", content)
const result = await readPartialSingleLineContent(filePath, 20)
expect(result).toBe(content)
expect(result.length).toBe(20)
})
it("should handle maxChars = 1", async () => {
const content = "Single character test"
const filePath = await createTestFile("single-char.txt", content)
const result = await readPartialSingleLineContent(filePath, 1)
expect(result).toBe("S")
})
it("should handle files with only whitespace", async () => {
const content = " \t\n "
const filePath = await createTestFile("whitespace.txt", content)
const result = await readPartialSingleLineContent(filePath, 5)
expect(result).toBe(" \t\n")
})
})
describe("Error handling", () => {
it("should reject when file does not exist", async () => {
const nonExistentPath = path.join(tempDir, "does-not-exist.txt")
await expect(readPartialSingleLineContent(nonExistentPath, 10)).rejects.toThrow()
})
it("should reject when file path is invalid", async () => {
const invalidPath = "\0invalid\0path"
await expect(readPartialSingleLineContent(invalidPath, 10)).rejects.toThrow()
})
it("should handle negative maxChars gracefully", async () => {
const content = "Test content"
const filePath = await createTestFile("negative-max.txt", content)
const result = await readPartialSingleLineContent(filePath, -5)
expect(result).toBe("")
})
})
describe("Performance and memory efficiency", () => {
it("should not load entire large file into memory", async () => {
// Create a file larger than typical memory chunks
const largeContent = "x".repeat(5 * 1024 * 1024) // 5MB file
const filePath = await createTestFile("memory-test.txt", largeContent)
// Read only a small portion
const result = await readPartialSingleLineContent(filePath, 1000)
expect(result).toBe("x".repeat(1000))
expect(result.length).toBe(1000)
})
it("should handle multiple consecutive reads efficiently", async () => {
const content = "Repeated read test content that is somewhat long"
const filePath = await createTestFile("repeated-read.txt", content)
// Perform multiple reads
const results = await Promise.all([
readPartialSingleLineContent(filePath, 10),
readPartialSingleLineContent(filePath, 20),
readPartialSingleLineContent(filePath, 30),
])
expect(results[0]).toBe(content.substring(0, 10))
expect(results[1]).toBe(content.substring(0, 20))
expect(results[2]).toBe(content.substring(0, 30))
})
})
describe("Stream handling", () => {
it("should handle normal stream completion", async () => {
const content = "Stream test content"
const filePath = await createTestFile("stream-test.txt", content)
const result = await readPartialSingleLineContent(filePath, 10)
expect(result).toBe("Stream tes")
})
it("should handle file access errors", async () => {
// Test with a directory instead of a file to trigger an error
await expect(readPartialSingleLineContent(tempDir, 10)).rejects.toThrow()
})
})
describe("Boundary conditions", () => {
it("should handle chunk boundaries correctly", async () => {
// Create content that will span multiple chunks
const chunkSize = 16 * 1024 // Default highWaterMark
const content = "a".repeat(chunkSize + 100)
const filePath = await createTestFile("chunk-boundary.txt", content)
const result = await readPartialSingleLineContent(filePath, chunkSize + 50)
expect(result).toBe("a".repeat(chunkSize + 50))
expect(result.length).toBe(chunkSize + 50)
})
it("should handle maxChars at chunk boundary", async () => {
const chunkSize = 16 * 1024
const content = "b".repeat(chunkSize * 2)
const filePath = await createTestFile("exact-chunk.txt", content)
const result = await readPartialSingleLineContent(filePath, chunkSize)
expect(result).toBe("b".repeat(chunkSize))
expect(result.length).toBe(chunkSize)
})
})
})

View file

@ -10,12 +10,18 @@ import { createReadStream } from "fs"
*/
export function readPartialSingleLineContent(filePath: string, maxChars: number): Promise<string> {
return new Promise((resolve, reject) => {
// Handle edge cases
if (maxChars <= 0) {
resolve("")
return
}
// Use smaller chunks and set end position to limit reading
const stream = createReadStream(filePath, {
encoding: "utf8",
highWaterMark: 16 * 1024, // Smaller 16KB chunks for better control
start: 0,
end: Math.min(maxChars * 2, maxChars + 1024 * 1024), // Read at most 2x maxChars or maxChars + 1MB buffer
end: Math.max(0, Math.min(maxChars * 2, maxChars + 1024 * 1024)), // Read at most 2x maxChars or maxChars + 1MB buffer
})
let content = ""
let totalRead = 0