Skip to content

Commit 81692a4

Browse files
author
Eric Wheeler
committed
fix: escape section markers in apply_diff
Provide support for escaping section markers so that the model can add or remove lines like: ======= by escaping them in the search or replace string: \======= A state machine tracks apply_diff markers appear in correct sequence: SEARCH -> SEPARATOR -> REPLACE. Prevents syntax corruption from interleaved or malformed blocks by validating before processing matches. If a model tries to interleave diff markers, then the state machine will return a response to the model like this so it can correct. testing shows that this works on Claude 3.5, 3.7 and gemini-2.0-flash-thinking: ```xml <error_details> ERROR: Special marker '=======' found in your diff content at line 7: When removing merge conflict markers like '=======' from files, you MUST escape them in your SEARCH section by prepending a backslash (\) at the beginning of the line: CORRECT FORMAT: <<<<<<< SEARCH content before \======= <-- Note the backslash here in this example content after ======= replacement content >>>>>>> REPLACE Without escaping, the system confuses your content with diff syntax markers. You may use multiple diff blocks in a single diff request, but ANY of ONLY the following separators that occur within SEARCH or REPLACE content must be must be escaped, as follows: \<<<<<<< SEARCH \======= \>>>>>>> REPLACE </error_details> ``` Fixes: RooCodeInc#1557 Fixes: RooCodeInc#1408 Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>
1 parent def4018 commit 81692a4

1 file changed

Lines changed: 94 additions & 1 deletion

File tree

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

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ Diff format:
7373
7474
\`\`\`
7575
76+
7677
Example:
7778
7879
Original file:
@@ -128,6 +129,7 @@ def calculate_sum(items):
128129
>>>>>>> REPLACE
129130
\`\`\`
130131
132+
131133
Usage:
132134
<apply_diff>
133135
<path>File path here</path>
@@ -139,15 +141,102 @@ Only use a single line of '=======' between search and replacement content, beca
139141
</apply_diff>`
140142
}
141143

144+
private unescapeMarkers(content: string): string {
145+
return content
146+
.replace(/^\\<<<<<<< SEARCH/gm, "<<<<<<< SEARCH")
147+
.replace(/^\\=======/gm, "=======")
148+
.replace(/^\\>>>>>>> REPLACE/gm, ">>>>>>> REPLACE")
149+
.replace(/^\\-------/gm, "-------")
150+
.replace(/^\\:end_line:/gm, ":end_line:")
151+
.replace(/^\\:start_line:/gm, ":start_line:")
152+
}
153+
154+
private validateMarkerSequencing(diffContent: string): { success: boolean; error?: string } {
155+
enum State {
156+
START,
157+
AFTER_SEARCH,
158+
AFTER_SEPARATOR,
159+
}
160+
const state = { current: State.START, line: 0 }
161+
162+
const SEARCH = "<<<<<<< SEARCH"
163+
const SEP = "======="
164+
const REPLACE = ">>>>>>> REPLACE"
165+
166+
const reportError = (found: string, expected: string) => ({
167+
success: false,
168+
error:
169+
`ERROR: Special marker '${found}' found in your diff content at line ${state.line}:\n` +
170+
"\n" +
171+
`When removing merge conflict markers like '${found}' from files, you MUST escape them\n` +
172+
"in your SEARCH section by prepending a backslash (\\) at the beginning of the line:\n" +
173+
"\n" +
174+
"CORRECT FORMAT:\n\n" +
175+
"<<<<<<< SEARCH\n" +
176+
"content before\n" +
177+
`\\${found} <-- Note the backslash here in this example\n` +
178+
"content after\n" +
179+
"=======\n" +
180+
"replacement content\n" +
181+
">>>>>>> REPLACE\n" +
182+
"\n" +
183+
"Without escaping, the system confuses your content with diff syntax markers.\n" +
184+
"You may use multiple diff blocks in a single diff request, but ANY of ONLY the following separators that occur within SEARCH or REPLACE content must be escaped, as follows:\n" +
185+
`\\${SEARCH}\n` +
186+
`\\${SEP}\n` +
187+
`\\${REPLACE}\n`,
188+
})
189+
190+
for (const line of diffContent.split("\n")) {
191+
state.line++
192+
const marker = line.trim()
193+
194+
switch (state.current) {
195+
case State.START:
196+
if (marker === SEP) return reportError(SEP, SEARCH)
197+
if (marker === REPLACE) return reportError(REPLACE, SEARCH)
198+
if (marker === SEARCH) state.current = State.AFTER_SEARCH
199+
break
200+
201+
case State.AFTER_SEARCH:
202+
if (marker === SEARCH) return reportError(SEARCH, SEP)
203+
if (marker === REPLACE) return reportError(REPLACE, SEP)
204+
if (marker === SEP) state.current = State.AFTER_SEPARATOR
205+
break
206+
207+
case State.AFTER_SEPARATOR:
208+
if (marker === SEARCH) return reportError(SEARCH, REPLACE)
209+
if (marker === SEP) return reportError(SEP, REPLACE)
210+
if (marker === REPLACE) state.current = State.START
211+
break
212+
}
213+
}
214+
215+
return state.current === State.START
216+
? { success: true }
217+
: {
218+
success: false,
219+
error: `ERROR: Unexpected end of sequence: Expected '${state.current === State.AFTER_SEARCH ? SEP : REPLACE}' was not found.`,
220+
}
221+
}
222+
142223
async applyDiff(
143224
originalContent: string,
144225
diffContent: string,
145226
_paramStartLine?: number,
146227
_paramEndLine?: number,
147228
): Promise<DiffResult> {
229+
const validseq = this.validateMarkerSequencing(diffContent)
230+
if (!validseq.success) {
231+
return {
232+
success: false,
233+
error: validseq.error!,
234+
}
235+
}
236+
148237
let matches = [
149238
...diffContent.matchAll(
150-
/<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}(-------\n){0,1}([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/g,
239+
/(?<!\\)<<<<<<< SEARCH\n(:start_line:\s*(\d+)\n){0,1}(:end_line:\s*(\d+)\n){0,1}((?<!\\)-------\n){0,1}([\s\S]*?)\n?(?<!\\)=======\n([\s\S]*?)\n?(?<!\\)>>>>>>> REPLACE/g,
151240
),
152241
]
153242

@@ -176,6 +265,10 @@ Only use a single line of '=======' between search and replacement content, beca
176265
startLine += startLine === 0 ? 0 : delta
177266
endLine += delta
178267

268+
// First unescape any escaped markers in the content
269+
searchContent = this.unescapeMarkers(searchContent)
270+
replaceContent = this.unescapeMarkers(replaceContent)
271+
179272
// Strip line numbers from search and replace content if every line starts with a line number
180273
if (everyLineHasLineNumbers(searchContent) && everyLineHasLineNumbers(replaceContent)) {
181274
searchContent = stripLineNumbers(searchContent)

0 commit comments

Comments
 (0)