diff --git a/src/core/config/envConfig.ts b/src/core/config/envConfig.ts new file mode 100644 index 0000000000..b544b99865 --- /dev/null +++ b/src/core/config/envConfig.ts @@ -0,0 +1,8 @@ +function getFromEnv(key: string, defaultValue: string): string { + const value = process.env[key] + return value === undefined ? defaultValue : value +} + +export const ROO_AGENT_CONFIG = { + fileReadCacheSize: () => parseInt(getFromEnv("ROO_FILE_READ_CACHE_SIZE", "100")), +} diff --git a/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap b/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap index 8f93b0353d..4bf0a13f87 100644 --- a/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap +++ b/src/core/prompts/__tests__/__snapshots__/system.test.ts.snap @@ -37,22 +37,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -64,7 +64,7 @@ Examples: src/app.ts - + 1-1000 @@ -74,11 +74,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -95,6 +96,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -531,22 +536,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -558,7 +563,7 @@ Examples: src/app.ts - + 1-1000 @@ -568,11 +573,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -589,6 +595,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -1025,22 +1035,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -1052,7 +1062,7 @@ Examples: src/app.ts - + 1-1000 @@ -1062,11 +1072,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -1083,6 +1094,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -1519,22 +1534,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -1546,7 +1561,7 @@ Examples: src/app.ts - + 1-1000 @@ -1556,11 +1571,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -1577,6 +1593,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -2069,22 +2089,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -2096,7 +2116,7 @@ Examples: src/app.ts - + 1-1000 @@ -2106,11 +2126,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -2127,6 +2148,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -2631,22 +2656,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -2658,7 +2683,7 @@ Examples: src/app.ts - + 1-1000 @@ -2668,11 +2693,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -2689,6 +2715,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -3181,22 +3211,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -3208,7 +3238,7 @@ Examples: src/app.ts - + 1-1000 @@ -3218,11 +3248,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -3239,6 +3270,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -3763,22 +3798,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -3790,7 +3825,7 @@ Examples: src/app.ts - + 1-1000 @@ -3800,11 +3835,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -3821,6 +3857,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -4299,22 +4339,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -4326,7 +4366,7 @@ Examples: src/app.ts - + 1-1000 @@ -4336,11 +4376,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -4357,6 +4398,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -4870,22 +4915,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -4897,7 +4942,7 @@ Examples: src/app.ts - + 1-1000 @@ -4907,11 +4952,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -4928,6 +4974,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -5355,22 +5405,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -5382,7 +5432,7 @@ Examples: src/app.ts - + 1-1000 @@ -5392,11 +5442,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -5413,6 +5464,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files @@ -5757,22 +5812,22 @@ Always use the actual tool name as the XML tag name for proper parsing and execu # Tools ## read_file -Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of one or more files. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. **IMPORTANT: You can read a maximum of 15 files in a single request.** If you need to read more files, use multiple sequential read_file requests. - +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory /test/path) - + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - + start-end @@ -5784,7 +5839,7 @@ Examples: src/app.ts - + 1-1000 @@ -5794,11 +5849,12 @@ Examples: src/app.ts - + 1-50 + 100-150 src/utils.ts - + 10-20 @@ -5815,6 +5871,10 @@ Examples: IMPORTANT: You MUST use this Efficient Reading Strategy: - You MUST read all related files and implementations together in a single operation (up to 15 files at once) - You MUST obtain all necessary context before proceeding with changes +- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +- You MUST combine adjacent line ranges (<10 lines apart) +- You MUST use multiple ranges for content separated by >10 lines +- You MUST include sufficient line context for planned modifications while keeping ranges minimal - When you need to read more than 15 files, prioritize the most critical files first, then use subsequent read_file requests for additional files diff --git a/src/core/prompts/tools/read-file.ts b/src/core/prompts/tools/read-file.ts index 9df1e0b1ab..41ca747624 100644 --- a/src/core/prompts/tools/read-file.ts +++ b/src/core/prompts/tools/read-file.ts @@ -5,22 +5,22 @@ export function getReadFileDescription(args: ToolArgs): string { const isMultipleReadsEnabled = maxConcurrentReads > 1 return `## read_file -Description: Request to read the contents of ${isMultipleReadsEnabled ? "one or more files" : "a file"}. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code.${args.partialReadsEnabled ? " Use line ranges to efficiently read specific portions of large files." : ""} Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. +Description: Request to read the contents of ${isMultipleReadsEnabled ? "one or more files" : "a file"}. The tool outputs line-numbered content (e.g. "1 | const x = 1") for easy reference when creating diffs or discussing code. Use line ranges to efficiently read specific portions of large files. Supports text extraction from PDF and DOCX files, but may not handle other binary files properly. ${isMultipleReadsEnabled ? `**IMPORTANT: You can read a maximum of ${maxConcurrentReads} files in a single request.** If you need to read more files, use multiple sequential read_file requests.` : "**IMPORTANT: Multiple file reads are currently disabled. You can only read one file at a time.**"} -${args.partialReadsEnabled ? `By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory.` : ""} +By specifying line ranges, you can efficiently read specific portions of large files without loading the entire file into memory. Parameters: - args: Contains one or more file elements, where each file contains: - path: (required) File path (relative to workspace directory ${args.cwd}) - ${args.partialReadsEnabled ? `- line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive)` : ""} + - line_range: (optional) One or more line range elements in format "start-end" (1-based, inclusive) Usage: path/to/file - ${args.partialReadsEnabled ? `start-end` : ""} + start-end @@ -32,7 +32,7 @@ Examples: src/app.ts - ${args.partialReadsEnabled ? `1-1000` : ""} + 1-1000 @@ -44,16 +44,12 @@ ${isMultipleReadsEnabled ? `2. Reading multiple files (within the ${maxConcurren src/app.ts - ${ - args.partialReadsEnabled - ? `1-50 - 100-150` - : "" - } + 1-50 + 100-150 src/utils.ts - ${args.partialReadsEnabled ? `10-20` : ""} + 10-20 ` @@ -72,14 +68,10 @@ ${isMultipleReadsEnabled ? "3. " : "2. "}Reading an entire file: IMPORTANT: You MUST use this Efficient Reading Strategy: - ${isMultipleReadsEnabled ? `You MUST read all related files and implementations together in a single operation (up to ${maxConcurrentReads} files at once)` : "You MUST read files one at a time, as multiple file reads are currently disabled"} - You MUST obtain all necessary context before proceeding with changes -${ - args.partialReadsEnabled - ? `- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed +${`- You MUST use line ranges to read specific portions of large files, rather than reading entire files when not needed - You MUST combine adjacent line ranges (<10 lines apart) - You MUST use multiple ranges for content separated by >10 lines - You MUST include sufficient line context for planned modifications while keeping ranges minimal -` - : "" -} +`} ${isMultipleReadsEnabled ? `- When you need to read more than ${maxConcurrentReads} files, prioritize the most critical files first, then use subsequent read_file requests for additional files` : ""}` } diff --git a/src/core/services/__tests__/fileReadCacheService.spec.ts b/src/core/services/__tests__/fileReadCacheService.spec.ts new file mode 100644 index 0000000000..5649bf31f0 --- /dev/null +++ b/src/core/services/__tests__/fileReadCacheService.spec.ts @@ -0,0 +1,161 @@ +import { vi, describe, it, expect, beforeEach } from "vitest" +import { + processAndFilterReadRequest, + subtractRange, + subtractRanges, + ConversationMessage, + mtimeCache, +} from "../fileReadCacheService" +import { stat } from "fs/promises" +import { lruCache } from "../../utils/lruCache" +vi.mock("fs/promises", () => ({ + stat: vi.fn(), +})) +vi.mock("../../utils/lruCache") +vi.mock("../../config/envConfig", () => ({ + ROO_AGENT_CONFIG: { + fileReadCacheSize: () => 10, + }, +})) +const mockedStat = vi.mocked(stat) +describe("fileReadCacheService", () => { + describe("subtractRange", () => { + it("should return the original range if there is no overlap", () => { + const original = { start: 1, end: 10 } + const toRemove = { start: 11, end: 20 } + expect(subtractRange(original, toRemove)).toEqual([original]) + }) + it("should return an empty array if the range is completely removed", () => { + const original = { start: 1, end: 10 } + const toRemove = { start: 1, end: 10 } + expect(subtractRange(original, toRemove)).toEqual([]) + }) + it("should subtract from the beginning", () => { + const original = { start: 1, end: 10 } + const toRemove = { start: 1, end: 5 } + expect(subtractRange(original, toRemove)).toEqual([{ start: 6, end: 10 }]) + }) + it("should subtract from the end", () => { + const original = { start: 1, end: 10 } + const toRemove = { start: 6, end: 10 } + expect(subtractRange(original, toRemove)).toEqual([{ start: 1, end: 5 }]) + }) + it("should subtract from the middle, creating two new ranges", () => { + const original = { start: 1, end: 10 } + const toRemove = { start: 4, end: 6 } + expect(subtractRange(original, toRemove)).toEqual([ + { start: 1, end: 3 }, + { start: 7, end: 10 }, + ]) + }) + }) + describe("subtractRanges", () => { + it("should subtract multiple ranges from a single original range", () => { + const originals = [{ start: 1, end: 20 }] + const toRemoves = [ + { start: 1, end: 5 }, + { start: 15, end: 20 }, + ] + expect(subtractRanges(originals, toRemoves)).toEqual([{ start: 6, end: 14 }]) + }) + }) + describe("processAndFilterReadRequest", () => { + const MOCK_FILE_PATH = "/test/file.txt" + const CURRENT_MTIME = new Date("2025-01-01T12:00:00.000Z") + + beforeEach(() => { + vi.clearAllMocks() + mtimeCache.clear() + vi.useFakeTimers().setSystemTime(CURRENT_MTIME) + mockedStat.mockResolvedValue({ + mtime: CURRENT_MTIME, + size: 1024, // Add size for the new cache implementation + } as any) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + afterAll(() => { + vi.clearAllMocks() + }) + + it("should allow all when history is empty", async () => { + const requestedRanges = [{ start: 1, end: 10 }] + const result = await processAndFilterReadRequest(MOCK_FILE_PATH, requestedRanges, []) + expect(result.status).toBe("ALLOW_ALL") + expect(result.rangesToRead).toEqual(requestedRanges) + }) + + it("should reject all when a full cache hit occurs", async () => { + const requestedRanges = [{ start: 1, end: 10 }] + const conversationHistory: ConversationMessage[] = [ + { + files: [ + { + fileName: MOCK_FILE_PATH, + mtime: CURRENT_MTIME.getTime(), + lineRanges: [{ start: 1, end: 10 }], + }, + ], + } as any, + ] + const result = await processAndFilterReadRequest(MOCK_FILE_PATH, requestedRanges, conversationHistory) + expect(result.status).toBe("REJECT_ALL") + expect(result.rangesToRead).toEqual([]) + }) + + it("should allow partial when a partial cache hit occurs", async () => { + const requestedRanges = [{ start: 1, end: 20 }] + const conversationHistory: ConversationMessage[] = [ + { + files: [ + { + fileName: MOCK_FILE_PATH, + mtime: CURRENT_MTIME.getTime(), + lineRanges: [{ start: 1, end: 10 }], + }, + ], + } as any, + ] + const result = await processAndFilterReadRequest(MOCK_FILE_PATH, requestedRanges, conversationHistory) + expect(result.status).toBe("ALLOW_PARTIAL") + expect(result.rangesToRead).toEqual([{ start: 11, end: 20 }]) + }) + + it("should allow all when mtime is older in history", async () => { + const requestedRanges = [{ start: 1, end: 10 }] + const conversationHistory: ConversationMessage[] = [ + { + files: [ + { + fileName: MOCK_FILE_PATH, + mtime: CURRENT_MTIME.getTime() - 100, // Older mtime + lineRanges: [{ start: 1, end: 10 }], + }, + ], + } as any, + ] + const result = await processAndFilterReadRequest(MOCK_FILE_PATH, requestedRanges, conversationHistory) + expect(result.status).toBe("ALLOW_ALL") + expect(result.rangesToRead).toEqual(requestedRanges) + }) + + it("should allow all when file does not exist", async () => { + mockedStat.mockRejectedValue({ code: "ENOENT" }) + const requestedRanges = [{ start: 1, end: 10 }] + const result = await processAndFilterReadRequest(MOCK_FILE_PATH, requestedRanges, []) + expect(result.status).toBe("ALLOW_ALL") + expect(result.rangesToRead).toEqual(requestedRanges) + }) + + it("should throw an error for non-ENOENT stat errors", async () => { + const error = new Error("EPERM") + mockedStat.mockRejectedValue(error) + const requestedRanges = [{ start: 1, end: 10 }] + const result = await processAndFilterReadRequest(MOCK_FILE_PATH, requestedRanges, []) + expect(result.status).toBe("ALLOW_ALL") // Fallback to allow all + }) + }) +}) diff --git a/src/core/services/fileReadCacheService.ts b/src/core/services/fileReadCacheService.ts new file mode 100644 index 0000000000..dbfb159e9e --- /dev/null +++ b/src/core/services/fileReadCacheService.ts @@ -0,0 +1,309 @@ +import { stat } from "fs/promises" +import { lruCache } from "../utils/lruCache" +import { ROO_AGENT_CONFIG } from "../config/envConfig" + +const CACHE_SIZE = ROO_AGENT_CONFIG.fileReadCacheSize() +const MAX_CACHE_MEMORY_MB = 100 // Maximum memory usage in MB for file content cache +const MAX_CACHE_MEMORY_BYTES = MAX_CACHE_MEMORY_MB * 1024 * 1024 + +// Types +export interface LineRange { + start: number + end: number +} + +export interface FileMetadata { + fileName: string + mtime: number + lineRanges: LineRange[] +} + +export interface ConversationMessage { + role: "user" | "assistant" + content: any + ts: number + tool?: { + name: string + options: any + } + files?: FileMetadata[] +} + +type CacheResult = + | { status: "ALLOW_ALL"; rangesToRead: LineRange[] } + | { status: "ALLOW_PARTIAL"; rangesToRead: LineRange[] } + | { status: "REJECT_ALL"; rangesToRead: LineRange[] } + +// Cache entry with size tracking +interface CacheEntry { + mtime: string + size: number // Size in bytes +} + +// Memory-aware cache for tracking file metadata +class MemoryAwareCache { + private cache: Map = new Map() + private totalSize: number = 0 + private maxSize: number + + constructor(maxSizeBytes: number) { + this.maxSize = maxSizeBytes + } + + get(key: string): string | undefined { + const entry = this.cache.get(key) + if (!entry) return undefined + + // Move to end (most recently used) + this.cache.delete(key) + this.cache.set(key, entry) + return entry.mtime + } + + set(key: string, mtime: string, size: number): void { + // Remove existing entry if present + if (this.cache.has(key)) { + const oldEntry = this.cache.get(key)! + this.totalSize -= oldEntry.size + this.cache.delete(key) + } + + // Evict oldest entries if needed + while (this.totalSize + size > this.maxSize && this.cache.size > 0) { + const oldestKey = this.cache.keys().next().value + if (oldestKey !== undefined) { + const oldestEntry = this.cache.get(oldestKey)! + this.totalSize -= oldestEntry.size + this.cache.delete(oldestKey) + console.log(`[FileReadCache] Evicted ${oldestKey} to free ${oldestEntry.size} bytes`) + } + } + + // Add new entry + this.cache.set(key, { mtime, size }) + this.totalSize += size + } + + delete(key: string): boolean { + const entry = this.cache.get(key) + if (!entry) return false + + this.totalSize -= entry.size + this.cache.delete(key) + return true + } + + clear(): void { + this.cache.clear() + this.totalSize = 0 + } + + getStats() { + return { + entries: this.cache.size, + totalSizeBytes: this.totalSize, + totalSizeMB: (this.totalSize / 1024 / 1024).toFixed(2), + maxSizeBytes: this.maxSize, + maxSizeMB: (this.maxSize / 1024 / 1024).toFixed(2), + utilizationPercent: ((this.totalSize / this.maxSize) * 100).toFixed(1), + } + } +} + +// Initialize memory-aware cache +const memoryAwareCache = new MemoryAwareCache(MAX_CACHE_MEMORY_BYTES) + +// Export as mtimeCache for compatibility with tests and existing code +export const mtimeCache = memoryAwareCache + +/** + * Checks if two line ranges overlap. + * @param r1 - The first line range. + * @param r2 - The second line range. + * @returns True if the ranges overlap, false otherwise. + */ +function rangesOverlap(r1: LineRange, r2: LineRange): boolean { + return r1.start <= r2.end && r1.end >= r2.start +} + +/** + * Subtracts one line range from another. + * @param from - The range to subtract from. + * @param toSubtract - The range to subtract. + * @returns An array of ranges remaining after subtraction. + */ +export function subtractRange(from: LineRange, toSubtract: LineRange): LineRange[] { + // No overlap + if (from.end < toSubtract.start || from.start > toSubtract.end) { + return [from] + } + const remainingRanges: LineRange[] = [] + // Part of 'from' is before 'toSubtract' + if (from.start < toSubtract.start) { + remainingRanges.push({ start: from.start, end: toSubtract.start - 1 }) + } + // Part of 'from' is after 'toSubtract' + if (from.end > toSubtract.end) { + remainingRanges.push({ start: toSubtract.end + 1, end: from.end }) + } + return remainingRanges +} + +/** + * Subtracts a set of ranges from another set of ranges. + */ +export function subtractRanges(originals: LineRange[], toRemoves: LineRange[]): LineRange[] { + let remaining = [...originals] + + for (const toRemove of toRemoves) { + remaining = remaining.flatMap((original) => subtractRange(original, toRemove)) + } + + return remaining +} + +/** + * + * @param filePath The path to the file to get the mtime for. + * @returns The mtime of the file as an ISO string, or null if the file does not exist. + * @throws An error if there is an error getting the file stats, other than the file not existing. + */ +async function getFileMtime(filePath: string): Promise { + const cachedMtime = mtimeCache.get(filePath) + if (cachedMtime) { + try { + const stats = await stat(filePath) + if (stats.mtime.toISOString() === cachedMtime) { + return cachedMtime + } + // Update cache with new mtime and size + const mtime = stats.mtime.toISOString() + mtimeCache.set(filePath, mtime, stats.size) + return mtime + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + // File was deleted, remove from cache + mtimeCache.delete(filePath) + return null + } + // For other errors like permission issues, log and rethrow + console.error(`[FileReadCache] Error checking file ${filePath}:`, error) + throw error + } + } + try { + const stats = await stat(filePath) + const mtime = stats.mtime.toISOString() + mtimeCache.set(filePath, mtime, stats.size) + return mtime + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return null // File does not exist, so no mtime. + } + // For other errors, we want to know about them. + console.error(`[FileReadCache] Error accessing file ${filePath}:`, error) + throw error + } +} + +/** + * Processes a read request against cached file data in conversation history. + * @param requestedFilePath - The full path of the file being requested. + * @param requestedRanges - The line ranges being requested. + * @param conversationHistory - The history of conversation messages. + * @returns A CacheResult indicating whether to allow, partially allow, or reject the read. + */ +export async function processAndFilterReadRequest( + requestedFilePath: string, + requestedRanges: LineRange[], + conversationHistory: ConversationMessage[], +): Promise { + try { + // First attempt to get file mtime + let currentMtime: string | null + try { + currentMtime = await getFileMtime(requestedFilePath) + } catch (error) { + // Handle file system errors gracefully + if (error instanceof Error && "code" in error) { + const code = (error as any).code + if (code === "EACCES" || code === "EPERM") { + console.warn(`[FileReadCache] Permission denied accessing ${requestedFilePath}`) + return { status: "ALLOW_ALL", rangesToRead: requestedRanges } + } + } + throw error // Re-throw other unexpected errors + } + + if (currentMtime === null) { + // If file does not exist, there's nothing to read from cache. Let the tool handle it. + return { status: "ALLOW_ALL", rangesToRead: requestedRanges } + } + + let rangesToRead = [...requestedRanges] + + // If no specific ranges are requested, treat it as a request for the whole file. + if (rangesToRead.length === 0) { + // We need to know the number of lines to create a full range. + // This logic is simplified; in a real scenario, you'd get the line count. + // For this example, we'll assume we can't determine the full range without reading the file, + // so we proceed with ALLOW_ALL if no ranges are specified. + return { status: "ALLOW_ALL", rangesToRead: requestedRanges } + } + + for (const message of conversationHistory) { + if (!message.files?.length) continue + for (const file of message.files) { + if (file.fileName !== requestedFilePath) continue + // Normalise the mtime coming from the history because it could be + // a number (ms since epoch) or already an ISO string. + const fileMtimeMs = typeof file.mtime === "number" ? file.mtime : Date.parse(String(file.mtime)) + if (Number.isNaN(fileMtimeMs)) { + // If the mtime cannot be parsed, skip this history entry – we cannot + // rely on it for cache validation. + continue + } + // Only treat the history entry as valid if it is at least as fresh as + // the file on disk. + if (fileMtimeMs >= Date.parse(currentMtime)) { + // File in history is up-to-date. Check ranges. + for (const cachedRange of file.lineRanges) { + rangesToRead = rangesToRead.flatMap((reqRange) => { + if (rangesOverlap(reqRange, cachedRange)) { + return subtractRange(reqRange, cachedRange) + } + return [reqRange] + }) + } + } + } + } + + // Decide the cache policy based on how the requested ranges compare to the + // ranges that still need to be read after checking the conversation history. + if (rangesToRead.length === 0) { + // The entire request is already satisfied by the cache. + return { status: "REJECT_ALL", rangesToRead: [] } + } + + // A partial hit occurs when *any* of the requested ranges were served by the + // cache. Comparing only the array length is not sufficient because the number + // of ranges can stay the same even though their boundaries have changed + // (e.g. `[ {1-20} ]` -> `[ {11-20} ]`). Instead, detect partial hits by + // checking deep equality with the original request. + const isPartial = + rangesToRead.length !== requestedRanges.length || + JSON.stringify(rangesToRead) !== JSON.stringify(requestedRanges) + + if (isPartial) { + return { status: "ALLOW_PARTIAL", rangesToRead } + } + + // No overlap with cache – allow the full request through. + return { status: "ALLOW_ALL", rangesToRead: requestedRanges } + } catch (error) { + console.error(`Error processing file read request for ${requestedFilePath}:`, error) + // On other errors, allow the read to proceed to let the tool handle it. + return { status: "ALLOW_ALL", rangesToRead: requestedRanges } + } +} diff --git a/src/core/tools/__tests__/readFileTool.test.ts b/src/core/tools/__tests__/readFileTool.test.ts index 3ed5cbe3f1..8158d98fc8 100644 --- a/src/core/tools/__tests__/readFileTool.test.ts +++ b/src/core/tools/__tests__/readFileTool.test.ts @@ -1,6 +1,8 @@ // npx jest src/core/tools/__tests__/readFileTool.test.ts import * as path from "path" +import * as fs from "fs" +import * as fsp from "fs/promises" import { countFileLines } from "../../../integrations/misc/line-counter" import { readLines } from "../../../integrations/misc/read-lines" @@ -11,24 +13,11 @@ import { ReadFileToolUse, ToolParamName, ToolResponse } from "../../../shared/to import { readFileTool } from "../readFileTool" import { formatResponse } from "../../prompts/responses" -jest.mock("path", () => { - const originalPath = jest.requireActual("path") - return { - ...originalPath, - resolve: jest.fn().mockImplementation((...args) => args.join("/")), - } -}) - -jest.mock("fs/promises", () => ({ - mkdir: jest.fn().mockResolvedValue(undefined), - writeFile: jest.fn().mockResolvedValue(undefined), - readFile: jest.fn().mockResolvedValue("{}"), -})) - jest.mock("isbinaryfile") jest.mock("../../../integrations/misc/line-counter") jest.mock("../../../integrations/misc/read-lines") +jest.mock("../../services/fileReadCacheService") // Mock input content for tests let mockInputContent = "" @@ -64,14 +53,10 @@ jest.mock("../../ignore/RooIgnoreController", () => ({ }, })) -jest.mock("../../../utils/fs", () => ({ - fileExistsAtPath: jest.fn().mockReturnValue(true), -})) - describe("read_file tool with maxReadFileLine setting", () => { // Test data - const testFilePath = "test/file.txt" - const absoluteFilePath = "/test/file.txt" + const testDir = path.join(__dirname, "test_files") + const testFilePath = path.join(testDir, "file.txt") // Use path.join for OS-agnostic paths const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n" const sourceCodeDef = "\n\n# file.txt\n1--5 | Content" @@ -85,16 +70,26 @@ describe("read_file tool with maxReadFileLine setting", () => { > const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction - const mockedPathResolve = path.resolve as jest.MockedFunction + const { processAndFilterReadRequest } = require("../../services/fileReadCacheService") + const mockedProcessAndFilterReadRequest = processAndFilterReadRequest as jest.Mock const mockCline: any = {} let mockProvider: any let toolResult: ToolResponse | undefined - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks() + mockedProcessAndFilterReadRequest.mockResolvedValue({ + status: "ALLOW_ALL", + rangesToRead: [], + }) + + // Create test directory and file + if (!fs.existsSync(testDir)) { + await fsp.mkdir(testDir, { recursive: true }) + } + await fsp.writeFile(testFilePath, fileContent) - mockedPathResolve.mockReturnValue(absoluteFilePath) mockedIsBinaryFile.mockResolvedValue(false) mockInputContent = fileContent @@ -117,9 +112,10 @@ describe("read_file tool with maxReadFileLine setting", () => { deref: jest.fn().mockReturnThis(), } - mockCline.cwd = "/" + mockCline.cwd = process.cwd() // Use actual cwd for resolving test files mockCline.task = "Test" mockCline.providerRef = mockProvider + mockCline.apiConversationHistory = [] mockCline.rooIgnoreController = { validateAccess: jest.fn().mockReturnValue(true), } @@ -140,6 +136,13 @@ describe("read_file tool with maxReadFileLine setting", () => { toolResult = undefined }) + afterEach(async () => { + // Clean up test directory + if (fs.existsSync(testDir)) { + await fsp.rm(testDir, { recursive: true, force: true }) + } + }) + /** * Helper function to execute the read file tool with different maxReadFileLine settings */ @@ -165,6 +168,7 @@ describe("read_file tool with maxReadFileLine setting", () => { addLineNumbersMock.mockClear() // Format args string based on params + // Use the actual testFilePath which is now absolute let argsContent = `${options.path || testFilePath}` if (options.start_line && options.end_line) { argsContent += `${options.start_line}-${options.end_line}` @@ -380,13 +384,121 @@ describe("read_file tool with maxReadFileLine setting", () => { }) }) +describe("readFileTool with fileReadCacheService", () => { + const testDir = path.join(__dirname, "test_files_cache") + const testFilePath = path.join(testDir, "cached_file.txt") + const fileContent = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`).join("\n") + + const mockedCountFileLines = countFileLines as jest.MockedFunction + const mockedReadLines = readLines as jest.MockedFunction + const { processAndFilterReadRequest } = require("../../services/fileReadCacheService") + const mockedProcessAndFilterReadRequest = processAndFilterReadRequest as jest.Mock + + const mockCline: any = {} + let mockProvider: any + let toolResult: ToolResponse | undefined + + beforeEach(async () => { + jest.clearAllMocks() + if (!fs.existsSync(testDir)) { + await fsp.mkdir(testDir, { recursive: true }) + } + await fsp.writeFile(testFilePath, fileContent) + + mockedCountFileLines.mockResolvedValue(20) + mockedReadLines.mockImplementation(async (filePath, end, start) => { + const lines = fileContent.split("\n") + // Ensure start is a number, default to 0 if undefined + const startIndex = start ?? 0 + // Handle undefined end, which slice can take to mean 'to the end' + const endIndex = end === undefined ? undefined : end + 1 + return lines.slice(startIndex, endIndex).join("\n") + }) + + mockProvider = { + getState: jest.fn().mockResolvedValue({ maxReadFileLine: -1 }), + deref: jest.fn().mockReturnThis(), + } + + mockCline.cwd = process.cwd() + mockCline.providerRef = mockProvider + mockCline.apiConversationHistory = [] + mockCline.rooIgnoreController = { validateAccess: jest.fn().mockReturnValue(true) } + mockCline.ask = jest.fn().mockResolvedValue({ response: "yesButtonClicked" }) + mockCline.fileContextTracker = { trackFileContext: jest.fn() } + mockCline.say = jest.fn() + }) + + afterEach(async () => { + if (fs.existsSync(testDir)) { + await fsp.rm(testDir, { recursive: true, force: true }) + } + }) + + async function executeToolWithCache( + args: string, + cacheResponse: { status: string; rangesToRead: any[] }, + ): Promise { + mockedProcessAndFilterReadRequest.mockResolvedValue(cacheResponse) + + const toolUse: ReadFileToolUse = { + type: "tool_use", + name: "read_file", + params: { args }, + partial: false, + } + + let result: ToolResponse | undefined + await readFileTool( + mockCline, + toolUse, + mockCline.ask, + jest.fn(), + (res: ToolResponse) => { + result = res + }, + (_: ToolParamName, content?: string) => content ?? "", + ) + return result + } + + it('should not call readLines when cache returns "REJECT_ALL"', async () => { + const result = await executeToolWithCache(`${testFilePath}`, { + status: "REJECT_ALL", + rangesToRead: [], + }) + + expect(mockedReadLines).not.toHaveBeenCalled() + expect(result).toContain("File content is already up-to-date in the conversation history.") + }) + + it('should call readLines with filtered ranges for "ALLOW_PARTIAL"', async () => { + const rangesToRead = [{ start: 5, end: 10 }] + await executeToolWithCache(`${testFilePath}1-20`, { + status: "ALLOW_PARTIAL", + rangesToRead, + }) + + expect(mockedReadLines).toHaveBeenCalledWith(testFilePath, 9, 4) + }) + + it('should call readLines with original ranges for "ALLOW_ALL"', async () => { + await executeToolWithCache(`${testFilePath}1-15`, { + status: "ALLOW_ALL", + rangesToRead: [], // This won't be used + }) + + expect(mockedReadLines).toHaveBeenCalledWith(testFilePath, 14, 0) + }) +}) + describe("read_file tool XML output structure", () => { // Add new test data for feedback messages const _feedbackMessage = "Test feedback message" const _feedbackImages = ["image1.png", "image2.png"] // Test data - const testFilePath = "test/file.txt" - const absoluteFilePath = "/test/file.txt" + const testDir = path.join(__dirname, "test_files_xml") // Use a different directory for this describe block + const testFilePath = path.join(testDir, "file.txt") const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" const sourceCodeDef = "\n\n# file.txt\n1--5 | Content" @@ -398,17 +510,21 @@ describe("read_file tool XML output structure", () => { typeof parseSourceCodeDefinitionsForFile > const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction - const mockedPathResolve = path.resolve as jest.MockedFunction // Mock instances const mockCline: any = {} let mockProvider: any let toolResult: ToolResponse | undefined - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks() - mockedPathResolve.mockReturnValue(absoluteFilePath) + // Create test directory and file + if (!fs.existsSync(testDir)) { + await fsp.mkdir(testDir, { recursive: true }) + } + await fsp.writeFile(testFilePath, fileContent) + mockedIsBinaryFile.mockResolvedValue(false) // Set default implementation for extractTextFromFile @@ -426,7 +542,7 @@ describe("read_file tool XML output structure", () => { deref: jest.fn().mockReturnThis(), } - mockCline.cwd = "/" + mockCline.cwd = process.cwd() // Use actual cwd mockCline.task = "Test" mockCline.providerRef = mockProvider mockCline.rooIgnoreController = { @@ -448,6 +564,13 @@ describe("read_file tool XML output structure", () => { toolResult = undefined }) + afterEach(async () => { + // Clean up test directory + if (fs.existsSync(testDir)) { + await fsp.rm(testDir, { recursive: true, force: true }) + } + }) + /** * Helper function to execute the read file tool with custom parameters */ @@ -554,8 +677,10 @@ describe("read_file tool XML output structure", () => { const result = await executeReadFileTool() // Verify - expect(result).toBe( - `\n${testFilePath}\n\n${numberedContent}\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\n\\n${numberedContent}\\n{.*}\\n\\n`, + ), ) }) @@ -567,7 +692,7 @@ describe("read_file tool XML output structure", () => { // Verify using regex to check structure const xmlStructureRegex = new RegExp( - `^\\n${testFilePath}\\n\\n.*\\n\\n$`, + `^\\n${testFilePath.replace(/\\/g, "\\\\")}\\n\\n.*\\n.*\\n\\n$`, "s", ) expect(result).toMatch(xmlStructureRegex) @@ -598,8 +723,10 @@ describe("read_file tool XML output structure", () => { const result = await executeReadFileTool({}, { totalLines: 0 }) // Verify - expect(result).toBe( - `\n${testFilePath}\nFile is empty\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\nFile is empty\\n{.*}\\n\\n`, + ), ) }) }) @@ -638,8 +765,10 @@ describe("read_file tool XML output structure", () => { ) // Verify - expect(result).toBe( - `\n${testFilePath}\n\n${numberedContent}\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\n\\n${numberedContent}\\n{.*}\\n\\n`, + ), ) }) @@ -674,8 +803,10 @@ describe("read_file tool XML output structure", () => { ) // Verify - expect(result).toBe( - `\n${testFilePath}\n\n${numberedContent}\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\n\\n${numberedContent}\\n{.*}\\n\\n`, + ), ) }) @@ -763,8 +894,10 @@ describe("read_file tool XML output structure", () => { ) // Should adjust to actual file length - expect(result).toBe( - `\n${testFilePath}\n\n${numberedContent}\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\n\\n${numberedContent}\\n{.*}\\n\\n`, + ), ) // Verify @@ -978,29 +1111,27 @@ describe("read_file tool XML output structure", () => { describe("Multiple Files Tests", () => { it("should handle multiple file entries correctly", async () => { // Setup - const file1Path = "test/file1.txt" - const file2Path = "test/file2.txt" - const file1Numbered = "1 | File 1 content" - const file2Numbered = "1 | File 2 content" - - // Mock path resolution - mockedPathResolve.mockImplementation((_, filePath) => { - if (filePath === file1Path) return "/test/file1.txt" - if (filePath === file2Path) return "/test/file2.txt" - return filePath - }) + const file1Path = path.join(testDir, "file1.txt") + const file2Path = path.join(testDir, "file2.txt") + const file1Content = "File 1 content" + const file2Content = "File 2 content" + await fsp.writeFile(file1Path, file1Content) + await fsp.writeFile(file2Path, file2Content) + + const file1Numbered = `1 | ${file1Content}` + const file2Numbered = `1 | ${file2Content}` // Mock content for each file mockedCountFileLines.mockResolvedValue(1) mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) mockedExtractTextFromFile.mockImplementation((filePath) => { - if (filePath === "/test/file1.txt") { + if (filePath === file1Path) { return Promise.resolve(file1Numbered) } - if (filePath === "/test/file2.txt") { + if (filePath === file2Path) { return Promise.resolve(file2Numbered) } - throw new Error("Unexpected file path") + throw new Error(`Unexpected file path: ${filePath}`) }) // Execute @@ -1012,40 +1143,37 @@ describe("read_file tool XML output structure", () => { ) // Verify - expect(result).toBe( - `\n${file1Path}\n\n${file1Numbered}\n\n${file2Path}\n\n${file2Numbered}\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${file1Path.replace(/\\/g, "\\\\")}\\n\\n${file1Numbered}\\n{.*}\\n\\n${file2Path.replace(/\\/g, "\\\\")}\\n\\n${file2Numbered}\\n{.*}\\n\\n`, + ), ) }) it("should handle errors in multiple file entries independently", async () => { // Setup - const validPath = "test/valid.txt" - const invalidPath = "test/invalid.txt" - const numberedContent = "1 | Valid file content" - - // Mock path resolution - mockedPathResolve.mockImplementation((_, filePath) => { - if (filePath === validPath) return "/test/valid.txt" - if (filePath === invalidPath) return "/test/invalid.txt" - return filePath - }) + const validPath = path.join(testDir, "valid.txt") + const invalidPath = path.join(testDir, "invalid.txt") // This file won't be created, RooIgnore will block + const validContent = "Valid file content" + await fsp.writeFile(validPath, validContent) + const numberedContent = `1 | ${validContent}` // Mock RooIgnore to block invalid file and track validation order const validationOrder: string[] = [] mockCline.rooIgnoreController = { - validateAccess: jest.fn().mockImplementation((path) => { - validationOrder.push(`validate:${path}`) - const isValid = path !== invalidPath - if (!isValid) { - validationOrder.push(`error:${path}`) + validateAccess: jest.fn().mockImplementation((p) => { + // p is absolute path + validationOrder.push(`validate:${p}`) + const isPathValid = p === validPath + if (!isPathValid) { + validationOrder.push(`error:${p}`) } - return isValid + return isPathValid }), } // Mock say to track RooIgnore error mockCline.say = jest.fn().mockImplementation((_type, _path) => { - // Don't add error to validationOrder here since validateAccess already does it return Promise.resolve() }) @@ -1054,36 +1182,37 @@ describe("read_file tool XML output structure", () => { // Mock file operations to track operation order mockedCountFileLines.mockImplementation((filePath) => { - const relPath = filePath === "/test/valid.txt" ? validPath : invalidPath - validationOrder.push(`countLines:${relPath}`) - if (filePath.includes(validPath)) { + validationOrder.push(`countLines:${filePath}`) + if (filePath === validPath) { return Promise.resolve(1) } - throw new Error("File not found") + // This should not be reached for invalidPath due to RooIgnore + throw new Error("File not found or access denied by mock") }) mockedIsBinaryFile.mockImplementation((filePath) => { - const relPath = filePath === "/test/valid.txt" ? validPath : invalidPath - validationOrder.push(`isBinary:${relPath}`) - if (filePath.includes(validPath)) { + validationOrder.push(`isBinary:${filePath}`) + if (filePath === validPath) { return Promise.resolve(false) } - throw new Error("File not found") + // This should not be reached for invalidPath + throw new Error("File not found or access denied by mock") }) mockedExtractTextFromFile.mockImplementation((filePath) => { - if (filePath === "/test/valid.txt") { + if (filePath === validPath) { validationOrder.push(`extract:${validPath}`) return Promise.resolve(numberedContent) } - return Promise.reject(new Error("File not found")) + // This should not be reached for invalidPath + return Promise.reject(new Error("File not found or access denied by mock")) }) // Mock approval for both files mockCline.ask = jest .fn() .mockResolvedValueOnce({ response: "yesButtonClicked" }) // First file approved - .mockResolvedValueOnce({ response: "noButtonClicked" }) // Second file denied + .mockResolvedValueOnce({ response: "noButtonClicked" }) // Second file denied - this won't be hit due to RooIgnore // Execute - Skip the default validateAccess mock const { readFileTool } = require("../readFileTool") @@ -1117,44 +1246,52 @@ describe("read_file tool XML output structure", () => { expect(validationOrder).toEqual([ `validate:${validPath}`, `validate:${invalidPath}`, - `error:${invalidPath}`, - `countLines:${validPath}`, + `error:${invalidPath}`, // RooIgnore blocks invalidPath + `countLines:${validPath}`, // Operations proceed for validPath `isBinary:${validPath}`, `extract:${validPath}`, ]) // Verify result - expect(result).toBe( - `\n${validPath}\n\n${numberedContent}\n\n${invalidPath}${formatResponse.rooIgnoreError(invalidPath)}\n`, + expect(result).toMatch( + new RegExp( + `\\n${validPath.replace(/\\/g, "\\\\")}\\n\\n${numberedContent}\\n{.*}\\n\\n${invalidPath.replace(/\\/g, "\\\\")}${formatResponse.rooIgnoreError(invalidPath)}\\n`, + ), ) }) it("should handle mixed binary and text files", async () => { // Setup - const textPath = "test/text.txt" - const binaryPath = "test/binary.pdf" - const numberedContent = "1 | Text file content" - const pdfContent = "1 | PDF content extracted" + const textPath = path.join(testDir, "text.txt") + const binaryPath = path.join(testDir, "binary.pdf") + const textContent = "Text file content" + const pdfContentRaw = "PDF content extracted" // Raw content + await fsp.writeFile(textPath, textContent) + await fsp.writeFile(binaryPath, "dummy binary data") // Actual content doesn't matter for this mock setup - // Mock path.resolve to return the expected paths - mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath}`) + const numberedTextContent = addLineNumbersMock(textContent) // "1 | Text file content" + const numberedPdfContent = addLineNumbersMock(pdfContentRaw) // "1 | PDF content extracted" // Mock binary file detection - mockedIsBinaryFile.mockImplementation((path) => { - if (path.includes("text.txt")) return Promise.resolve(false) - if (path.includes("binary.pdf")) return Promise.resolve(true) + mockedIsBinaryFile.mockImplementation((p) => { + if (p === textPath) return Promise.resolve(false) + if (p === binaryPath) return Promise.resolve(true) return Promise.resolve(false) }) - mockedCountFileLines.mockImplementation((path) => { + mockedCountFileLines.mockImplementation((p) => { return Promise.resolve(1) }) - mockedExtractTextFromFile.mockImplementation((path) => { - if (path.includes("binary.pdf")) { - return Promise.resolve(pdfContent) + // Specific mock for this test to ensure correct content is numbered and returned + mockedExtractTextFromFile.mockImplementation((p) => { + if (p === binaryPath) { + return Promise.resolve(numberedPdfContent) // Use pre-calculated numbered content } - return Promise.resolve(numberedContent) + if (p === textPath) { + return Promise.resolve(numberedTextContent) // Use pre-calculated numbered content + } + throw new Error(`Unexpected path in mixed binary/text test mock: ${p}`) }) // Configure mocks for the test @@ -1188,17 +1325,25 @@ describe("read_file tool XML output structure", () => { // Check the result expect(mockPushToolResult).toHaveBeenCalledWith( - `\n${textPath}\n\n${numberedContent}\n\n${binaryPath}\n\n${pdfContent}\n\n`, + expect.stringMatching( + new RegExp( + `\\n${textPath.replace(/\\/g, "\\\\")}\\n\\n${numberedTextContent}\\n{.*}\\n\\n${binaryPath.replace(/\\/g, "\\\\")}\\n\\n${numberedPdfContent}\\n{.*}\\n\\n`, + ), + ), ) }) it("should block unsupported binary files", async () => { // Setup - const unsupportedBinaryPath = "test/binary.exe" + const unsupportedBinaryPath = path.join(testDir, "binary.exe") + await fsp.writeFile(unsupportedBinaryPath, "dummy binary data") mockedIsBinaryFile.mockImplementation(() => Promise.resolve(true)) mockedCountFileLines.mockImplementation(() => Promise.resolve(1)) mockProvider.getState.mockResolvedValue({ maxReadFileLine: -1 }) + // Ensure getSupportedBinaryFormats is mocked correctly for this test + const originalGetSupported = extractTextModule.getSupportedBinaryFormats + extractTextModule.getSupportedBinaryFormats = jest.fn(() => [".pdf", ".docx"]) // Create standalone mock functions const mockAskApproval = jest.fn().mockResolvedValue({ response: "yesButtonClicked" }) @@ -1230,12 +1375,15 @@ describe("read_file tool XML output structure", () => { expect(mockPushToolResult).toHaveBeenCalledWith( `\n${unsupportedBinaryPath}\nBinary file\n\n`, ) + // Restore original mock + extractTextModule.getSupportedBinaryFormats = originalGetSupported }) }) describe("Edge Cases Tests", () => { it("should handle empty files correctly with maxReadFileLine=-1", async () => { // Setup - use empty string + await fsp.writeFile(testFilePath, "") // Ensure file is actually empty mockInputContent = "" const maxReadFileLine = -1 const totalLines = 0 @@ -1246,13 +1394,16 @@ describe("read_file tool XML output structure", () => { const result = await executeReadFileTool({}, { maxReadFileLine, totalLines }) // Verify - expect(result).toBe( - `\n${testFilePath}\nFile is empty\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\nFile is empty\\n{.*}\\n\\n`, + ), ) }) it("should handle empty files correctly with maxReadFileLine=0", async () => { // Setup + await fsp.writeFile(testFilePath, "") // Ensure file is actually empty mockedCountFileLines.mockResolvedValue(0) mockedExtractTextFromFile.mockResolvedValue("") mockedReadLines.mockResolvedValue("") @@ -1264,48 +1415,72 @@ describe("read_file tool XML output structure", () => { const result = await executeReadFileTool({}, { totalLines: 0 }) // Verify - expect(result).toBe( - `\n${testFilePath}\nFile is empty\n\n`, + expect(result).toMatch( + new RegExp( + `\\n${testFilePath.replace(/\\/g, "\\\\")}\\nFile is empty\\n{.*}\\n\\n`, + ), ) }) it("should handle binary files with custom content correctly", async () => { // Setup + const exePath = testFilePath.replace(".txt", ".exe") + await fsp.writeFile(exePath, "dummy binary data for exe") // Create the dummy .exe file + mockedIsBinaryFile.mockResolvedValue(true) - mockedExtractTextFromFile.mockResolvedValue("") + mockedExtractTextFromFile.mockResolvedValue("") // extractTextFromFile returns empty for unsupported binary mockedReadLines.mockResolvedValue("") + // Ensure getSupportedBinaryFormats is mocked correctly for this test + const originalGetSupported = extractTextModule.getSupportedBinaryFormats + extractTextModule.getSupportedBinaryFormats = jest.fn(() => [".pdf", ".docx"]) // .exe is not supported // Execute - const result = await executeReadFileTool({}, { isBinary: true }) + const result = await executeReadFileTool( + { args: `${exePath}` }, + { isBinary: true }, + ) // Verify expect(result).toBe( - `\n${testFilePath}\nBinary file\n\n`, + `\n${exePath}\nBinary file\n\n`, ) expect(mockedReadLines).not.toHaveBeenCalled() + // Restore original mock + extractTextModule.getSupportedBinaryFormats = originalGetSupported }) it("should handle file read errors correctly", async () => { // Setup - const errorMessage = "File not found" - // For error cases, we need to override the mock to simulate a failure - mockedExtractTextFromFile.mockRejectedValue(new Error(errorMessage)) + // To test this, ensure the file does not exist when validateAccessAndExistence is called. + if (fs.existsSync(testFilePath)) { + await fsp.rm(testFilePath) // Delete the file created by beforeEach + } + + // This mock will not be reached if validateAccessAndExistence fails first due to ENOENT + mockedExtractTextFromFile.mockRejectedValue( + new Error(`ENOENT: no such file or directory, open '${testFilePath}'`), + ) // Execute const result = await executeReadFileTool({}) // Verify - expect(result).toBe( - `\n${testFilePath}Error reading file: ${errorMessage}\n`, - ) + // If file doesn't exist, validateAccessAndExistence causes an error with 'stat' + // The readFileTool catches this and formats the error. + expect(result).toContain(`Error reading file: ENOENT: no such file or directory, open '${testFilePath}'`) expect(result).not.toContain(` { // Setup const xmlContent = "Test" - mockInputContent = xmlContent - mockedExtractTextFromFile.mockResolvedValue(`1 | ${xmlContent}`) + await fsp.writeFile(testFilePath, xmlContent) // Write actual XML content + mockInputContent = xmlContent // This mock might still be used by addLineNumbersMock + // extractTextFromFile will now read the actual file content + mockedExtractTextFromFile.mockImplementation(async (filePath) => { + const actualContent = await fsp.readFile(filePath, "utf-8") + return addLineNumbersMock(actualContent) + }) // Execute const result = await executeReadFileTool() @@ -1316,7 +1491,44 @@ describe("read_file tool XML output structure", () => { it("should handle files with very long paths", async () => { // Setup - const longPath = "very/long/path/".repeat(10) + "file.txt" + const longPathDir = path.join( + testDir, + "very", + "long", + "path", + "very", + "long", + "path", + "very", + "long", + "path", + "very", + "long", + "path", + "very", + "long", + "path", + ) + const longFileName = "file.txt" + const longPath = path.join(longPathDir, longFileName) + const longFileActualContent = "content for long path file" + + await fsp.mkdir(longPathDir, { recursive: true }) + await fsp.writeFile(longPath, longFileActualContent) + + // Specific mock for this test to read actual content and number it + const originalExtractMock = mockedExtractTextFromFile.getMockImplementation() + mockedExtractTextFromFile.mockImplementation(async (p) => { + if (p === longPath) { + const actualContent = await fsp.readFile(p, "utf-8") + return addLineNumbersMock(actualContent) + } + // Fallback for other paths if any (should not happen in this specific test call) + if (originalExtractMock) { + return originalExtractMock(p) + } + throw new Error(`Unexpected path in longPath test mock: ${p}`) + }) // Execute const result = await executeReadFileTool({ @@ -1325,6 +1537,12 @@ describe("read_file tool XML output structure", () => { // Verify long path is handled correctly expect(result).toContain(`${longPath}`) + expect(result).toContain(addLineNumbersMock(longFileActualContent)) // Expect numbered actual content + + // Restore original mock if necessary (though beforeEach will reset it) + if (originalExtractMock) { + mockedExtractTextFromFile.mockImplementation(originalExtractMock) + } }) }) }) diff --git a/src/core/tools/applyDiffTool.ts b/src/core/tools/applyDiffTool.ts index 500c7a92c3..83ea99bc62 100644 --- a/src/core/tools/applyDiffTool.ts +++ b/src/core/tools/applyDiffTool.ts @@ -184,10 +184,36 @@ export async function applyDiffTool( // Get the formatted response message const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) + const stats = await fs.stat(absolutePath) + const newMtime = stats.mtime.toISOString() + + const lineRanges: { start: number; end: number }[] = [] + const diffBlocks = diffContent.split("<<<<<<< SEARCH") + for (const block of diffBlocks) { + if (!block.trim()) continue + const startLineMatch = block.match(/:start_line:(\d+)/) + if (startLineMatch) { + const startLine = parseInt(startLineMatch[1], 10) + const parts = block.split("=======") + if (parts.length > 1) { + const replacement = parts[1].split(">>>>>>> REPLACE")[0] + const lineCount = replacement.trim().split("\n").length + lineRanges.push({ start: startLine, end: startLine + lineCount - 1 }) + } + } + } + + const metadata = { + fileName: relPath, + mtime: newMtime, + lineRanges, + } + const metadataXml = `${JSON.stringify(metadata)}` + if (partFailHint) { - pushToolResult(partFailHint + message) + pushToolResult(partFailHint + message + "\n" + metadataXml) } else { - pushToolResult(message) + pushToolResult(message + "\n" + metadataXml) } await cline.diffViewProvider.reset() diff --git a/src/core/tools/readFileTool.ts b/src/core/tools/readFileTool.ts index e49ac43d7b..84440bf9bc 100644 --- a/src/core/tools/readFileTool.ts +++ b/src/core/tools/readFileTool.ts @@ -1,4 +1,5 @@ import path from "path" +import fs from "fs" import { isBinaryFile } from "isbinaryfile" import { Task } from "../task/Task" @@ -14,6 +15,7 @@ import { readLines } from "../../integrations/misc/read-lines" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter" import { parseXml } from "../../utils/xml" +import { processAndFilterReadRequest, ConversationMessage } from "../services/fileReadCacheService" export function getReadFileToolDescription(blockName: string, blockParams: any): string { // Handle both single path and multiple files via args @@ -57,7 +59,6 @@ interface FileEntry { lineRanges?: LineRange[] } -// New interface to track file processing state interface FileResult { path: string status: "approved" | "denied" | "blocked" | "error" | "pending" @@ -431,7 +432,29 @@ export async function readFileTool( const fullPath = path.resolve(cline.cwd, relPath) const { maxReadFileLine = 500 } = (await cline.providerRef.deref()?.getState()) ?? {} - // Process approved files + // INTEGRATION WITH fileReadCacheService + const cacheResult = await processAndFilterReadRequest( + fullPath, + fileResult.lineRanges ?? [], + (cline.apiConversationHistory as ConversationMessage[]) ?? [], + ) + + if (cacheResult.status === "REJECT_ALL") { + updateFileResult(relPath, { + notice: "File content is already up-to-date in the conversation history.", + xmlContent: `${relPath}File content is already up-to-date in the conversation history.`, + }) + continue // Move to the next file + } + + if (cacheResult.status === "ALLOW_PARTIAL") { + fileResult.lineRanges = cacheResult.rangesToRead + } + // For ALLOW_ALL, we proceed with the original fileResult.lineRanges + + let fileContent: string | undefined // To store content read from file + let linesRead: LineRange[] = [] // To store ranges actually read + try { const [totalLines, isBinary] = await Promise.all([countFileLines(fullPath), isBinaryFile(fullPath)]) @@ -458,17 +481,26 @@ export async function readFileTool( await readLines(fullPath, range.end - 1, range.start - 1), range.start, ) + fileContent = content // Store content for caching + linesRead.push(range) // Store the specific range read + const lineRangeAttr = ` lines="${range.start}-${range.end}"` rangeResults.push(`\n${content}`) } + const stats = await fs.promises.stat(fullPath) + const metadata = { + fileName: relPath, + mtime: stats.mtime.toISOString(), + lineRanges: linesRead, + } + const metadataXml = `${JSON.stringify(metadata)}\n` + updateFileResult(relPath, { - xmlContent: `${relPath}\n${rangeResults.join("\n")}\n`, + xmlContent: `${relPath}\n${rangeResults.join("\n")}\n${metadataXml}`, }) - continue } - // Handle definitions-only mode - if (maxReadFileLine === 0) { + else if (maxReadFileLine === 0) { try { const defResult = await parseSourceCodeDefinitionsForFile(fullPath, cline.rooIgnoreController) if (defResult) { @@ -477,6 +509,8 @@ export async function readFileTool( xmlContent: `${relPath}\n${defResult}\n${xmlInfo}`, }) } + fileContent = "" // No content to cache for definitions only + linesRead = [] // No specific lines read } catch (error) { if (error instanceof Error && error.message.startsWith("Unsupported language:")) { console.warn(`[read_file] Warning: ${error.message}`) @@ -486,12 +520,13 @@ export async function readFileTool( ) } } - continue } - // Handle files exceeding line threshold - if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { + else if (maxReadFileLine > 0 && totalLines > maxReadFileLine) { const content = addLineNumbers(await readLines(fullPath, maxReadFileLine - 1, 0)) + fileContent = content // Store content for caching + linesRead = [{ start: 1, end: maxReadFileLine }] // Store the range read + const lineRangeAttr = ` lines="1-${maxReadFileLine}"` let xmlInfo = `\n${content}\n` @@ -513,24 +548,35 @@ export async function readFileTool( ) } } - continue } - // Handle normal file read - const content = await extractTextFromFile(fullPath) - const lineRangeAttr = ` lines="1-${totalLines}"` - let xmlInfo = totalLines > 0 ? `\n${content}\n` : `` + else { + const content = await extractTextFromFile(fullPath) + fileContent = content // Store content for caching + linesRead = [{ start: 1, end: totalLines }] // Store the full range read - if (totalLines === 0) { - xmlInfo += `File is empty\n` - } + const lineRangeAttr = ` lines="1-${totalLines}"` + let xmlInfo = totalLines > 0 ? `\n${content}\n` : `` - // Track file read - await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + if (totalLines === 0) { + xmlInfo += `File is empty\n` + } - updateFileResult(relPath, { - xmlContent: `${relPath}\n${xmlInfo}`, - }) + // Track file read + await cline.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + + const stats = await fs.promises.stat(fullPath) + const metadata = { + fileName: relPath, + mtime: stats.mtime.toISOString(), + lineRanges: linesRead, + } + xmlInfo += `${JSON.stringify(metadata)}\n` + + updateFileResult(relPath, { + xmlContent: `${relPath}\n${xmlInfo}`, + }) + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) updateFileResult(relPath, { diff --git a/src/core/tools/writeToFileTool.ts b/src/core/tools/writeToFileTool.ts index 63191acb7e..a31ada26b7 100644 --- a/src/core/tools/writeToFileTool.ts +++ b/src/core/tools/writeToFileTool.ts @@ -1,4 +1,5 @@ import path from "path" +import fs from "fs/promises" import delay from "delay" import * as vscode from "vscode" @@ -221,7 +222,18 @@ export async function writeToFileTool( // Get the formatted response message const message = await cline.diffViewProvider.pushToolWriteResult(cline, cline.cwd, !fileExists) - pushToolResult(message) + const stats = await fs.stat(path.resolve(cline.cwd, relPath)) + const newMtime = stats.mtime.toISOString() + const lineCount = newContent.split("\n").length + + const metadata = { + fileName: relPath, + mtime: newMtime, + lineRanges: [{ start: 1, end: lineCount }], + } + const metadataXml = `${JSON.stringify(metadata)}` + + pushToolResult(message + "\n" + metadataXml) await cline.diffViewProvider.reset() diff --git a/src/core/utils/lruCache.ts b/src/core/utils/lruCache.ts new file mode 100644 index 0000000000..6d2e1c6226 --- /dev/null +++ b/src/core/utils/lruCache.ts @@ -0,0 +1,36 @@ +export class lruCache { + private capacity: number + private cache: Map + + constructor(capacity: number) { + this.capacity = capacity + this.cache = new Map() + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) { + return undefined + } + + const value = this.cache.get(key) as V + this.cache.delete(key) + this.cache.set(key, value) + return value + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key) + } else if (this.cache.size >= this.capacity) { + const oldestKey = this.cache.keys().next().value + if (oldestKey !== undefined) { + this.cache.delete(oldestKey) + } + } + this.cache.set(key, value) + } + + clear(): void { + this.cache.clear() + } +} diff --git a/src/esbuild.mjs b/src/esbuild.mjs index 178ff9eb07..e6983f058b 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -39,6 +39,12 @@ async function main() { fs.rmSync(distDir, { recursive: true, force: true }) } + const assetsMaterialIconsDir = path.join(srcDir, "assets", "vscode-material-icons") + if (fs.existsSync(assetsMaterialIconsDir)) { + console.log(`[${name}] Cleaning assets directory: ${assetsMaterialIconsDir}`) + fs.rmSync(assetsMaterialIconsDir, { recursive: true, force: true }) + } + /** * @type {import('esbuild').Plugin[]} */