Skip to content

Commit b8814d3

Browse files
committed
fix: improve XML parsing error handling for read_file tool
- Add proper trimming of path values to handle whitespace - Provide clearer error messages when path is empty or missing - Track invalid entries for better error reporting - Add tests to confirm line_range is optional - Add tests for malformed XML scenarios - Fixes issue #7664 where Grok/Qwen3-Coder XML with whitespace caused errors
1 parent 593292c commit b8814d3

File tree

2 files changed

+145
-6
lines changed

2 files changed

+145
-6
lines changed

src/core/tools/__tests__/readFileTool.spec.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1435,12 +1435,15 @@ describe("read_file tool XML output structure", () => {
14351435
partial: false,
14361436
}
14371437

1438+
// Create a spy for handleError
1439+
const handleErrorSpy = vi.fn()
1440+
14381441
// Execute
14391442
await readFileTool(
14401443
mockCline,
14411444
toolUse,
14421445
mockCline.ask,
1443-
vi.fn(),
1446+
handleErrorSpy,
14441447
(result: ToolResponse) => {
14451448
toolResult = result
14461449
},
@@ -1449,7 +1452,122 @@ describe("read_file tool XML output structure", () => {
14491452

14501453
// Verify error is returned for empty path
14511454
expect(toolResult).toContain("<error>")
1452-
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalled()
1455+
expect(toolResult).toContain("No valid file paths found")
1456+
expect(toolResult).toContain("Ensure each <file> element contains a non-empty <path> element")
1457+
expect(handleErrorSpy).toHaveBeenCalled()
1458+
})
1459+
1460+
it("should work without line_range parameter (line_range is optional)", async () => {
1461+
// Test that line_range is truly optional
1462+
const argsWithoutLineRange = `
1463+
<file>
1464+
<path>test/file.txt</path>
1465+
</file>
1466+
`
1467+
const toolUse: ReadFileToolUse = {
1468+
type: "tool_use",
1469+
name: "read_file",
1470+
params: { args: argsWithoutLineRange },
1471+
partial: false,
1472+
}
1473+
1474+
// Setup mocks
1475+
mockedCountFileLines.mockResolvedValue(5)
1476+
mockedExtractTextFromFile.mockResolvedValue("1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5")
1477+
mockProvider.getState.mockResolvedValue({
1478+
maxReadFileLine: -1,
1479+
maxImageFileSize: 20,
1480+
maxTotalImageSize: 20,
1481+
})
1482+
1483+
// Execute
1484+
await readFileTool(
1485+
mockCline,
1486+
toolUse,
1487+
mockCline.ask,
1488+
vi.fn(),
1489+
(result: ToolResponse) => {
1490+
toolResult = result
1491+
},
1492+
(param: ToolParamName, content?: string) => content ?? "",
1493+
)
1494+
1495+
// Verify the file was read successfully without line_range
1496+
expect(toolResult).toContain("<file><path>test/file.txt</path>")
1497+
expect(toolResult).toContain('<content lines="1-5">')
1498+
expect(toolResult).not.toContain("<error>")
1499+
expect(toolResult).not.toContain("line_range")
1500+
})
1501+
1502+
it("should provide helpful error for malformed XML missing path element", async () => {
1503+
// Test case simulating what Grok might send - file element with missing path
1504+
const malformedArgs = `
1505+
<file>
1506+
<path></path>
1507+
</file>
1508+
`
1509+
const toolUse: ReadFileToolUse = {
1510+
type: "tool_use",
1511+
name: "read_file",
1512+
params: { args: malformedArgs },
1513+
partial: false,
1514+
}
1515+
1516+
// Create a spy for handleError
1517+
const handleErrorSpy = vi.fn()
1518+
1519+
// Execute
1520+
await readFileTool(
1521+
mockCline,
1522+
toolUse,
1523+
mockCline.ask,
1524+
handleErrorSpy,
1525+
(result: ToolResponse) => {
1526+
toolResult = result
1527+
},
1528+
(param: ToolParamName, content?: string) => content ?? "",
1529+
)
1530+
1531+
// Verify helpful error is returned
1532+
expect(toolResult).toContain("<error>")
1533+
expect(toolResult).toContain("No valid file paths found")
1534+
expect(toolResult).toContain("Ensure each <file> element contains a non-empty <path> element")
1535+
expect(handleErrorSpy).toHaveBeenCalled()
1536+
})
1537+
1538+
it("should provide error when file element has no path child at all", async () => {
1539+
// Test case where file element exists but has no path child element
1540+
const malformedArgs = `
1541+
<file>
1542+
</file>
1543+
`
1544+
const toolUse: ReadFileToolUse = {
1545+
type: "tool_use",
1546+
name: "read_file",
1547+
params: { args: malformedArgs },
1548+
partial: false,
1549+
}
1550+
1551+
// Execute
1552+
await readFileTool(
1553+
mockCline,
1554+
toolUse,
1555+
mockCline.ask,
1556+
vi.fn(),
1557+
(result: ToolResponse) => {
1558+
toolResult = result
1559+
},
1560+
(param: ToolParamName, content?: string) => content ?? "",
1561+
)
1562+
1563+
// When file element has no path child, it falls through to the general error
1564+
expect(toolResult).toContain("<error>")
1565+
expect(toolResult).toContain("Missing required parameter")
1566+
// This is handled by sayAndCreateMissingParamError
1567+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalledWith(
1568+
"read_file",
1569+
"args with valid file paths. Expected format: <args><file><path>filepath</path></file></args>",
1570+
)
14531571
})
14541572
})
14551573
})

