Skip to content

Commit b5c5e21

Browse files
fix(diff): repair truncated Grok diffs with missing markers (#186) (#230)
* fix(diff): repair truncated Grok diffs by reinserting missing markers (#186) Grok frequently truncates streamed diffs, leaving SEARCH blocks without the ======= separator and/or the >>>>>>> REPLACE closer, which makes applyDiff fail with 'Expected ======= was not found'. repairTruncatedDiff() detects incomplete blocks and reinserts the missing markers while preserving valid blocks and escaped markers. * test(diff): add fixture-based regression tests for truncated Grok diffs (#186) Per review feedback: end-to-end applyDiff() regression guards using realistic truncated-Grok fixtures (missing >>>>>>> REPLACE, missing ======= separator), plus a well-formed multi-block diff that must pass through unchanged. * refactor(diff): address CodeRabbit review on truncated-diff repair (#186) - Use a local repairedDiff in applyDiff instead of reassigning the diffContent parameter, keeping the original input observable. - When a block has a closer but no ======= separator, splice the separator in before the existing >>>>>>> REPLACE rather than synthesizing a second closer. - Strip leading Grok header directives (:start_line:, :end_line:, -------) before the first-line-is-SEARCH heuristic so metadata isn't treated as content; the directives are preserved on the SEARCH section. * fix: remove unused needsRepair variable in repairTruncatedDiff Address review feedback from @edelauna (code review #3284681514): needsRepair was assigned but never read, making it a dead store. The variable served no functional purpose in the repair loop, so it has been removed. --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com>
1 parent d96cd4c commit b5c5e21

2 files changed

Lines changed: 276 additions & 2 deletions

File tree

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

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,4 +1204,174 @@ function sum(a, b) {
12041204
expect(result.error).toContain(":start_line:5 <-- Invalid location")
12051205
})
12061206
})
1207+
1208+
describe("repairTruncatedDiff", () => {
1209+
let strategy: MultiSearchReplaceDiffStrategy
1210+
1211+
beforeEach(() => {
1212+
strategy = new MultiSearchReplaceDiffStrategy()
1213+
})
1214+
1215+
it("should not modify a complete diff", () => {
1216+
const diff = "<<<<<<< SEARCH\n" + "original content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE"
1217+
const result = strategy["repairTruncatedDiff"](diff)
1218+
expect(result).toBe(diff)
1219+
})
1220+
1221+
it("should not modify a diff with multiple complete blocks", () => {
1222+
const diff =
1223+
"<<<<<<< SEARCH\n" +
1224+
"content1\n" +
1225+
"=======\n" +
1226+
"new1\n" +
1227+
">>>>>>> REPLACE\n\n" +
1228+
"<<<<<<< SEARCH\n" +
1229+
"content2\n" +
1230+
"=======\n" +
1231+
"new2\n" +
1232+
">>>>>>> REPLACE"
1233+
const result = strategy["repairTruncatedDiff"](diff)
1234+
expect(result).toBe(diff)
1235+
})
1236+
1237+
it("should repair diff missing ======= and >>>>>>> REPLACE", () => {
1238+
const diff = "<<<<<<< SEARCH\n" + "original content\n" + "new content"
1239+
const result = strategy["repairTruncatedDiff"](diff)
1240+
expect(result).toBe(
1241+
"<<<<<<< SEARCH\n" + "original content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE",
1242+
)
1243+
})
1244+
1245+
it("should repair diff missing only >>>>>>> REPLACE", () => {
1246+
const diff = "<<<<<<< SEARCH\n" + "original content\n" + "=======\n" + "new content"
1247+
const result = strategy["repairTruncatedDiff"](diff)
1248+
expect(result).toBe(
1249+
"<<<<<<< SEARCH\n" + "original content\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE",
1250+
)
1251+
})
1252+
1253+
it("should repair first truncated block while preserving subsequent complete blocks", () => {
1254+
const diff =
1255+
"<<<<<<< SEARCH\n" +
1256+
"content1\n" +
1257+
"new1\n\n" +
1258+
"<<<<<<< SEARCH\n" +
1259+
"content2\n" +
1260+
"=======\n" +
1261+
"new2\n" +
1262+
">>>>>>> REPLACE"
1263+
const result = strategy["repairTruncatedDiff"](diff)
1264+
expect(result).toBe(
1265+
"<<<<<<< SEARCH\n" +
1266+
"content1\n" +
1267+
"=======\n" +
1268+
"new1\n" +
1269+
">>>>>>> REPLACE\n\n" +
1270+
"<<<<<<< SEARCH\n" +
1271+
"content2\n" +
1272+
"=======\n" +
1273+
"new2\n" +
1274+
">>>>>>> REPLACE",
1275+
)
1276+
})
1277+
1278+
it("should handle empty search content with missing replace marker", () => {
1279+
const diff = "<<<<<<< SEARCH\n" + "replacement text"
1280+
const result = strategy["repairTruncatedDiff"](diff)
1281+
expect(result).toBe("<<<<<<< SEARCH\n" + "=======\n" + "replacement text\n" + ">>>>>>> REPLACE")
1282+
})
1283+
1284+
it("should not add trailing newline if content already ends with one", () => {
1285+
const diff = "<<<<<<< SEARCH\n" + "original\n" + "=======\n" + "new content\n"
1286+
const result = strategy["repairTruncatedDiff"](diff)
1287+
expect(result).toBe("<<<<<<< SEARCH\n" + "original\n" + "=======\n" + "new content\n" + ">>>>>>> REPLACE")
1288+
})
1289+
1290+
it("inserts ======= before an existing closer instead of synthesizing a second one", () => {
1291+
// Has >>>>>>> REPLACE but no ======= separator.
1292+
const diff = "<<<<<<< SEARCH\n" + "old line\n" + ">>>>>>> REPLACE"
1293+
const result = strategy["repairTruncatedDiff"](diff)
1294+
expect(result).toBe("<<<<<<< SEARCH\n" + "old line\n" + "=======\n" + ">>>>>>> REPLACE")
1295+
// Exactly one closer, exactly one separator.
1296+
expect(result.match(/>>>>>>> REPLACE/g)).toHaveLength(1)
1297+
expect(result.match(/^=======$/gm)).toHaveLength(1)
1298+
})
1299+
1300+
it("preserves :start_line: / ------- directives instead of treating them as SEARCH content", () => {
1301+
const diff = "<<<<<<< SEARCH\n" + ":start_line:5\n" + "-------\n" + "old line\n" + "new line"
1302+
const result = strategy["repairTruncatedDiff"](diff)
1303+
expect(result).toBe(
1304+
"<<<<<<< SEARCH\n" +
1305+
":start_line:5\n" +
1306+
"-------\n" +
1307+
"old line\n" +
1308+
"=======\n" +
1309+
"new line\n" +
1310+
">>>>>>> REPLACE",
1311+
)
1312+
})
1313+
1314+
it("treats a single content line after a directive header as the SEARCH target", () => {
1315+
const diff = "<<<<<<< SEARCH\n" + ":start_line:5\n" + "-------\n" + "old line"
1316+
const result = strategy["repairTruncatedDiff"](diff)
1317+
expect(result).toBe(
1318+
"<<<<<<< SEARCH\n" +
1319+
":start_line:5\n" +
1320+
"-------\n" +
1321+
"old line\n" +
1322+
"=======\n" +
1323+
"\n" +
1324+
">>>>>>> REPLACE",
1325+
)
1326+
})
1327+
})
1328+
1329+
// Regression guards for #186: Grok sometimes truncates the streamed diff and drops
1330+
// the closing markers, which previously surfaced as "Unable to apply diff - Expected
1331+
// '=======' was not found". These fixtures exercise the full applyDiff() path end-to-end.
1332+
describe("truncated Grok diff regression (#186)", () => {
1333+
const grokStrategy = new MultiSearchReplaceDiffStrategy(1.0, 5)
1334+
const originalContent = 'function greet() {\n\treturn "hello"\n}\n'
1335+
const expectedContent = 'function greet() {\n\treturn "hi there"\n}\n'
1336+
1337+
it("applies a diff whose closing >>>>>>> REPLACE marker was truncated", async () => {
1338+
const diff =
1339+
"src/greet.ts\n" + "<<<<<<< SEARCH\n" + '\treturn "hello"\n' + "=======\n" + '\treturn "hi there"'
1340+
const result = await grokStrategy.applyDiff(originalContent, diff)
1341+
expect(result.success).toBe(true)
1342+
if (result.success) {
1343+
expect(result.content).toBe(expectedContent)
1344+
}
1345+
})
1346+
1347+
it("applies a diff truncated before the ======= separator", async () => {
1348+
const diff = "src/greet.ts\n" + "<<<<<<< SEARCH\n" + '\treturn "hello"\n' + '\treturn "hi there"'
1349+
const result = await grokStrategy.applyDiff(originalContent, diff)
1350+
expect(result.success).toBe(true)
1351+
if (result.success) {
1352+
expect(result.content).toBe(expectedContent)
1353+
}
1354+
})
1355+
1356+
it("leaves a well-formed multi-block diff unchanged", async () => {
1357+
const multiBlock = "export const a = 1\nexport const b = 2\n"
1358+
const diff =
1359+
"src/consts.ts\n" +
1360+
"<<<<<<< SEARCH\n" +
1361+
"export const a = 1\n" +
1362+
"=======\n" +
1363+
"export const a = 10\n" +
1364+
">>>>>>> REPLACE\n" +
1365+
"<<<<<<< SEARCH\n" +
1366+
"export const b = 2\n" +
1367+
"=======\n" +
1368+
"export const b = 20\n" +
1369+
">>>>>>> REPLACE"
1370+
const result = await grokStrategy.applyDiff(multiBlock, diff)
1371+
expect(result.success).toBe(true)
1372+
if (result.success) {
1373+
expect(result.content).toBe("export const a = 10\nexport const b = 20\n")
1374+
}
1375+
})
1376+
})
12071377
})

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

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,13 +242,117 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
242242
}
243243
}
244244

