Skip to content
Draft
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
7 changes: 1 addition & 6 deletions src/core/tools/ApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { fileExistsAtPath } from "../../utils/fs"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
import type { ToolUse } from "../../shared/tools"
Expand All @@ -33,11 +32,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> {

async execute(params: ApplyDiffParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { askApproval, handleError, pushToolResult, toolProtocol } = callbacks
let { path: relPath, diff: diffContent } = params

if (diffContent && !task.api.getModel().id.includes("claude")) {
diffContent = unescapeHtmlEntities(diffContent)
}
const { path: relPath, diff: diffContent } = params

try {
if (!relPath) {
Expand Down
10 changes: 3 additions & 7 deletions src/core/tools/ExecuteCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { Task } from "../task/Task"

import { ToolUse, ToolResponse } from "../../shared/tools"
import { formatResponse } from "../prompts/responses"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../integrations/terminal/Terminal"
Expand Down Expand Up @@ -58,8 +57,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {

task.consecutiveMistakeCount = 0

const unescapedCommand = unescapeHtmlEntities(command)
const didApprove = await askApproval("command", unescapedCommand)
const didApprove = await askApproval("command", command)

if (!didApprove) {
return
Expand All @@ -86,16 +84,14 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
.get<string[]>("commandTimeoutAllowlist", [])

// Check if command matches any prefix in the allowlist
const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) =>
unescapedCommand.startsWith(prefix.trim()),
)
const isCommandAllowlisted = commandTimeoutAllowlist.some((prefix) => command.startsWith(prefix.trim()))

// Convert seconds to milliseconds for internal use, but skip timeout if command is allowlisted
const commandExecutionTimeout = isCommandAllowlisted ? 0 : commandExecutionTimeoutSeconds * 1000

const options: ExecuteCommandOptions = {
executionId,
command: unescapedCommand,
command,
customCwd,
terminalShellIntegrationDisabled,
terminalOutputLineLimit,
Expand Down
19 changes: 4 additions & 15 deletions src/core/tools/MultiApplyDiffTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } f
import { formatResponse } from "../prompts/responses"
import { fileExistsAtPath } from "../../utils/fs"
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { parseXmlForDiff } from "../../utils/xml"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { applyDiffTool as applyDiffToolClass } from "./ApplyDiffTool"
Expand Down Expand Up @@ -202,7 +201,7 @@ Original error: ${errorMessage}`
path: legacyPath,
diff: [
{
content: legacyDiffContent, // Unescaping will be handled later like new diffs
content: legacyDiffContent,
startLine: legacyStartLineStr ? parseInt(legacyStartLineStr) : undefined,
},
],
Expand Down Expand Up @@ -320,12 +319,7 @@ Original error: ${errorMessage}`
let unified = ""
try {
const original = await fs.readFile(opResult.absolutePath!, "utf-8")
const processed = !cline.api.getModel().id.includes("claude")
? (opResult.diffItems || []).map((item) => ({
...item,
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
}))
: opResult.diffItems || []
const processed = opResult.diffItems || []

const applyRes =
(await cline.diffStrategy?.applyDiff(original, processed)) ?? ({ success: false } as any)
Expand Down Expand Up @@ -481,13 +475,8 @@ Original error: ${errorMessage}`
let successCount = 0
let formattedError = ""

// Pre-process all diff items for HTML entity unescaping if needed
const processedDiffItems = !cline.api.getModel().id.includes("claude")
? diffItems.map((item) => ({
...item,
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
}))
: diffItems
// Use diff items directly without HTML entity unescaping
const processedDiffItems = diffItems

// Apply all diffs at once with the array-based method
const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? {
Expand Down
5 changes: 0 additions & 5 deletions src/core/tools/WriteToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { fileExistsAtPath, createDirectoriesForFile } from "../../utils/fs"
import { stripLineNumbers, everyLineHasLineNumbers } from "../../integrations/misc/extract-text"
import { getReadablePath } from "../../utils/path"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } from "../diff/stats"
import type { ToolUse } from "../../shared/tools"
Expand Down Expand Up @@ -88,10 +87,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
newContent = newContent.split("\n").slice(0, -1).join("\n")
}

if (!task.api.getModel().id.includes("claude")) {
newContent = unescapeHtmlEntities(newContent)
}

const fullPath = relPath ? path.resolve(task.cwd, removeClosingTag("path", relPath)) : ""
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ vitest.mock("../../prompts/responses", () => ({
rooIgnoreError: vitest.fn((msg) => `RooIgnore Error: ${msg}`),
},
}))
vitest.mock("../../../utils/text-normalization", () => ({
unescapeHtmlEntities: vitest.fn((text) => text),
}))
vitest.mock("../../../shared/package", () => ({
Package: {
name: "roo-cline",
Expand Down
53 changes: 30 additions & 23 deletions src/core/tools/__tests__/executeCommandTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import * as vscode from "vscode"
import { Task } from "../../task/Task"
import { formatResponse } from "../../prompts/responses"
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../../shared/tools"
import { unescapeHtmlEntities } from "../../../utils/text-normalization"

// Mock dependencies
vitest.mock("execa", () => ({
Expand Down Expand Up @@ -106,36 +105,44 @@ describe("executeCommandTool", () => {
})

/**
* Tests for HTML entity unescaping in commands
* This verifies that HTML entities are properly converted to their actual characters
* Tests for HTML entity preservation in commands
* Commands should be passed to the terminal exactly as provided by the model
* HTML entities should NOT be converted to their character equivalents
*/
describe("HTML entity unescaping", () => {
it("should unescape &lt; to < character", () => {
const input = "echo &lt;test&gt;"
const expected = "echo <test>"
expect(unescapeHtmlEntities(input)).toBe(expected)
})
describe("HTML entity preservation", () => {
it("should preserve HTML entities in commands (not convert them)", async () => {
// If a model sends a command with HTML entities, they should be preserved as-is
// This ensures commands are executed exactly as the model intended
mockToolUse.params.command = "echo &lt;test&gt;"

it("should unescape &gt; to > character", () => {
const input = "echo test &gt; output.txt"
const expected = "echo test > output.txt"
expect(unescapeHtmlEntities(input)).toBe(expected)
})
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag,
toolProtocol: "xml",
})

it("should unescape &amp; to & character", () => {
const input = "echo foo &amp;&amp; echo bar"
const expected = "echo foo && echo bar"
expect(unescapeHtmlEntities(input)).toBe(expected)
// The command should be passed through unchanged - entities preserved
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo &lt;test&gt;")
})

it("should handle multiple mixed HTML entities", () => {
const input = "grep -E 'pattern' &lt;file.txt &gt;output.txt 2&gt;&amp;1"
const expected = "grep -E 'pattern' <file.txt >output.txt 2>&1"
expect(unescapeHtmlEntities(input)).toBe(expected)
it("should preserve ampersand entities in commands", async () => {
mockToolUse.params.command = "echo foo &amp;&amp; echo bar"

await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
askApproval: mockAskApproval as unknown as AskApproval,
handleError: mockHandleError as unknown as HandleError,
pushToolResult: mockPushToolResult as unknown as PushToolResult,
removeClosingTag: mockRemoveClosingTag as unknown as RemoveClosingTag,
toolProtocol: "xml",
})

// Entities should be preserved, not converted to &&
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo foo &amp;&amp; echo bar")
})
})

// Now we can run these tests
describe("Basic functionality", () => {
it("should execute a command normally", async () => {
// Setup
Expand Down
29 changes: 14 additions & 15 deletions src/core/tools/__tests__/writeToFileTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { MockedFunction } from "vitest"
import { fileExistsAtPath, createDirectoriesForFile } from "../../../utils/fs"
import { isPathOutsideWorkspace } from "../../../utils/pathUtils"
import { getReadablePath } from "../../../utils/path"
import { unescapeHtmlEntities } from "../../../utils/text-normalization"
import { everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
import { ToolUse, ToolResponse } from "../../../shared/tools"
import { writeToFileTool } from "../WriteToFileTool"
Expand Down Expand Up @@ -47,10 +46,6 @@ vi.mock("../../../utils/path", () => ({
getReadablePath: vi.fn().mockReturnValue("test/path.txt"),
}))

vi.mock("../../../utils/text-normalization", () => ({
unescapeHtmlEntities: vi.fn().mockImplementation((content) => content),
}))

vi.mock("../../../integrations/misc/extract-text", () => ({
everyLineHasLineNumbers: vi.fn().mockReturnValue(false),
stripLineNumbers: vi.fn().mockImplementation((content) => content),
Expand Down Expand Up @@ -97,7 +92,6 @@ describe("writeToFileTool", () => {
const mockedCreateDirectoriesForFile = createDirectoriesForFile as MockedFunction<typeof createDirectoriesForFile>
const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction<typeof isPathOutsideWorkspace>
const mockedGetReadablePath = getReadablePath as MockedFunction<typeof getReadablePath>
const mockedUnescapeHtmlEntities = unescapeHtmlEntities as MockedFunction<typeof unescapeHtmlEntities>
const mockedEveryLineHasLineNumbers = everyLineHasLineNumbers as MockedFunction<typeof everyLineHasLineNumbers>
const mockedStripLineNumbers = stripLineNumbers as MockedFunction<typeof stripLineNumbers>
const mockedPathResolve = path.resolve as MockedFunction<typeof path.resolve>
Expand All @@ -117,7 +111,6 @@ describe("writeToFileTool", () => {
mockedFileExistsAtPath.mockResolvedValue(false)
mockedIsPathOutsideWorkspace.mockReturnValue(false)
mockedGetReadablePath.mockReturnValue("test/path.txt")
mockedUnescapeHtmlEntities.mockImplementation((content) => content)
mockedEveryLineHasLineNumbers.mockReturnValue(false)
mockedStripLineNumbers.mockImplementation((content) => content)

Expand Down Expand Up @@ -327,20 +320,26 @@ describe("writeToFileTool", () => {
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith("", true)
})

it("unescapes HTML entities for non-Claude models", async () => {
mockCline.api.getModel.mockReturnValue({ id: "gpt-4" })
it("preserves HTML entities in content (does not convert them to characters)", async () => {
// HTML entities should be preserved as-is to allow writing actual HTML entities to files
// This is important for HTML, JSX, TSX files that need to contain entities like &apos;
const contentWithEntities = "&lt;div&gt;John&apos;s &amp; Jane&apos;s &quot;test&quot;&lt;/div&gt;"

await executeWriteFileTool({ content: "&lt;test&gt;" })
await executeWriteFileTool({ content: contentWithEntities })

expect(mockedUnescapeHtmlEntities).toHaveBeenCalledWith("&lt;test&gt;")
// The content should be passed to the diff view unchanged - entities preserved
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(contentWithEntities, true)
})

it("skips HTML unescaping for Claude models", async () => {
mockCline.api.getModel.mockReturnValue({ id: "claude-3" })
it("preserves HTML entities regardless of model type", async () => {
// Test with non-Claude model - entities should still be preserved
mockCline.api.getModel.mockReturnValue({ id: "gpt-4" })
const contentWithEntities = "&amp;&lt;&gt;&quot;&apos;"

await executeWriteFileTool({ content: "&lt;test&gt;" })
await executeWriteFileTool({ content: contentWithEntities })

expect(mockedUnescapeHtmlEntities).not.toHaveBeenCalled()
// Entities should be preserved, not converted to & < > " '
expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(contentWithEntities, true)
})

it("strips line numbers from numbered content", async () => {
Expand Down
Loading