src/core/tools/readFileTool.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,21 @@ export async function readFileTool(
131131
const parsed = parseXml(argsXmlTag) as any
132132
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)
133133

134+
// Track invalid entries for better error reporting
135+
const invalidEntries: string[] = []
136+
134137
for (const file of files) {
135-
if (!file.path) continue // Skip if no path in a file entry
138+
// Check if path exists and is not empty after trimming
139+
const filePath = typeof file.path === "string" ? file.path.trim() : file.path
140+
141+
if (!filePath) {
142+
// Track this invalid entry
143+
invalidEntries.push(JSON.stringify(file).substring(0, 100))
144+
continue // Skip if no path in a file entry
145+
}
136146

137147
const fileEntry: FileEntry = {
138-
path: file.path,
148+
path: filePath,
139149
lineRanges: [],
140150
}
141151

@@ -153,8 +163,16 @@ export async function readFileTool(
153163
}
154164
fileEntries.push(fileEntry)
155165
}
166+
167+
// If we had invalid entries but no valid ones, provide a helpful error
168+
if (fileEntries.length === 0 && invalidEntries.length > 0) {
169+
const errorMessage = `No valid file paths found in read_file args. Invalid entries: ${invalidEntries.join(", ")}. Ensure each <file> element contains a non-empty <path> element.`
170+
await handleError("parsing read_file args", new Error(errorMessage))
171+
pushToolResult(`<files><error>${errorMessage}</error></files>`)
172+
return
173+
}
156174
} catch (error) {
157-
const errorMessage = `Failed to parse read_file XML args: ${error instanceof Error ? error.message : String(error)}`
175+
const errorMessage = `Failed to parse read_file XML args: ${error instanceof Error ? error.message : String(error)}. Expected format: <file><path>filepath</path></file>`
158176
await handleError("parsing read_file args", new Error(errorMessage))
159177
pushToolResult(`<files><error>${errorMessage}</error></files>`)
160178
return
@@ -186,7 +204,10 @@ export async function readFileTool(
186204
if (fileEntries.length === 0) {
187205
cline.consecutiveMistakeCount++
188206
cline.recordToolError("read_file")
189-
const errorMsg = await cline.sayAndCreateMissingParamError("read_file", "args (containing valid file paths)")
207+
const errorMsg = await cline.sayAndCreateMissingParamError(
208+
"read_file",
209+
"args with valid file paths. Expected format: <args><file><path>filepath</path></file></args>",
210+
)
190211
pushToolResult(`<files><error>${errorMsg}</error></files>`)
191212
return
192213
}

0 commit comments

Comments
 (0)