245+
/**
246+
* Repairs truncated diffs (common with Grok) by adding missing ======= and >>>>>>> REPLACE markers.
247+
* When the model's output gets cut off mid-stream, the diff may end after SEARCH content
248+
* without the separator or closing marker. This method detects that pattern and appends
249+
* the missing markers so the diff can still be parsed and applied.
250+
*/
251+
private repairTruncatedDiff(diffContent: string): string {
252+
// Only repair if the diff has at least one SEARCH marker
253+
if (!/(?<!\\)<<<<<<< SEARCH/.test(diffContent)) {
254+
return diffContent
255+
}
256+
257+
// Split into blocks based on SEARCH markers
258+
const blocks = diffContent.split(/(?=(?<!\\)<<<<<<< SEARCH)/)
259+
260+
let repaired = ""
261+
262+
for (let i = 0; i < blocks.length; i++) {
263+
const block = blocks[i]
264+
265+
if (block.trim() === "") {
266+
continue
267+
}
268+
269+
// Skip prefix blocks that don't contain a SEARCH marker
270+
// (e.g., the filename line before the first <<<<<<< SEARCH)
271+
if (!/(?<!\\)<<<<<<< SEARCH/.test(block)) {
272+
repaired += block
273+
continue
274+
}
275+
276+
// Check if this block is complete (has both ======= and >>>>>>> REPLACE)
277+
const hasSeparator = /(?<=\n)(?<!\\)=======\s*\n/.test(block)
278+
const hasCloser = /(?<=\n)(?<!\\)>>>>>>> REPLACE(?=\n|$)/.test(block)
279+
280+
if (hasSeparator && hasCloser) {
281+
// Block is complete — emit verbatim (keeps its own trailing separator)
282+
repaired += block
283+
continue
284+
}
285+
286+
// Block needs repair. Build a clean block ending at >>>>>>> REPLACE, then
287+
// re-add an inter-block separator if more (non-empty) blocks follow, so the
288+
// appended closer never gets glued to the next "<<<<<<< SEARCH".
289+
const isLast = blocks.slice(i + 1).every((b) => b.trim() === "")
290+
const separator = isLast ? "" : "\n\n"
291+
292+
if (hasSeparator && !hasCloser) {
293+
// Has ======= but missing >>>>>>> REPLACE — append closing marker
294+
const body = block.replace(/\s+$/, "")
295+
repaired += body + "\n>>>>>>> REPLACE" + separator
296+
} else if (hasCloser && !hasSeparator) {
297+
// Has >>>>>>> REPLACE but missing the ======= separator. Don't synthesize a
298+
// second closer; splice the separator in right before the existing closer so
299+
// everything above it becomes the SEARCH section.
300+
const body = block.replace(/\s+$/, "")
301+
repaired += body.replace(/(\n)(>>>>>>> REPLACE)(?=\n|$)/, "$1=======\n$2") + separator
302+
} else {
303+
// Missing both ======= and >>>>>>> REPLACE.
304+
const searchMatch = block.match(/^<<<<<<< SEARCH\n?([\s\S]*)$/)
305+
let content = (searchMatch?.[1] ?? "").replace(/\s+$/, "")
306+
307+
// Peel off any leading Grok header directives (:start_line:, :end_line:, -------)
308+
// so the "first line is SEARCH" heuristic sees real content, not metadata. The
309+
// directives are preserved as a header on the SEARCH section.
310+
let header = ""
311+
const directiveLine = /^(?::start_line:\s*\d+|:end_line:\s*\d+|-------)\s*$/
312+
let nlIdx: number
313+
while ((nlIdx = content.indexOf("\n")) !== -1 && directiveLine.test(content.slice(0, nlIdx))) {
314+
header += content.slice(0, nlIdx + 1)
315+
content = content.slice(nlIdx + 1)
316+
}
317+
318+
const firstNewlineIdx = content.indexOf("\n")
319+
if (firstNewlineIdx !== -1) {
320+
// First line is SEARCH content, rest is REPLACE content
321+
const searchContent = content.substring(0, firstNewlineIdx)
322+
const replaceContent = content.substring(firstNewlineIdx + 1)
323+
repaired +=
324+
"<<<<<<< SEARCH\n" +
325+
header +
326+
searchContent +
327+
"\n=======\n" +
328+
replaceContent +
329+
"\n>>>>>>> REPLACE" +
330+
separator
331+
} else if (header) {
332+
// Only a directive header plus a single content line: that line is the SEARCH
333+
// target (the user pinned it with start_line); the REPLACE section is empty.
334+
repaired += "<<<<<<< SEARCH\n" + header + content + "\n=======\n\n>>>>>>> REPLACE" + separator
335+
} else {
336+
// Single line — treat as empty SEARCH with content as REPLACE
337+
repaired += "<<<<<<< SEARCH\n=======\n" + content + "\n>>>>>>> REPLACE" + separator
338+
}
339+
}
340+
}
341+
342+
return repaired || diffContent
343+
}
344+
245345
async applyDiff(
246346
originalContent: string,
247347
diffContent: string,
248348
_paramStartLine?: number,
249349
_paramEndLine?: number,
250350
): Promise<DiffResult> {
251-
const validseq = this.validateMarkerSequencing(diffContent)
351+
// Repair truncated diffs before validation (common with Grok and other models
352+
// whose output gets cut off mid-stream, leaving missing ======= and >>>>>>> REPLACE markers)
353+
const repairedDiff = this.repairTruncatedDiff(diffContent)
354+
355+
const validseq = this.validateMarkerSequencing(repairedDiff)
252356
if (!validseq.success) {
253357
return {
254358
success: false,
@@ -288,7 +392,7 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy {
288392
*/
289393

290394
let matches = [
291-
...diffContent.matchAll(
395+
...repairedDiff.matchAll(
292396
/(?:^|\n)(?<!\\)<<<<<<< SEARCH>?\s*\n((?:\:start_line:\s*(\d+)\s*\n))?((?:\:end_line:\s*(\d+)\s*\n))?((?<!\\)-------\s*\n)?([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)=======\s*\n)([\s\S]*?)(?:\n)?(?:(?<=\n)(?<!\\)>>>>>>> REPLACE)(?=\n|$)/g,
293397
),
294398
]

0 commit comments

Comments
 (0)