Skip to content

Commit 593292c

Browse files
committed
test: add comprehensive tests for XML parsing with whitespace
- Add tests to verify XML parser handles whitespace correctly - Add tests for assistant message parser with whitespace in XML - Add tests for readFileTool handling XML args with whitespace - Tests confirm existing implementation already handles whitespace properly - Addresses issue #7664 where Grok/Qwen3-Coder generate XML with spaces
1 parent 195f4eb commit 593292c

File tree

3 files changed

+282
-0
lines changed

3 files changed

+282
-0
lines changed

src/core/assistant-message/__tests__/parseAssistantMessage.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,48 @@ const isEmptyTextContent = (block: AssistantMessageContent) =>
271271
expect(toolUse.partial).toBe(false)
272272
})
273273

274+
it("should handle whitespace and newlines between XML tags (Grok/Qwen format)", () => {
275+
// This is the format that Grok and Qwen3-Coder generate that was causing issues
276+
const message = `<read_file>
277+
<args>
278+
<file>
279+
<path>src/shared/infrastructure/supabase/factory.py</path>
280+
</file>
281+
</args>
282+
</read_file>`
283+
const result = parser(message).filter((block) => !isEmptyTextContent(block))
284+
285+
expect(result).toHaveLength(1)
286+
const toolUse = result[0] as ToolUse
287+
expect(toolUse.type).toBe("tool_use")
288+
expect(toolUse.name).toBe("read_file")
289+
// The args should be captured as a parameter
290+
expect(toolUse.params.args).toBeDefined()
291+
expect(toolUse.params.args).toContain("<file>")
292+
expect(toolUse.params.args).toContain("<path>src/shared/infrastructure/supabase/factory.py</path>")
293+
expect(toolUse.partial).toBe(false)
294+
})
295+
296+
it("should handle whitespace-only path values", () => {
297+
const message = `<read_file>
298+
<args>
299+
<file>
300+
<path> </path>
301+
</file>
302+
</args>
303+
</read_file>`
304+
const result = parser(message).filter((block) => !isEmptyTextContent(block))
305+
306+
expect(result).toHaveLength(1)
307+
const toolUse = result[0] as ToolUse
308+
expect(toolUse.type).toBe("tool_use")
309+
expect(toolUse.name).toBe("read_file")
310+
expect(toolUse.params.args).toBeDefined()
311+
// The whitespace-only path should be preserved in the args
312+
expect(toolUse.params.args).toContain("<path> </path>")
313+
expect(toolUse.partial).toBe(false)
314+
})
315+
274316
it("should handle multi-line parameters", () => {
275317
const message = `<write_to_file><path>file.ts</path><content>
276318
line 1

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,132 @@ describe("read_file tool XML output structure", () => {
13251325
`<files>\n<file><path>${testFilePath}</path><error>Access to ${testFilePath} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.</error></file>\n</files>`,
13261326
)
13271327
})
1328+
1329+
it("should handle XML args with whitespace and newlines (Grok/Qwen format)", async () => {
1330+
// This test reproduces the exact issue reported with Grok and Qwen3-Coder
1331+
// where XML has newlines and spaces between tags
1332+
const argsWithWhitespace = `
1333+
<file>
1334+
<path> test/file.txt </path>
1335+
</file>
1336+
`
1337+
const toolUse: ReadFileToolUse = {
1338+
type: "tool_use",
1339+
name: "read_file",
1340+
params: { args: argsWithWhitespace },
1341+
partial: false,
1342+
}
1343+
1344+
// Setup mocks
1345+
mockedCountFileLines.mockResolvedValue(5)
1346+
mockedExtractTextFromFile.mockResolvedValue("1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5")
1347+
mockProvider.getState.mockResolvedValue({
1348+
maxReadFileLine: -1,
1349+
maxImageFileSize: 20,
1350+
maxTotalImageSize: 20,
1351+
})
1352+
1353+
// Execute
1354+
await readFileTool(
1355+
mockCline,
1356+
toolUse,
1357+
mockCline.ask,
1358+
vi.fn(),
1359+
(result: ToolResponse) => {
1360+
toolResult = result
1361+
},
1362+
(param: ToolParamName, content?: string) => content ?? "",
1363+
)
1364+
1365+
// Verify the file was read successfully (path was trimmed)
1366+
expect(toolResult).toContain("<file><path>test/file.txt</path>")
1367+
expect(toolResult).toContain('<content lines="1-5">')
1368+
expect(toolResult).not.toContain("<error>")
1369+
})
1370+
1371+
it("should handle multiple files with whitespace in XML", async () => {
1372+
// Test multiple files with varying whitespace
1373+
const argsWithMultipleFiles = `
1374+
<file>
1375+
<path>
1376+
test/file1.txt
1377+
</path>
1378+
</file>
1379+
<file>
1380+
<path> test/file2.txt </path>
1381+
</file>
1382+
<file>
1383+
<path>test/file3.txt</path>
1384+
</file>
1385+
`
1386+
const toolUse: ReadFileToolUse = {
1387+
type: "tool_use",
1388+
name: "read_file",
1389+
params: { args: argsWithMultipleFiles },
1390+
partial: false,
1391+
}
1392+
1393+
// Setup mocks
1394+
mockedCountFileLines.mockResolvedValue(3)
1395+
mockedExtractTextFromFile.mockResolvedValue("1 | Content")
1396+
mockProvider.getState.mockResolvedValue({
1397+
maxReadFileLine: -1,
1398+
maxImageFileSize: 20,
1399+
maxTotalImageSize: 20,
1400+
})
1401+
1402+
// Mock path.resolve for each file
1403+
mockedPathResolve.mockImplementation((cwd, relPath) => `/${relPath.trim()}`)
1404+
1405+
// Execute
1406+
await readFileTool(
1407+
mockCline,
1408+
toolUse,
1409+
mockCline.ask,
1410+
vi.fn(),
1411+
(result: ToolResponse) => {
1412+
toolResult = result
1413+
},
1414+
(param: ToolParamName, content?: string) => content ?? "",
1415+
)
1416+
1417+
// Verify all files were processed
1418+
expect(toolResult).toContain("<file><path>test/file1.txt</path>")
1419+
expect(toolResult).toContain("<file><path>test/file2.txt</path>")
1420+
expect(toolResult).toContain("<file><path>test/file3.txt</path>")
1421+
expect(toolResult).not.toContain("<error>")
1422+
})
1423+
1424+
it("should handle empty path after trimming whitespace", async () => {
1425+
// Test case where path is only whitespace
1426+
const argsWithEmptyPath = `
1427+
<file>
1428+
<path> </path>
1429+
</file>
1430+
`
1431+
const toolUse: ReadFileToolUse = {
1432+
type: "tool_use",
1433+
name: "read_file",
1434+
params: { args: argsWithEmptyPath },
1435+
partial: false,
1436+
}
1437+
1438+
// Execute
1439+
await readFileTool(
1440+
mockCline,
1441+
toolUse,
1442+
mockCline.ask,
1443+
vi.fn(),
1444+
(result: ToolResponse) => {
1445+
toolResult = result
1446+
},
1447+
(param: ToolParamName, content?: string) => content ?? "",
1448+
)
1449+
1450+
// Verify error is returned for empty path
1451+
expect(toolResult).toContain("<error>")
1452+
expect(mockCline.sayAndCreateMissingParamError).toHaveBeenCalled()
1453+
})
13281454
})
13291455
})
13301456

src/utils/__tests__/xml.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,120 @@ describe("parseXml", () => {
114114
expect(result.root.data.nestedXml).toHaveProperty("item", "Should not parse this")
115115
})
116116
})
117+
118+
describe("whitespace handling", () => {
119+
it("should handle spaces within path tags", () => {
120+
const xml = `
121+
<args>
122+
<file>
123+
<path> ./test/file.ts </path>
124+
</file>
125+
</args>
126+
`
127+
128+
const result = parseXml(xml) as any
129+
130+
// The path should be trimmed
131+
expect(result.args.file.path).toBe("./test/file.ts")
132+
})
133+
134+
it("should handle newlines and spaces in nested tags", () => {
135+
const xml = `
136+
<args>
137+
<file>
138+
<path>
139+
src/shared/infrastructure/supabase/factory.py
140+
</path>
141+
</file>
142+
</args>
143+
`
144+
145+
const result = parseXml(xml) as any
146+
147+
// The path should be trimmed
148+
expect(result.args.file.path).toBe("src/shared/infrastructure/supabase/factory.py")
149+
})
150+
151+
it("should handle multiple files with varying whitespace", () => {
152+
const xml = `
153+
<args>
154+
<file>
155+
<path> file1.ts </path>
156+
</file>
157+
<file>
158+
<path>
159+
file2.ts
160+
</path>
161+
</file>
162+
<file>
163+
<path>file3.ts</path>
164+
</file>
165+
</args>
166+
`
167+
168+
const result = parseXml(xml) as any
169+
170+
// All paths should be trimmed
171+
expect(Array.isArray(result.args.file)).toBe(true)
172+
expect(result.args.file[0].path).toBe("file1.ts")
173+
expect(result.args.file[1].path).toBe("file2.ts")
174+
expect(result.args.file[2].path).toBe("file3.ts")
175+
})
176+
177+
it("should handle empty or whitespace-only path tags", () => {
178+
const xml = `
179+
<args>
180+
<file>
181+
<path> </path>
182+
</file>
183+
</args>
184+
`
185+
186+
const result = parseXml(xml) as any
187+
188+
// Empty string after trimming
189+
expect(result.args.file.path).toBe("")
190+
})
191+
192+
it("should handle tabs and mixed whitespace", () => {
193+
const xml = `
194+
<args>
195+
<file>
196+
<path>
197+
./path/with/tabs.ts
198+
</path>
199+
</file>
200+
</args>
201+
`
202+
203+
const result = parseXml(xml) as any
204+
205+
// Should trim tabs and newlines
206+
expect(result.args.file.path).toBe("./path/with/tabs.ts")
207+
})
208+
209+
it("should handle the exact format from Grok that was failing", () => {
210+
// This is the exact format that was causing issues with Grok
211+
const xml = `<read_file>
212+
<args>
213+
<file>
214+
<path>src/shared/infrastructure/supabase/factory.py</path>
215+
</file>
216+
</args>
217+
</read_file>`
218+
219+
// First extract just the args portion
220+
const argsMatch = xml.match(/<args>([\s\S]*?)<\/args>/)
221+
expect(argsMatch).toBeTruthy()
222+
223+
if (argsMatch) {
224+
const argsXml = `<args>${argsMatch[1]}</args>`
225+
const result = parseXml(argsXml) as any
226+
227+
expect(result.args.file.path).toBe("src/shared/infrastructure/supabase/factory.py")
228+
}
229+
})
230+
})
117231
})
118232

119233
describe("parseXmlForDiff", () => {

0 commit comments

Comments
 (0)