Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { parseAssistantMessageV2 } from "../parseAssistantMessageV2"
import { ToolUse } from "../../../shared/tools"

describe("parseAssistantMessageV2 - Issue #7664", () => {
describe("handling malformed XML structure from LLMs", () => {
it("should handle the exact structure from juliettefournier-econ's example", () => {
// This is the exact structure that was causing issues
const message = `I'll read that file for you.

<read_file>
<args>
<file>
<path>src/shared/infrastructure/supabase/factory.py</path>
<line_range>10-20</line_range>
</file>
</args>
</read_file>`

const result = parseAssistantMessageV2(message)

// Should have text and tool use
expect(result).toHaveLength(2)
expect(result[0].type).toBe("text")
expect(result[1].type).toBe("tool_use")

const toolUse = result[1] as ToolUse
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.args).toBeDefined()
expect(toolUse.params.args).toContain("<file>")
expect(toolUse.params.args).toContain("<path>src/shared/infrastructure/supabase/factory.py</path>")
expect(toolUse.params.args).toContain("<line_range>10-20</line_range>")
})

it("should handle structure with no spaces between XML tags", () => {
const message = `<read_file><args><file><path>test.py</path><line_range>1-10</line_range></file></args></read_file>`

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.args).toBeDefined()
expect(toolUse.params.args).toContain("<path>test.py</path>")
expect(toolUse.params.args).toContain("<line_range>1-10</line_range>")
})

it("should handle structure with mixed spacing and newlines", () => {
const message = `<read_file>
<args>
<file><path> src/test.py </path>
<line_range>1-10</line_range>
</file>
</args>
</read_file>`

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.args).toBeDefined()
// The args should preserve the internal structure
expect(toolUse.params.args).toContain("<file>")
expect(toolUse.params.args).toContain("<path>")
expect(toolUse.params.args).toContain("src/test.py")
expect(toolUse.params.args).toContain("</path>")
expect(toolUse.params.args).toContain("<line_range>1-10</line_range>")
})

it("should handle empty path element", () => {
const message = `<read_file>
<args>
<file>
<path></path>
<line_range>10-20</line_range>
</file>
</args>
</read_file>`

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.args).toBeDefined()
expect(toolUse.params.args).toContain("<path></path>")
})

it("should handle self-closing path element", () => {
const message = `<read_file>
<args>
<file>
<path/>
<line_range>10-20</line_range>
</file>
</args>
</read_file>`

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.args).toBeDefined()
expect(toolUse.params.args).toContain("<path/>")
})

it("should handle multiple files with varying structures", () => {
const message = `<read_file>
<args>
<file>
<path> ./file1.ts </path>
</file>
<file>
<path>
./file2.ts
</path>
<line_range>10-20</line_range>
</file>
</args>
</read_file>`

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.args).toBeDefined()
// Check that both files are present
expect(toolUse.params.args).toContain("./file1.ts")
expect(toolUse.params.args).toContain("./file2.ts")
expect(toolUse.params.args).toContain("<line_range>10-20</line_range>")
})

it("should handle partial/incomplete tool use", () => {
const message = `<read_file>
<args>
<file>
<path>test.py</path>`
// Message ends abruptly

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("read_file")
expect(toolUse.partial).toBe(true)
expect(toolUse.params.args).toBeDefined()
expect(toolUse.params.args).toContain("<path>test.py</path>")
})
})

describe("args parameter trimming behavior", () => {
it("should trim args parameter content", () => {
const message = `<read_file>
<args>

<file>
<path>test.py</path>
</file>

</args>
</read_file>`

const result = parseAssistantMessageV2(message)

expect(result).toHaveLength(1)
const toolUse = result[0] as ToolUse
expect(toolUse.params.args).toBeDefined()
// args should be trimmed
expect(toolUse.params.args).not.toMatch(/^\s+/)
expect(toolUse.params.args).not.toMatch(/\s+$/)
expect(toolUse.params.args).toMatch(/^<file>/)
expect(toolUse.params.args).toMatch(/<\/file>$/)
})
})
})
130 changes: 130 additions & 0 deletions src/core/tools/__tests__/readFileTool-empty-path.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { vi, describe, it, expect, beforeEach } from "vitest"
import { readFileTool } from "../readFileTool"
import { Task } from "../../task/Task"
import { ReadFileToolUse } from "../../../shared/tools"

