Skip to content

Commit 5da2a11

Browse files
committed
fix: remove HTML entity unescaping from apply_diff tools (#5633)
- Remove HTML entity unescaping logic from applyDiffTool.ts for non-Claude models - Remove HTML entity unescaping logic from multiApplyDiffTool.ts for non-Claude models - Remove unused imports of unescapeHtmlEntities from both files - Add comprehensive test coverage for HTML entity handling scenarios - Preserve HTML entities in search content to ensure exact matching with file content - Fix search failures and unintended content modifications caused by entity unescaping Fixes #5633
1 parent cdacdfd commit 5da2a11

File tree

3 files changed

+261
-13
lines changed

3 files changed

+261
-13
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { applyDiffToolLegacy } from "../applyDiffTool"
3+
import fs from "fs/promises"
4+
5+
// Mock dependencies
6+
vi.mock("fs/promises")
7+
vi.mock("../../../utils/fs", () => ({
8+
fileExistsAtPath: vi.fn().mockResolvedValue(true),
9+
}))
10+
11+
vi.mock("../../../utils/path", () => ({
12+
getReadablePath: vi.fn((cwd, relPath) => relPath),
13+
}))
14+
15+
describe("HTML Entity Handling in apply_diff Tools", () => {
16+
let mockCline: any
17+
let mockBlock: any
18+
let mockAskApproval: any
19+
let mockHandleError: any
20+
let mockPushToolResult: any
21+
let mockRemoveClosingTag: any
22+
23+
beforeEach(() => {
24+
vi.clearAllMocks()
25+
26+
// Mock file system
27+
const mockReadFile = vi.mocked(fs.readFile)
28+
mockReadFile.mockResolvedValue("// Comment with & entity\nconst value = 'test';")
29+
30+
mockCline = {
31+
cwd: "/test",
32+
api: {
33+
getModel: vi.fn().mockReturnValue({ id: "gpt-4" }), // Non-Claude model
34+
},
35+
diffStrategy: {
36+
applyDiff: vi.fn().mockResolvedValue({
37+
success: true,
38+
content: "// Comment with & entity\nconst value = 'updated';",
39+
}),
40+
getProgressStatus: vi.fn(),
41+
},
42+
diffViewProvider: {
43+
editType: "",
44+
open: vi.fn(),
45+
update: vi.fn(),
46+
scrollToFirstDiff: vi.fn(),
47+
saveChanges: vi.fn(),
48+
pushToolWriteResult: vi.fn().mockResolvedValue("File updated successfully"),
49+
reset: vi.fn(),
50+
revertChanges: vi.fn(),
51+
},
52+
fileContextTracker: {
53+
trackFileContext: vi.fn(),
54+
},
55+
rooIgnoreController: {
56+
validateAccess: vi.fn().mockReturnValue(true),
57+
},
58+
rooProtectedController: {
59+
isWriteProtected: vi.fn().mockReturnValue(false),
60+
},
61+
consecutiveMistakeCount: 0,
62+
consecutiveMistakeCountForApplyDiff: new Map(),
63+
didEditFile: false,
64+
ask: vi.fn(),
65+
say: vi.fn(),
66+
recordToolError: vi.fn(),
67+
sayAndCreateMissingParamError: vi.fn(),
68+
}
69+
70+
mockAskApproval = vi.fn().mockResolvedValue(true)
71+
mockHandleError = vi.fn()
72+
mockPushToolResult = vi.fn()
73+
mockRemoveClosingTag = vi.fn((tag, value) => value)
74+
})
75+
76+
describe("Legacy apply_diff tool", () => {
77+
it("should not unescape HTML entities in diff content for non-Claude models", async () => {
78+
const diffContent = `<<<<<<< SEARCH
79+
:start_line:1
80+
-------
81+
// Comment with &amp; entity
82+
=======
83+
// Comment with &amp; entity updated
84+
>>>>>>> REPLACE`
85+
86+
mockBlock = {
87+
params: {
88+
path: "test.js",
89+
diff: diffContent,
90+
},
91+
partial: false,
92+
}
93+
94+
await applyDiffToolLegacy(
95+
mockCline,
96+
mockBlock,
97+
mockAskApproval,
98+
mockHandleError,
99+
mockPushToolResult,
100+
mockRemoveClosingTag,
101+
)
102+
103+
// Verify that diffStrategy.applyDiff was called with the original diff content (not unescaped)
104+
expect(mockCline.diffStrategy.applyDiff).toHaveBeenCalledWith(
105+
"// Comment with &amp; entity\nconst value = 'test';",
106+
diffContent,
107+
NaN, // parseInt of undefined start_line
108+
)
109+
})
110+
111+
it("should handle files containing various HTML entities without unescaping search content", async () => {
112+
const fileContent = `<div>Hello &amp; welcome to &lt;our&gt; site!</div>
113+
<p>Don&apos;t forget to check &quot;special offers&quot;</p>`
114+
115+
const mockReadFile = vi.mocked(fs.readFile)
116+
mockReadFile.mockResolvedValue(fileContent)
117+
118+
const diffContent = `<<<<<<< SEARCH
119+
:start_line:1
120+
-------
121+
<div>Hello &amp; welcome to &lt;our&gt; site!</div>
122+
=======
123+
<div>Hello &amp; welcome to &lt;our updated&gt; site!</div>
124+
>>>>>>> REPLACE`
125+
126+
mockBlock = {
127+
params: {
128+
path: "test.html",
129+
diff: diffContent,
130+
},
131+
partial: false,
132+
}
133+
134+
await applyDiffToolLegacy(
135+
mockCline,
136+
mockBlock,
137+
mockAskApproval,
138+
mockHandleError,
139+
mockPushToolResult,
140+
mockRemoveClosingTag,
141+
)
142+
143+
// Verify the search content was not unescaped
144+
expect(mockCline.diffStrategy.applyDiff).toHaveBeenCalledWith(
145+
fileContent,
146+
expect.stringContaining("&amp; welcome to &lt;our&gt;"),
147+
NaN,
148+
)
149+
})
150+
151+
it("should preserve HTML entities in both search and replace content", async () => {
152+
const diffContent = `<<<<<<< SEARCH
153+
:start_line:1
154+
-------
155+
// Step 5 &amp; 6: Find and validate
156+
=======
157+
// Step 5 &amp; 6: Find, validate &amp; process
158+
>>>>>>> REPLACE`
159+
160+
mockBlock = {
161+
params: {
162+
path: "test.js",
163+
diff: diffContent,
164+
},
165+
partial: false,
166+
}
167+
168+
await applyDiffToolLegacy(
169+
mockCline,
170+
mockBlock,
171+
mockAskApproval,
172+
mockHandleError,
173+
mockPushToolResult,
174+
mockRemoveClosingTag,
175+
)
176+
177+
const actualDiffContent = mockCline.diffStrategy.applyDiff.mock.calls[0][1]
178+
expect(actualDiffContent).toContain("Step 5 &amp; 6: Find and validate")
179+
expect(actualDiffContent).toContain("Step 5 &amp; 6: Find, validate &amp; process")
180+
})
181+
182+
it("should handle apostrophe entities correctly", async () => {
183+
const fileContent = "// Don&apos;t modify this comment"
184+
const mockReadFile = vi.mocked(fs.readFile)
185+
mockReadFile.mockResolvedValue(fileContent)
186+
187+
const diffContent = `<<<<<<< SEARCH
188+
:start_line:1
189+
-------
190+
// Don&apos;t modify this comment
191+
=======
192+
// Don&apos;t modify this updated comment
193+
>>>>>>> REPLACE`
194+
195+
mockBlock = {
196+
params: {
197+
path: "test.js",
198+
diff: diffContent,
199+
},
200+
partial: false,
201+
}
202+
203+
await applyDiffToolLegacy(
204+
mockCline,
205+
mockBlock,
206+
mockAskApproval,
207+
mockHandleError,
208+
mockPushToolResult,
209+
mockRemoveClosingTag,
210+
)
211+
212+
// Verify apostrophe entities are preserved
213+
const actualDiffContent = mockCline.diffStrategy.applyDiff.mock.calls[0][1]
214+
expect(actualDiffContent).toContain("Don&apos;t modify this comment")
215+
expect(actualDiffContent).toContain("Don&apos;t modify this updated comment")
216+
})
217+
})
218+
219+
describe("Claude model behavior", () => {
220+
beforeEach(() => {
221+
// Set up Claude model
222+
mockCline.api.getModel.mockReturnValue({ id: "claude-3-sonnet" })
223+
})
224+
225+
it("should not unescape HTML entities for Claude models (no change in behavior)", async () => {
226+
const diffContent = `<<<<<<< SEARCH
227+
:start_line:1
228+
-------
229+
// Comment with &amp; entity
230+
=======
231+
// Comment with &amp; entity updated
232+
>>>>>>> REPLACE`
233+
234+
mockBlock = {
235+
params: {
236+
path: "test.js",
237+
diff: diffContent,
238+
},
239+
partial: false,
240+
}
241+
242+
await applyDiffToolLegacy(
243+
mockCline,
244+
mockBlock,
245+
mockAskApproval,
246+
mockHandleError,
247+
mockPushToolResult,
248+
mockRemoveClosingTag,
249+
)
250+
251+
// Verify that diffStrategy.applyDiff was called with the original diff content
252+
expect(mockCline.diffStrategy.applyDiff).toHaveBeenCalledWith(
253+
"// Comment with &amp; entity\nconst value = 'test';",
254+
diffContent,
255+
NaN,
256+
)
257+
})
258+
})
259+
})

src/core/tools/applyDiffTool.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } f
1010
import { formatResponse } from "../prompts/responses"
1111
import { fileExistsAtPath } from "../../utils/fs"
1212
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
13-
import { unescapeHtmlEntities } from "../../utils/text-normalization"
1413

