diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts index 23900fc142d..b25286f5fa3 100644 --- a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts +++ b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts @@ -13,6 +13,51 @@ describe("MultiSearchReplaceDiffStrategy", () => { expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) }) + it("validates correct marker sequence with extra > in SEARCH", () => { + const diff = "<<<<<<< SEARCH>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates correct marker sequence with multiple > in SEARCH", () => { + const diff = "<<<<<<< SEARCH>>\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(false) + }) + + it("validates mixed cases with and without extra > in the same diff", () => { + const diff = + "<<<<<<< SEARCH>\n" + + "content1\n" + + "=======\n" + + "new1\n" + + ">>>>>>> REPLACE\n\n" + + "<<<<<<< SEARCH\n" + + "content2\n" + + "=======\n" + + "new2\n" + + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + + it("validates extra > with whitespace variations", () => { + const diff1 = "<<<<<<< SEARCH> \n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff1).success).toBe(true) + + const diff2 = "<<<<<<< SEARCH >\n" + "some content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff2).success).toBe(false) + }) + + it("validates extra > with line numbers", () => { + const diff = + "<<<<<<< SEARCH>\n" + + ":start_line:10\n" + + "-------\n" + + "content1\n" + + "=======\n" + + "new1\n" + + ">>>>>>> REPLACE" + expect(strategy["validateMarkerSequencing"](diff).success).toBe(true) + }) + it("validates multiple correct marker sequences", () => { const diff = "<<<<<<< SEARCH\n" + diff --git a/src/core/diff/strategies/multi-file-search-replace.ts b/src/core/diff/strategies/multi-file-search-replace.ts index 5ec223477cf..c71d3c3807d 100644 --- a/src/core/diff/strategies/multi-file-search-replace.ts +++ b/src/core/diff/strategies/multi-file-search-replace.ts @@ -259,7 +259,10 @@ Each file requires its own path, start_line, and diff elements. const state = { current: State.START, line: 0 } - const SEARCH = "<<<<<<< SEARCH" + // Pattern allows optional '>' after SEARCH to handle AI-generated diffs + // (e.g., Sonnet 4 sometimes adds an extra '>') + const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/ + const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display const SEP = "=======" const REPLACE = ">>>>>>> REPLACE" const SEARCH_PREFIX = "<<<<<<< " @@ -329,7 +332,7 @@ Each file requires its own path, start_line, and diff elements. }) const lines = diffContent.split("\n") - const searchCount = lines.filter((l) => l.trim() === SEARCH).length + const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length const sepCount = lines.filter((l) => l.trim() === SEP).length const replaceCount = lines.filter((l) => l.trim() === REPLACE).length @@ -357,12 +360,12 @@ Each file requires its own path, start_line, and diff elements. : reportMergeConflictError(SEP, SEARCH) if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH) if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) - if (marker === SEARCH) state.current = State.AFTER_SEARCH + if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) break case State.AFTER_SEARCH: - if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP) + if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP) if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP) if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) @@ -370,7 +373,7 @@ Each file requires its own path, start_line, and diff elements. break case State.AFTER_SEPARATOR: - if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE) + if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE) if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE) if (marker === SEP) return likelyBadStructure @@ -456,7 +459,7 @@ Each file requires its own path, start_line, and diff elements. /* Regex parts: 1. (?:^|\n) Ensures the first marker starts at the beginning of the file or right after a newline. - 2. (??\s*\n Matches the line "<<<<<<< SEARCH" with optional '>' (ignoring any trailing spaces) – the negative lookbehind makes sure it isn't escaped. 3. ((?:\:start_line:\s*(\d+)\s*\n))? Optionally matches a ":start_line:" line. The outer capturing group is group 1 and the inner (\d+) is group 2. 4. ((?:\:end_line:\s*(\d+)\s*\n))? Optionally matches a ":end_line:" line. Group 3 is the whole match and group 4 is the digits. 5. ((?>>>>>> REPLACE)(?=\n|$)/g, + /(?:^|\n)(??\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?>>>>>> REPLACE)(?=\n|$)/g, ), ] diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index d4c14b169f2..a6a9913203c 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -198,7 +198,10 @@ Only use a single line of '=======' between search and replacement content, beca } const state = { current: State.START, line: 0 } - const SEARCH = "<<<<<<< SEARCH" + // Pattern allows optional '>' after SEARCH to handle AI-generated diffs + // (e.g., Sonnet 4 sometimes adds an extra '>') + const SEARCH_PATTERN = /^<<<<<<< SEARCH>?$/ + const SEARCH = SEARCH_PATTERN.source.replace(/[\^$]/g, "") // Remove regex anchors for display const SEP = "=======" const REPLACE = ">>>>>>> REPLACE" const SEARCH_PREFIX = "<<<<<<<" @@ -268,7 +271,7 @@ Only use a single line of '=======' between search and replacement content, beca }) const lines = diffContent.split("\n") - const searchCount = lines.filter((l) => l.trim() === SEARCH).length + const searchCount = lines.filter((l) => SEARCH_PATTERN.test(l.trim())).length const sepCount = lines.filter((l) => l.trim() === SEP).length const replaceCount = lines.filter((l) => l.trim() === REPLACE).length @@ -296,12 +299,12 @@ Only use a single line of '=======' between search and replacement content, beca : reportMergeConflictError(SEP, SEARCH) if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEARCH) if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) - if (marker === SEARCH) state.current = State.AFTER_SEARCH + if (SEARCH_PATTERN.test(marker)) state.current = State.AFTER_SEARCH else if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) break case State.AFTER_SEARCH: - if (marker === SEARCH) return reportInvalidDiffError(SEARCH, SEP) + if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, SEP) if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, SEARCH) if (marker === REPLACE) return reportInvalidDiffError(REPLACE, SEP) if (marker.startsWith(REPLACE_PREFIX)) return reportMergeConflictError(marker, SEARCH) @@ -309,7 +312,7 @@ Only use a single line of '=======' between search and replacement content, beca break case State.AFTER_SEPARATOR: - if (marker === SEARCH) return reportInvalidDiffError(SEARCH, REPLACE) + if (SEARCH_PATTERN.test(marker)) return reportInvalidDiffError(SEARCH_PATTERN.source, REPLACE) if (marker.startsWith(SEARCH_PREFIX)) return reportMergeConflictError(marker, REPLACE) if (marker === SEP) return likelyBadStructure @@ -378,7 +381,7 @@ Only use a single line of '=======' between search and replacement content, beca let matches = [ ...diffContent.matchAll( - /(?:^|\n)(?>>>>>> REPLACE)(?=\n|$)/g, + /(?:^|\n)(??\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?>>>>>> REPLACE)(?=\n|$)/g, ), ]