Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 6fff6d9

Browse files
committed
fix(apply_diff): detect malformed ------- separator and surface helpful error
Closes #12210. When a smaller model emits an apply_diff payload where the `-------` separator is missing the trailing newline, the outer regex's optional separator group quietly absorbs the run-on line into the search content. Match then fails with a confusing "63% similar (needs 100%)" error and the model loops. Add `detectMalformedSeparator` that runs against each replacement's search content right after marker unescaping. It flags first lines that start with seven or more dashes followed by non-dash, non-whitespace content on the same line. Markdown HRs and bare separators are NOT flagged. When detected, return an actionable error naming the exact problem and showing the correct shape so the model can self-correct instead of looping. Three regression tests under `malformed \`-------\` separator detection`: - exact reproduction from the issue → "Malformed separator" error surfaced; "63% similar" message no longer appears - well-formed separator still applies cleanly - search content with a long row of dashes inside the file is NOT misclassified
1 parent ad25634 commit 6fff6d9

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,4 +1204,80 @@ function sum(a, b) {
12041204
expect(result.error).toContain(":start_line:5 <-- Invalid location")
12051205
})
12061206
})
1207+
1208+
// Regression for https://github.com/RooCodeInc/Roo-Code/issues/12210.
1209+
describe("malformed `-------` separator detection", () => {
1210+
let strategy: MultiSearchReplaceDiffStrategy
1211+
1212+
beforeEach(() => {
1213+
strategy = new MultiSearchReplaceDiffStrategy(1.0, 5)
1214+
})
1215+
1216+
it("returns a 'malformed separator' error when LLM omits the newline after -------", async () => {
1217+
const originalContent =
1218+
"import { useTranslate } from '../../i18n/I18nContext';\n" +
1219+
"\n" +
1220+
"type MouseMode = 'draw' | 'erase';\n"
1221+
const diffContent =
1222+
"<<<<<<< SEARCH\n" +
1223+
":start_line:1\n" +
1224+
"-------import { useTranslate } from '../../i18n/I18nContext';\n" +
1225+
"\n" +
1226+
"type MouseMode\n" +
1227+
"=======\n" +
1228+
"import { useTranslate } from '../../i18n/I18nContext';\n" +
1229+
"import { MaskEditorProvider, useMaskEditor } from './MaskEditorContext';\n" +
1230+
"\n" +
1231+
"type MouseMode\n" +
1232+
">>>>>>> REPLACE"
1233+
1234+
const result = await strategy.applyDiff(originalContent, diffContent)
1235+
expect(result.success).toBe(false)
1236+
if (!result.success) {
1237+
const parts = result.failParts ?? []
1238+
const errors = parts
1239+
.filter((part) => part.success === false)
1240+
.map((part) => ("error" in part ? (part.error ?? "") : ""))
1241+
.concat("error" in result ? (result.error ?? "") : "")
1242+
.join(" ")
1243+
expect(errors).toContain("Malformed separator")
1244+
expect(errors).toContain("must be on its own line")
1245+
expect(errors).not.toContain("63%")
1246+
expect(errors).not.toContain("similar")
1247+
}
1248+
})
1249+
1250+
it("does not flag a well-formed -------\\n separator", async () => {
1251+
const originalContent = "function hello() {\n console.log('hello')\n}\n"
1252+
const diffContent =
1253+
"<<<<<<< SEARCH\n" +
1254+
":start_line:1\n" +
1255+
"-------\n" +
1256+
"function hello() {\n" +
1257+
"=======\n" +
1258+
"function helloWorld() {\n" +
1259+
">>>>>>> REPLACE"
1260+
1261+
const result = await strategy.applyDiff(originalContent, diffContent)
1262+
expect(result.success).toBe(true)
1263+
if (result.success) {
1264+
expect(result.content).toBe("function helloWorld() {\n console.log('hello')\n}\n")
1265+
}
1266+
})
1267+
1268+
it("does not flag a search line that legitimately contains many dashes", async () => {
1269+
const originalContent = "/* ---------- header ---------- */\nconst x = 1;\n"
1270+
const diffContent =
1271+
"<<<<<<< SEARCH\n" +
1272+
":start_line:1\n" +
1273+
"-------\n" +
1274+
"/* ---------- header ---------- */\n" +
1275+
"=======\n" +
1276+
"/* === header === */\n" +
1277+
">>>>>>> REPLACE"
1278+
1279+
const result = await strategy.applyDiff(originalContent, diffContent)
1280+
expect(result.success).toBe(true)
1281+
})
1282+
})
12071283
})

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,45 @@ import { normalizeString } from "../../../utils/text-normalization"
88

