Skip to content

Commit ec1d6e2

Browse files
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. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 166bc3f commit ec1d6e2

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,4 +1204,87 @@ 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+
})
12071290
})

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

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,95 @@ 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+
let needsRepair = false
262+
263+
for (let i = 0; i < blocks.length; i++) {
264+
const block = blocks[i]
265+
266+
if (block.trim() === "") {
267+
continue
268+
}
269+
270+
// Skip prefix blocks that don't contain a SEARCH marker
271+
// (e.g., the filename line before the first <<<<<<< SEARCH)
272+
if (!/(?<!\\)<<<<<<< SEARCH/.test(block)) {
273+
repaired += block
274+
continue
275+
}
276+
277+
// Check if this block is complete (has both ======= and >>>>>>> REPLACE)
278+
const hasSeparator = /(?<=\n)(?<!\\)=======\s*\n/.test(block)
279+
const hasCloser = /(?<=\n)(?<!\\)>>>>>>> REPLACE(?=\n|$)/.test(block)
280+
281+
if (hasSeparator && hasCloser) {
282+
// Block is complete — emit verbatim (keeps its own trailing separator)
283+
repaired += block
284+
continue
285+
}
286+
287+
// Block needs repair. Build a clean block ending at >>>>>>> REPLACE, then
288+
// re-add an inter-block separator if more (non-empty) blocks follow, so the
289+
// appended closer never gets glued to the next "<<<<<<< SEARCH".
290+
needsRepair = true
291+
const isLast = blocks.slice(i + 1).every((b) => b.trim() === "")
292+
const separator = isLast ? "" : "\n\n"
293+
294+
if (hasSeparator && !hasCloser) {
295+
// Has ======= but missing >>>>>>> REPLACE — append closing marker
296+
const body = block.replace(/\s+$/, "")
297+
repaired += body + "\n>>>>>>> REPLACE" + separator
298+
} else {
299+
// Missing both ======= and >>>>>>> REPLACE
300+
const searchMatch = block.match(/^<<<<<<< SEARCH\n?([\s\S]*)$/)
301+
const content = (searchMatch?.[1] ?? "").replace(/\s+$/, "")
302+
const firstNewlineIdx = content.indexOf("\n")
303+
if (firstNewlineIdx !== -1) {
304+
// First line is SEARCH content, rest is REPLACE content
305+
const searchContent = content.substring(0, firstNewlineIdx)
306+
const replaceContent = content.substring(firstNewlineIdx + 1)
307+
repaired +=
308+
"<<<<<<< SEARCH\n" +
309+
searchContent +
310+
"\n=======\n" +
311+
replaceContent +
312+
"\n>>>>>>> REPLACE" +
313+
separator
314+
} else {
315+
// Single line — treat as empty SEARCH with content as REPLACE
316+
repaired += "<<<<<<< SEARCH\n=======\n" + content + "\n>>>>>>> REPLACE" + separator
317+
}
318+
}
319+
}
320+
321+
return repaired || diffContent
322+
}
323+
245324
async applyDiff(
246325
originalContent: string,
247326
diffContent: string,
248327
_paramStartLine?: number,
249328
_paramEndLine?: number,
250329
): Promise<DiffResult> {
330+
// Repair truncated diffs before validation (common with Grok and other models
331+
// whose output gets cut off mid-stream, leaving missing ======= and >>>>>>> REPLACE markers)
332+
diffContent = this.repairTruncatedDiff(diffContent)
333+
251334
const validseq = this.validateMarkerSequencing(diffContent)
252335
if (!validseq.success) {
253336
return {

0 commit comments

Comments
 (0)