1514
export async function applyDiffToolLegacy(
1615
cline: Task,
@@ -23,10 +22,6 @@ export async function applyDiffToolLegacy(
2322
const relPath: string | undefined = block.params.path
2423
let diffContent: string | undefined = block.params.diff
2524

26-
if (diffContent && !cline.api.getModel().id.includes("claude")) {
27-
diffContent = unescapeHtmlEntities(diffContent)
28-
}
29-
3025
const sharedMessageProps: ClineSayTool = {
3126
tool: "appliedDiff",
3227
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),

src/core/tools/multiApplyDiffTool.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { ToolUse, RemoveClosingTag, AskApproval, HandleError, PushToolResult } f
1010
import { formatResponse } from "../prompts/responses"
1111
import { fileExistsAtPath } from "../../utils/fs"
1212
import { RecordSource } from "../context-tracking/FileContextTrackerTypes"
13-
import { unescapeHtmlEntities } from "../../utils/text-normalization"
1413
import { parseXml } from "../../utils/xml"
1514
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
1615
import { applyDiffToolLegacy } from "./applyDiffTool"
@@ -410,13 +409,8 @@ Original error: ${errorMessage}`
410409
let successCount = 0
411410
let formattedError = ""
412411

413-
// Pre-process all diff items for HTML entity unescaping if needed
414-
const processedDiffItems = !cline.api.getModel().id.includes("claude")
415-
? diffItems.map((item) => ({
416-
...item,
417-
content: item.content ? unescapeHtmlEntities(item.content) : item.content,
418-
}))
419-
: diffItems
412+
// Use diff items as-is without HTML entity unescaping to prevent search mismatches
413+
const processedDiffItems = diffItems
420414

421415
// Apply all diffs at once with the array-based method
422416
const diffResult = (await cline.diffStrategy?.applyDiff(originalContent, processedDiffItems)) ?? {

0 commit comments

Comments
 (0)