Skip to content

Commit 270fd88

Browse files
KJ7LNWEric Wheeler
andauthored
refactor: improve readFileTool XML output format (#2340)
* fix: addLineNumbers handling of empty content Empty files should not have line numbers, but non-empty files with empty content at a specific line offset should. - If content is empty, return empty string for empty files - If content is empty but startLine > 1, return line number for empty content at that offset This ensures that the model does not think the file contains a single empty line. Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * refactor: improve readFileTool XML output format - Remove unnecessary XML indentation that could confuse the model - Separate file content from notices and errors using dedicated tags - Add line range information to content tags - Handle empty files properly with self-closing tags - Add comprehensive test coverage Fixes #2278 Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * fix: always show line numbers in read_file XML output - Always display line numbers in non-range reads - Improve XML formatting with consistent newlines for better readability Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * test: update tests to match new XML format with line numbers - Update test expectations to match the new XML format with newlines - Update tests to expect line numbers attribute in content tags - Modify test assertions to check for the correct line range values Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * fix: consistent blank line handling in addLineNumbers - Add newline to all output - Handle trailing newlines and empty lines consistently - Add test cases for blank lines: - Multiple blank lines within content - Multiple trailing blank lines - Only blank lines with offset - Trailing newlines Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * test: use actual addLineNumbers in read-file-xml tests - Modified extract-text mock to preserve actual addLineNumbers implementation - Removed mock implementation of addLineNumbers - Updated test data to account for trailing newline - Removed unnecessary mock verification Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * test: ensure actual addLineNumbers function is called in tests - Replace direct mocking of addLineNumbers with spy on actual implementation - Add verification to ensure the real function is called when appropriate - Add skipAddLineNumbersCheck option for cases where function should not be called - Update test cases to use appropriate verification options - Fix numberedFileContent to include trailing newline for consistency Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * fix: modify readLines to process data directly instead of line by line - Direct data processing provides more accurate results by preserving exact content with carriage returns - Improved performance through minimal buffering and efficient string operations - Use string indexes to find newlines while maintaining their original format - Handle all edge cases correctly with preserved line endings - Add tests for various edge cases including empty files, single lines, and different line endings Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> * test: remove unused mockInputContent variable Remove unused variable declaration to appease ellipsis-dev linter requirements. Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> --------- Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org> Co-authored-by: Eric Wheeler <roo-code@z.ewheeler.org>
1 parent 2779e8f commit 270fd88

File tree

7 files changed

+983
-98
lines changed

7 files changed

+983
-98
lines changed

src/core/__tests__/read-file-maxReadFileLine.test.ts

Lines changed: 141 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,22 @@ import { Cline } from "../Cline"
1010
// Mock dependencies
1111
jest.mock("../../integrations/misc/line-counter")
1212
jest.mock("../../integrations/misc/read-lines")
13-
jest.mock("../../integrations/misc/extract-text")
13+
jest.mock("../../integrations/misc/extract-text", () => {
14+
const actual = jest.requireActual("../../integrations/misc/extract-text")
15+
// Create a spy on the actual addLineNumbers function
16+
const addLineNumbersSpy = jest.spyOn(actual, "addLineNumbers")
17+
18+
return {
19+
...actual,
20+
// Expose the spy so tests can access it
21+
__addLineNumbersSpy: addLineNumbersSpy,
22+
extractTextFromFile: jest.fn(),
23+
}
24+
})
25+
26+
// Get a reference to the spy
27+
const addLineNumbersSpy = jest.requireMock("../../integrations/misc/extract-text").__addLineNumbersSpy
28+
1429
jest.mock("../../services/tree-sitter")
1530
jest.mock("isbinaryfile")
1631
jest.mock("../ignore/RooIgnoreController", () => ({
@@ -46,9 +61,9 @@ describe("read_file tool with maxReadFileLine setting", () => {
4661
const testFilePath = "test/file.txt"
4762
const absoluteFilePath = "/test/file.txt"
4863
const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
49-
const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5"
64+
const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5\n"
5065
const sourceCodeDef = "\n\n# file.txt\n1--5 | Content"
51-
const expectedFullFileXml = `<file>\n <path>${testFilePath}</path>\n <content>\n${numberedFileContent}\n </content>\n</file>`
66+
const expectedFullFileXml = `<file><path>${testFilePath}</path>\n<content lines="1-5">\n${numberedFileContent}</content>\n</file>`
5267

5368
// Mocked functions with correct types
5469
const mockedCountFileLines = countFileLines as jest.MockedFunction<typeof countFileLines>
@@ -58,6 +73,10 @@ describe("read_file tool with maxReadFileLine setting", () => {
5873
const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction<
5974
typeof parseSourceCodeDefinitionsForFile
6075
>
76+
77+
// Variable to control what content is used by the mock - set in beforeEach
78+
let mockInputContent = ""
79+
6180
const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction<typeof isBinaryFile>
6281
const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
6382

@@ -74,13 +93,19 @@ describe("read_file tool with maxReadFileLine setting", () => {
7493

7594
// Setup mocks for file operations
7695
mockedIsBinaryFile.mockResolvedValue(false)
77-
mockedAddLineNumbers.mockImplementation((content: string, startLine = 1) => {
78-
return content
79-
.split("\n")
80-
.map((line, i) => `${i + startLine} | ${line}`)
81-
.join("\n")
96+
97+
// Set the default content for the mock
98+
mockInputContent = fileContent
99+
100+
// Setup the extractTextFromFile mock implementation with the current mockInputContent
101+
mockedExtractTextFromFile.mockImplementation((filePath) => {
102+
const actual = jest.requireActual("../../integrations/misc/extract-text")
103+
return Promise.resolve(actual.addLineNumbers(mockInputContent))
82104
})
83105

106+
// No need to setup the extractTextFromFile mock implementation here
107+
// as it's already defined at the module level
108+
84109
// Setup mock provider
85110
mockProvider = {
86111
getState: jest.fn(),
@@ -105,16 +130,32 @@ describe("read_file tool with maxReadFileLine setting", () => {
105130
/**
106131
* Helper function to execute the read file tool with different maxReadFileLine settings
107132
*/
108-
async function executeReadFileTool(maxReadFileLine: number, totalLines = 5): Promise<string | undefined> {
133+
async function executeReadFileTool(
134+
params: Partial<ReadFileToolUse["params"]> = {},
135+
options: {
136+
maxReadFileLine?: number
137+
totalLines?: number
138+
skipAddLineNumbersCheck?: boolean // Flag to skip addLineNumbers check
139+
} = {},
140+
): Promise<string | undefined> {
109141
// Configure mocks based on test scenario
142+
const maxReadFileLine = options.maxReadFileLine ?? 500
143+
const totalLines = options.totalLines ?? 5
144+
110145
mockProvider.getState.mockResolvedValue({ maxReadFileLine })
111146
mockedCountFileLines.mockResolvedValue(totalLines)
112147

148+
// Reset the spy before each test
149+
addLineNumbersSpy.mockClear()
150+
113151
// Create a tool use object
114152
const toolUse: ReadFileToolUse = {
115153
type: "tool_use",
116154
name: "read_file",
117-
params: { path: testFilePath },
155+
params: {
156+
path: testFilePath,
157+
...params,
158+
},
118159
partial: false,
119160
}
120161

@@ -133,16 +174,23 @@ describe("read_file tool with maxReadFileLine setting", () => {
133174
(param: string, value: string) => value,
134175
)
135176

177+
// Verify addLineNumbers was called appropriately
178+
if (!options.skipAddLineNumbersCheck) {
179+
expect(addLineNumbersSpy).toHaveBeenCalled()
180+
} else {
181+
expect(addLineNumbersSpy).not.toHaveBeenCalled()
182+
}
183+
136184
return toolResult
137185
}
138186

139187
describe("when maxReadFileLine is negative", () => {
140188
it("should read the entire file using extractTextFromFile", async () => {
141-
// Setup
142-
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
189+
// Setup - use default mockInputContent
190+
mockInputContent = fileContent
143191

144192
// Execute
145-
const result = await executeReadFileTool(-1)
193+
const result = await executeReadFileTool({}, { maxReadFileLine: -1 })
146194

147195
// Verify
148196
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
@@ -157,8 +205,15 @@ describe("read_file tool with maxReadFileLine setting", () => {
157205
// Setup - for maxReadFileLine = 0, the implementation won't call readLines
158206
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
159207

160-
// Execute
161-
const result = await executeReadFileTool(0)
208+
// Execute - skip addLineNumbers check as it's not called for maxReadFileLine=0
209+
const result = await executeReadFileTool(
210+
{},
211+
{
212+
maxReadFileLine: 0,
213+
totalLines: 5,
214+
skipAddLineNumbersCheck: true,
215+
},
216+
)
162217

163218
// Verify
164219
expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
@@ -167,8 +222,15 @@ describe("read_file tool with maxReadFileLine setting", () => {
167222
absoluteFilePath,
168223
mockCline.rooIgnoreController,
169224
)
170-
expect(result).toContain("[Showing only 0 of 5 total lines")
171-
expect(result).toContain(sourceCodeDef)
225+
226+
// Verify XML structure
227+
expect(result).toContain(`<file><path>${testFilePath}</path>`)
228+
expect(result).toContain("<notice>Showing only 0 of 5 total lines")
229+
expect(result).toContain("</notice>")
230+
expect(result).toContain("<list_code_definition_names>")
231+
expect(result).toContain(sourceCodeDef.trim())
232+
expect(result).toContain("</list_code_definition_names>")
233+
expect(result).not.toContain("<content") // No content when maxReadFileLine is 0
172234
})
173235
})
174236

@@ -180,7 +242,7 @@ describe("read_file tool with maxReadFileLine setting", () => {
180242
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
181243

182244
// Execute
183-
const result = await executeReadFileTool(3)
245+
const result = await executeReadFileTool({}, { maxReadFileLine: 3 })
184246

185247
// Verify - check behavior but not specific implementation details
186248
expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
@@ -189,22 +251,32 @@ describe("read_file tool with maxReadFileLine setting", () => {
189251
absoluteFilePath,
190252
mockCline.rooIgnoreController,
191253
)
254+
255+
// Verify XML structure
256+
expect(result).toContain(`<file><path>${testFilePath}</path>`)
257+
expect(result).toContain('<content lines="1-3">')
192258
expect(result).toContain("1 | Line 1")
193259
expect(result).toContain("2 | Line 2")
194260
expect(result).toContain("3 | Line 3")
195-
expect(result).toContain("[Showing only 3 of 5 total lines")
196-
expect(result).toContain(sourceCodeDef)
261+
expect(result).toContain("</content>")
262+
expect(result).toContain("<notice>Showing only 3 of 5 total lines")
263+
expect(result).toContain("</notice>")
264+
expect(result).toContain("<list_code_definition_names>")
265+
expect(result).toContain(sourceCodeDef.trim())
266+
expect(result).toContain("</list_code_definition_names>")
267+
expect(result).toContain("<list_code_definition_names>")
268+
expect(result).toContain(sourceCodeDef.trim())
197269
})
198270
})
199271

200272
describe("when maxReadFileLine equals or exceeds file length", () => {
201273
it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => {
202274
// Setup
203275
mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine
204-
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
276+
mockInputContent = fileContent
205277

206278
// Execute
207-
const result = await executeReadFileTool(10, 5)
279+
const result = await executeReadFileTool({}, { maxReadFileLine: 10, totalLines: 5 })
208280

209281
// Verify
210282
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
@@ -214,69 +286,82 @@ describe("read_file tool with maxReadFileLine setting", () => {
214286
it("should read with extractTextFromFile when file has few lines", async () => {
215287
// Setup
216288
mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine
217-
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
289+
mockInputContent = fileContent
218290

219291
// Execute
220-
const result = await executeReadFileTool(5, 3)
292+
const result = await executeReadFileTool({}, { maxReadFileLine: 5, totalLines: 3 })
221293

222294
// Verify
223295
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
224296
expect(mockedReadLines).not.toHaveBeenCalled()
225-
expect(result).toBe(expectedFullFileXml)
297+
// Create a custom expected XML with lines="1-3" since totalLines is 3
298+
const expectedXml = `<file><path>${testFilePath}</path>\n<content lines="1-3">\n${numberedFileContent}</content>\n</file>`
299+
expect(result).toBe(expectedXml)
226300
})
227301
})
228302

229303
describe("when file is binary", () => {
230304
it("should always use extractTextFromFile regardless of maxReadFileLine", async () => {
231305
// Setup
232306
mockedIsBinaryFile.mockResolvedValue(true)
233-
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
307+
// For binary files, we're using a maxReadFileLine of 3 and totalLines is assumed to be 3
308+
mockedCountFileLines.mockResolvedValue(3)
309+
310+
// For binary files, we need a special mock implementation that doesn't use addLineNumbers
311+
// Save the original mock implementation
312+
const originalMockImplementation = mockedExtractTextFromFile.getMockImplementation()
313+
// Create a special mock implementation that doesn't call addLineNumbers
314+
mockedExtractTextFromFile.mockImplementation(() => {
315+
return Promise.resolve(numberedFileContent)
316+
})
317+
318+
// Reset the spy to clear any previous calls
319+
addLineNumbersSpy.mockClear()
320+
321+
// Execute - skip addLineNumbers check as we're directly providing the numbered content
322+
const result = await executeReadFileTool(
323+
{},
324+
{
325+
maxReadFileLine: 3,
326+
totalLines: 3,
327+
skipAddLineNumbersCheck: true,
328+
},
329+
)
234330

235-
// Execute
236-
const result = await executeReadFileTool(3)
331+
// Restore the original mock implementation after the test
332+
mockedExtractTextFromFile.mockImplementation(originalMockImplementation)
237333

238334
// Verify
239335
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
240336
expect(mockedReadLines).not.toHaveBeenCalled()
241-
expect(result).toBe(expectedFullFileXml)
337+
// Create a custom expected XML with lines="1-3" for binary files
338+
const expectedXml = `<file><path>${testFilePath}</path>\n<content lines="1-3">\n${numberedFileContent}</content>\n</file>`
339+
expect(result).toBe(expectedXml)
242340
})
243341
})
244342

245343
describe("with range parameters", () => {
246344
it("should honor start_line and end_line when provided", async () => {
247345
// Setup
248-
const rangeToolUse: ReadFileToolUse = {
249-
type: "tool_use",
250-
name: "read_file",
251-
params: {
252-
path: testFilePath,
253-
start_line: "2",
254-
end_line: "4",
255-
},
256-
partial: false,
257-
}
258-
259346
mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4")
260347

261-
// Import the tool implementation dynamically
262-
const { readFileTool } = require("../tools/readFileTool")
263-
264-
// Execute the tool
265-
let rangeResult: string | undefined
266-
await readFileTool(
267-
mockCline,
268-
rangeToolUse,
269-
mockCline.ask,
270-
jest.fn(),
271-
(result: string) => {
272-
rangeResult = result
273-
},
274-
(param: string, value: string) => value,
275-
)
348+
// Execute using executeReadFileTool with range parameters
349+
const rangeResult = await executeReadFileTool({
350+
start_line: "2",
351+
end_line: "4",
352+
})
276353

277354
// Verify
278355
expect(mockedReadLines).toHaveBeenCalledWith(absoluteFilePath, 3, 1) // end_line - 1, start_line - 1
279-
expect(mockedAddLineNumbers).toHaveBeenCalledWith(expect.any(String), 2) // start with proper line numbers
356+
expect(addLineNumbersSpy).toHaveBeenCalledWith(expect.any(String), 2) // start with proper line numbers
357+
358+
// Verify XML structure with lines attribute
359+
expect(rangeResult).toContain(`<file><path>${testFilePath}</path>`)
360+
expect(rangeResult).toContain(`<content lines="2-4">`)
361+
expect(rangeResult).toContain("2 | Line 2")
362+
expect(rangeResult).toContain("3 | Line 3")
363+
expect(rangeResult).toContain("4 | Line 4")
364+
expect(rangeResult).toContain("</content>")
280365
})
281366
})
282367
})

0 commit comments

Comments
 (0)