describe("readFileTool - empty path handling", () => {
let mockCline: any
let mockAskApproval: any
let mockHandleError: any
let mockPushToolResult: any
let mockRemoveClosingTag: any

beforeEach(() => {
mockCline = {
cwd: "/test",
consecutiveMistakeCount: 0,
recordToolError: vi.fn(),
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
say: vi.fn(),
rooIgnoreController: undefined,
api: {
getModel: () => ({ info: { supportsImages: false } }),
},
}

mockAskApproval = vi.fn().mockResolvedValue(true)
mockHandleError = vi.fn()
mockPushToolResult = vi.fn()
mockRemoveClosingTag = vi.fn()
})

it("should provide clear error message for empty path elements", async () => {
const toolUse: ReadFileToolUse = {
type: "tool_use",
name: "read_file",
params: {
args: `<file><path></path></file>`,
},
partial: false,
}

await readFileTool(
mockCline as unknown as Task,
toolUse,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)

// Should handle the error with a clear message
expect(mockHandleError).toHaveBeenCalledWith(
"parsing read_file args",
expect.objectContaining({
message: "All file paths are empty or missing. Please provide valid file paths in the <path> elements.",
}),
)

// Should push the error result
expect(mockPushToolResult).toHaveBeenCalledWith(
`<files><error>All file paths are empty or missing. Please provide valid file paths in the <path> elements.</error></files>`,
)
})

it("should provide clear error message for whitespace-only path elements", async () => {
const toolUse: ReadFileToolUse = {
type: "tool_use",
name: "read_file",
params: {
args: `<file><path> </path></file>`,
},
partial: false,
}

await readFileTool(
mockCline as unknown as Task,
toolUse,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)

// Should handle the error with a clear message
expect(mockHandleError).toHaveBeenCalledWith(
"parsing read_file args",
expect.objectContaining({
message: "All file paths are empty or missing. Please provide valid file paths in the <path> elements.",
}),
)

// Should push the error result
expect(mockPushToolResult).toHaveBeenCalledWith(
`<files><error>All file paths are empty or missing. Please provide valid file paths in the <path> elements.</error></files>`,
)
})

// Note: Testing the case where some paths are empty but others are valid
// would require extensive mocking of file system operations.
// The core functionality is tested by the other tests which verify
// that empty paths are properly detected and reported.

it("should handle multiple empty paths", async () => {
const toolUse: ReadFileToolUse = {
type: "tool_use",
name: "read_file",
params: {
args: `<file><path></path></file><file><path> </path></file><file><path/></file>`,
},
partial: false,
}

await readFileTool(
mockCline as unknown as Task,
toolUse,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)

// Should handle the error with a clear message
expect(mockHandleError).toHaveBeenCalledWith(
"parsing read_file args",
expect.objectContaining({
message: "All file paths are empty or missing. Please provide valid file paths in the <path> elements.",
}),
)
})
})
20 changes: 19 additions & 1 deletion src/core/tools/readFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,18 @@ export async function readFileTool(
const parsed = parseXml(argsXmlTag) as any
const files = Array.isArray(parsed.file) ? parsed.file : [parsed.file].filter(Boolean)

// Track empty paths for better error reporting
let emptyPathCount = 0
let totalFileCount = 0

for (const file of files) {
if (!file.path) continue // Skip if no path in a file entry
totalFileCount++

// Check for empty or whitespace-only paths
if (!file.path || (typeof file.path === "string" && file.path.trim() === "")) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider extracting this empty path checking logic into a small helper function like isEmptyPath(path) for better readability and reusability:

Suggested change
if (!file.path || (typeof file.path === "string" && file.path.trim() === "")) {
const isEmptyPath = (path: any): boolean => !path || (typeof path === "string" && path.trim() === "");
// Check for empty or whitespace-only paths
if (isEmptyPath(file.path)) {

emptyPathCount++
continue // Skip if no path or empty path in a file entry
}

const fileEntry: FileEntry = {
path: file.path,
Expand All @@ -153,6 +163,14 @@ export async function readFileTool(
}
fileEntries.push(fileEntry)
}

// If all paths were empty, provide a more specific error
if (emptyPathCount > 0 && fileEntries.length === 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intentional? The code tracks both emptyPathCount and totalFileCount, but the condition only checks emptyPathCount > 0. Could we simplify this to just check if fileEntries.length === 0 after processing all files? The current logic works but seems more complex than necessary.

const errorMessage = `All file paths are empty or missing. Please provide valid file paths in the <path> elements.`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new logic correctly detects empty/whitespace-only path elements and provides a clear error. Consider using the i18n translation function (e.g., t(...)) for the error message (currently hardcoded) so that user‐facing text remains localizable.

Suggested change
const errorMessage = `All file paths are empty or missing. Please provide valid file paths in the <path> elements.`
const errorMessage = t("tools:readFile.allPathsEmpty")

This comment was generated because it violated a code review rule: irule_C0ez7Rji6ANcGkkX.

await handleError("parsing read_file args", new Error(errorMessage))
pushToolResult(`<files><error>${errorMessage}</error></files>`)
return
}
} catch (error) {
const errorMessage = `Failed to parse read_file XML args: ${error instanceof Error ? error.message : String(error)}`
await handleError("parsing read_file args", new Error(errorMessage))
Expand Down
Loading
Loading