99
const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
1010

11+
/**
12+
* Detect a malformed `-------` separator in the SEARCH section content.
13+
*
14+
* If the LLM forgets the newline after the separator, the run-on line
15+
* (e.g. `-------import { ... }`) ends up as the first line of the
16+
* captured search content. The outer regex doesn't reject this — it
17+
* just falls through, and the file-content match fails with a
18+
* confusing "63% similar" error. By detecting the malformed prefix
19+
* here we can return an actionable error instead.
20+
*
21+
* Returns the offending line when malformed, or `null` otherwise.
22+
*
23+
* Regression test for https://github.com/RooCodeInc/Roo-Code/issues/12210.
24+
*/
25+
function detectMalformedSeparator(searchContent: string): string | null {
26+
if (!searchContent) {
27+
return null
28+
}
29+
const firstLine = searchContent.split(/\r?\n/, 1)[0] ?? ""
30+
const trimmed = firstLine.trim()
31+
// Must start with at least seven dashes (the separator) AND have
32+
// non-dash, non-whitespace content immediately after them on the
33+
// same line. A bare `-------` (separator on its own line that
34+
// happened to land in the search content for unrelated reasons) is
35+
// not flagged.
36+
const match = trimmed.match(/^-{7,}(.+)$/)
37+
if (!match) {
38+
return null
39+
}
40+
const tail = match[1].trim()
41+
// Allow lines that start with a literal `-` continuation (e.g.
42+
// markdown bullets that begin with extra dashes) — they wouldn't
43+
// look like the separator-then-code shape that confuses callers.
44+
if (tail === "" || /^-+$/.test(tail)) {
45+
return null
46+
}
47+
return firstLine
48+
}
49+
1150
function getSimilarity(original: string, search: string): number {
1251
// Empty searches are no longer supported
1352
if (search === "") {
@@ -321,6 +360,31 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
321360
searchContent = this.unescapeMarkers(searchContent)
322361
replaceContent = this.unescapeMarkers(replaceContent)
323362

363+
// Detect the specific malformation flagged in #12210: an
364+
// unescaped `-------` separator that lacks the trailing
365+
// newline (e.g. `-------import { ... }`). The outer regex
366+
// makes the separator group optional, so the search content
367+
// silently absorbs the run-on line and matching fails with
368+
// a confusing "63% similar" error. Surface the actual root
369+
// cause so the model can self-correct.
370+
const malformedSeparator = detectMalformedSeparator(searchContent)
371+
if (malformedSeparator) {
372+
diffResults.push({
373+
success: false,
374+
error:
375+
`Malformed separator in SEARCH section\n\n` +
376+
`Debug Info:\n` +
377+
`- The "-------" separator that follows :start_line:/:end_line: must be on its own line, followed by a newline before the search content begins.\n` +
378+
`- Got: ${JSON.stringify(malformedSeparator)}\n` +
379+
`- Expected:\n` +
380+
` :start_line:7\n` +
381+
` -------\n` +
382+
` <search content here>\n` +
383+
`- Tip: insert a newline immediately after "-------". Do not put the first line of search content on the same line as the separator.`,
384+
})
385+
continue
386+
}
387+
324388
// Strip line numbers from search and replace content if every line starts with a line number
325389
const hasAllLineNumbers =
326390
(everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) ||

0 commit comments

Comments
 (0)