Skip to content

Commit 065c5ab

Browse files
authored
fix: Improve error message when apply_diff structure is invalid (#2260)
Currently all errors in apply_diff diff structure are assumed to be related to merge conflicts. This is unfortunate because it is way more likely that LLM simply made an error structuring the diff. The LLM is then directed to fix escaping in the diff from which it doesn't recover. It is safe to assume that missing or extra "<<<<<<< SEARCH" or ">>>>>>> REPLACE" strings are likely bad diff structure, because while "<<<<<<<" and ">>>>>>>" are common merge conflict markers, no tools add "SEARCH" and "REPLACE" strings after them. This is likely output of the LLM itself. LLM not recovering has been observed in Claude 3.5 Sonnet and Claude 3.7 Sonnet models. To address the issue, error message now mentions merge conflict markers and their escaping only if it's clear that errors may come from this source. In the rest of cases the error message repeats the expected diff structure back to the LLM.
1 parent d2f9970 commit 065c5ab

File tree

2 files changed

+84
-8
lines changed

2 files changed

+84
-8
lines changed

src/core/diff/strategies/__tests__/multi-search-replace.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,57 @@ describe("MultiSearchReplaceDiffStrategy", () => {
2828
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
2929
})
3030

31+
it("validates multiple correct marker sequences with line numbers", () => {
32+
const diff =
33+
"<<<<<<< SEARCH\n" +
34+
":start_line:10\n" +
35+
":end_line:11\n" +
36+
"-------\n" +
37+
"content1\n" +
38+
"=======\n" +
39+
"new1\n" +
40+
">>>>>>> REPLACE\n\n" +
41+
"<<<<<<< SEARCH\n" +
42+
":start_line:10\n" +
43+
":end_line:11\n" +
44+
"-------\n" +
45+
"content2\n" +
46+
"=======\n" +
47+
"new2\n" +
48+
">>>>>>> REPLACE"
49+
expect(strategy["validateMarkerSequencing"](diff).success).toBe(true)
50+
})
51+
3152
it("detects separator before search", () => {
3253
const diff = "=======\n" + "content\n" + ">>>>>>> REPLACE"
3354
const result = strategy["validateMarkerSequencing"](diff)
3455
expect(result.success).toBe(false)
3556
expect(result.error).toContain("'=======' found in your diff content")
57+
expect(result.error).toContain("Diff block is malformed")
3658
})
3759

38-
it("detects replace before separator", () => {
60+
it("detects missing separator", () => {
3961
const diff = "<<<<<<< SEARCH\n" + "content\n" + ">>>>>>> REPLACE"
4062
const result = strategy["validateMarkerSequencing"](diff)
4163
expect(result.success).toBe(false)
4264
expect(result.error).toContain("'>>>>>>> REPLACE' found in your diff content")
65+
expect(result.error).toContain("Diff block is malformed")
66+
})
67+
68+
it("detects two separators", () => {
69+
const diff = "<<<<<<< SEARCH\n" + "content\n" + "=======\n" + "=======\n" + ">>>>>>> REPLACE"
70+
const result = strategy["validateMarkerSequencing"](diff)
71+
expect(result.success).toBe(false)
72+
expect(result.error).toContain("'=======' found in your diff content")
73+
expect(result.error).toContain("When removing merge conflict markers")
74+
})
75+
76+
it("detects replace before separator (merge conflict message)", () => {
77+
const diff = "<<<<<<< SEARCH\n" + "content\n" + ">>>>>>>"
78+
const result = strategy["validateMarkerSequencing"](diff)
79+
expect(result.success).toBe(false)
80+
expect(result.error).toContain("'>>>>>>>' found in your diff content")
81+
expect(result.error).toContain("When removing merge conflict markers")
4382
})
4483

4584
it("detects incomplete sequence", () => {

src/core/diff/strategies/multi-search-replace.ts

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,10 @@ Only use a single line of '=======' between search and replacement content, beca
162162
const SEARCH = "<<<<<<< SEARCH"
163163
const SEP = "======="
164164
const REPLACE = ">>>>>>> REPLACE"
165+
const SEARCH_PREFIX = "<<<<<<<"
166+
const REPLACE_PREFIX = ">>>>>>>"
165167

166-
const reportError = (found: string, expected: string) => ({
168+
const reportMergeConflictError = (found: string, expected: string) => ({
167169
success: false,
168170
error:
169171
`ERROR: Special marker '${found}' found in your diff content at line ${state.line}:\n` +
@@ -187,27 +189,62 @@ Only use a single line of '=======' between search and replacement content, beca
187189
`\\${REPLACE}\n`,
188190
})
189191

192+
const reportInvalidDiffError = (found: string, expected: string) => ({
193+
success: false,
194+
error:
195+
`ERROR: Diff block is malformed: marker '${found}' found in your diff content at line ${state.line}. Expected: ${expected}\n` +
196+
"\n" +
197+
"CORRECT FORMAT:\n\n" +
198+
"<<<<<<< SEARCH\n" +
199+
":start_line: (required) The line number of original content where the search block starts.\n" +
200+
":end_line: (required) The line number of original content where the search block ends.\n" +
201+
"-------\n" +
202+
"[exact content to find including whitespace]\n" +
203+
"=======\n" +
204+
"[new content to replace with]\n" +
205+
">>>>>>> REPLACE\n",
206+
})
207+
208+
const lines = diffContent.split("\n")
209+
const searchCount = lines.filter((l) => l.trim() === SEARCH).length
210+
const sepCount = lines.filter((l) => l.trim() === SEP).length
211+
const replaceCount = lines.filter((l) => l.trim() === REPLACE).length
212+
213+
const likelyBadStructure = searchCount !== replaceCount || sepCount < searchCount
214+
190215
for (const line of diffContent.split("\n")) {
191216
state.line++
192217
const marker = line.trim()
193218

194219
switch (state.current) {
195220
case State.START:
196-
if (marker === SEP) return reportError(SEP, SEARCH)
197-
if (marker === REPLACE) return reportError(REPLACE, SEARCH)
221+
if (marker === SEP)
222+
return likelyBadStructure
223+
? reportInvalidDiffError(SEP, SEARCH)
224+
: reportMergeConflictError(SEP, SEARCH)
225+
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH)
226+
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
198227
if (marker === SEARCH) state.current = State.AFTER_SEARCH
228+
else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
199229
break
200230

201231
case State.AFTER_SEARCH:
202-
if (marker === SEARCH) return reportError(SEARCH, SEP)
203-
if (marker === REPLACE) return reportError(REPLACE, SEP)
232+
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP)
233+
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH)
234+
if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP)
235+
if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH)
204236
if (marker === SEP) state.current = State.AFTER_SEPARATOR
205237
break
206238

207239
case State.AFTER_SEPARATOR:
208-
if (marker === SEARCH) return reportError(SEARCH, REPLACE)
209-
if (marker === SEP) return reportError(SEP, REPLACE)
240+
if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE)
241+
if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE)
242+
if (marker === SEP)
243+
return likelyBadStructure
244+
? reportInvalidDiffError(SEP, REPLACE)
245+
: reportMergeConflictError(SEP, REPLACE)
210246
if (marker === REPLACE) state.current = State.START
247+
else if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, REPLACE)
211248
break
212249
}
213250
}

0 commit comments

Comments
 (0)