Skip to content

Commit c851eed

Browse files
committed
fix(edit): show candidate lines with similarity score on oldText match failure (#97035)
When oldText matching fails, the error message now shows up to 3 candidate lines from the file content ranked by similarity score (Levenshtein distance), helping users identify whitespace or indentation mismatches. Bounds applied throughout: - Content split capped at 200 lines - Line scoring limited to first 200 chars per line - oldText lines capped before trim/slicing - similarityScore threshold at 0.3 Fixes: #97032
1 parent 733b464 commit c851eed

2 files changed

Lines changed: 171 additions & 7 deletions

File tree

src/agents/sessions/tools/edit-diff.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { constants } from "node:fs";
77
import { access, readFile } from "node:fs/promises";
88
import * as Diff from "diff";
9+
import { levenshteinDistance } from "../../../shared/levenshtein-distance.js";
910
import { resolveToCwd } from "./path-utils.js";
1011

1112
export function detectLineEnding(content: string): "\r\n" | "\n" {
@@ -255,17 +256,74 @@ function countOccurrences(content: string, oldText: string): number {
255256
return fuzzyContent.split(fuzzyOldText).length - 1;
256257
}
257258

258-
function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error {
259-
if (totalEdits === 1) {
260-
return new Error(
261-
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
262-
);
259+
function getNotFoundError(
260+
path: string,
261+
editIndex: number,
262+
totalEdits: number,
263+
content?: string,
264+
oldText?: string,
265+
): Error {
266+
let candidateHint = "";
267+
if (content && oldText) {
268+
const lines = content.split("\n", CANDIDATE_MAX_LINES); // bounded split to avoid whole-file allocation
269+
const oldTextLines = oldText.split("\n", CANDIDATE_MAX_LINES);
270+
const oldTextFirstLine = oldTextLines[0]?.slice(0, CANDIDATE_MAX_LINE_LENGTH).trim();
271+
if (oldTextFirstLine) {
272+
// Score content lines by similarity, capped to avoid unbounded CPU on large files
273+
const linesToScore = lines.slice(0, CANDIDATE_MAX_LINES);
274+
const oldTextTruncated = oldTextFirstLine.slice(0, CANDIDATE_MAX_LINE_LENGTH);
275+
const scored = linesToScore
276+
.map((line, idx) => ({
277+
lineNum: idx + 1,
278+
line,
279+
// Slice first, then trim — avoids scanning the full line before truncation
280+
score: similarityScore(line.slice(0, CANDIDATE_MAX_LINE_LENGTH).trim(), oldTextTruncated),
281+
}))
282+
.filter((s) => s.score > CANDIDATE_MIN_SCORE)
283+
.toSorted((a, b) => b.score - a.score)
284+
.slice(0, 3);
285+
286+
if (scored.length > 0) {
287+
candidateHint =
288+
"\n" +
289+
scored
290+
.map(
291+
(s) =>
292+
` ${editIndex === 0 ? "" : `edits[${editIndex}].`}oldText at line ${s.lineNum}: "${s.line.slice(0, 80)}" (${Math.round(s.score * 100)}% match, check whitespace/indentation)`,
293+
)
294+
.join("\n");
295+
}
296+
}
263297
}
298+
299+
const prefix =
300+
totalEdits === 1 ? "Could not find the exact text" : `Could not find edits[${editIndex}]`;
264301
return new Error(
265-
`Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`,
302+
`${prefix} in ${path}. The old text must match exactly including all whitespace and newlines.${candidateHint}`,
266303
);
267304
}
268305

306+
const CANDIDATE_MAX_LINES = 200;
307+
const CANDIDATE_MAX_LINE_LENGTH = 200;
308+
const CANDIDATE_MIN_SCORE = 0.3;
309+
310+
/** Simple similarity score between two strings (0-1). */
311+
function similarityScore(a: string, b: string): number {
312+
if (!a || !b) {
313+
return 0;
314+
}
315+
if (a === b) {
316+
return 1;
317+
}
318+
const longer = a.length > b.length ? a : b;
319+
const shorter = a.length > b.length ? b : a;
320+
if (longer.length === 0) {
321+
return 1;
322+
}
323+
const edits = levenshteinDistance(longer, shorter);
324+
return 1 - edits / longer.length;
325+
}
326+
269327
function getDuplicateError(
270328
path: string,
271329
editIndex: number,
@@ -336,7 +394,7 @@ export function applyEditsToNormalizedContent(
336394
const edit = normalizedEdits[i];
337395
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
338396
if (!matchResult.found) {
339-
throw getNotFoundError(path, i, normalizedEdits.length);
397+
throw getNotFoundError(path, i, normalizedEdits.length, normalizedContent, edit.oldText);
340398
}
341399

342400
const occurrences = countOccurrences(replacementBaseContent, edit.oldText);

src/agents/sessions/tools/edit.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { applyPatch } from "diff";
77
import { Value } from "typebox/value";
88
import { afterEach, describe, expect, it, vi } from "vitest";
99
import type { Theme } from "../../modes/interactive/theme/theme.js";
10+
import { applyEditsToNormalizedContent } from "./edit-diff.js";
1011
import {
1112
createEditTool,
1213
createEditToolDefinition,
@@ -623,3 +624,108 @@ describe("edit tool", () => {
623624
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("new content\n");
624625
});
625626
});
627+
628+
describe("applyEditsToNormalizedContent candidate hints", () => {
629+
it("shows nearest candidate with similarity score on match failure", () => {
630+
const content = 'function hello() {\n console.log("Hello");\n return 42;\n}\n';
631+
try {
632+
applyEditsToNormalizedContent(
633+
content,
634+
[{ oldText: 'console.log("hello")', newText: 'console.log("Hi")' }],
635+
"test.js",
636+
);
637+
expect.fail("should have thrown");
638+
} catch (e) {
639+
const msg = (e as Error).message;
640+
expect(msg).toContain("console.log");
641+
expect(msg).toContain("% match");
642+
expect(msg).toContain("line 2");
643+
}
644+
});
645+
646+
it("shows match hint with indentation hint for whitespace mismatch", () => {
647+
const content = "const x = 1;\nconst y = 2;\nconst z = 3;\n";
648+
try {
649+
applyEditsToNormalizedContent(
650+
content,
651+
[{ oldText: " const y = 2", newText: " const y = 999" }],
652+
"test.js",
653+
);
654+
expect.fail("should have thrown");
655+
} catch (e) {
656+
const msg = (e as Error).message;
657+
expect(msg).toContain("% match");
658+
expect(msg).toContain("whitespace");
659+
}
660+
});
661+
662+
it("shows candidate hint with multi-line oldText using first line only", () => {
663+
const content = "first line\nsecond line\nthird line\n";
664+
try {
665+
applyEditsToNormalizedContent(
666+
content,
667+
[{ oldText: "wrong line\nwith extra", newText: "replacement" }],
668+
"test.js",
669+
);
670+
expect.fail("should have thrown");
671+
} catch (e) {
672+
const msg = (e as Error).message;
673+
expect(msg).toContain("% match");
674+
}
675+
});
676+
677+
it("includes edit index prefix for multi-edit scenarios", () => {
678+
const content = "line A\nline B\nline\n";
679+
try {
680+
applyEditsToNormalizedContent(
681+
content,
682+
[
683+
{ oldText: "line A", newText: "changed A" },
684+
{ oldText: "lina misspell", newText: "replacement" },
685+
],
686+
"test.js",
687+
);
688+
expect.fail("should have thrown");
689+
} catch (e) {
690+
const msg = (e as Error).message;
691+
expect(msg).toContain("edits[1]");
692+
expect(msg).toContain("% match");
693+
}
694+
});
695+
696+
it("bounded oldText trim does not stall on a very long single line", () => {
697+
// Minified or generated file: oldText is one very long line.
698+
// trim() must not scan the full line before the 200-char cap.
699+
const longLine = "x".repeat(50_000);
700+
try {
701+
applyEditsToNormalizedContent(
702+
"some content",
703+
[{ oldText: longLine, newText: "replacement" }],
704+
"minified.js",
705+
);
706+
expect.fail("should have thrown");
707+
} catch (e) {
708+
const msg = (e as Error).message;
709+
expect(msg).toContain("Could not find");
710+
expect(msg.length).toBeLessThan(500);
711+
}
712+
});
713+
714+
it("does not produce unbounded output on large content", () => {
715+
const largeContent = Array.from({ length: 1000 }, (_, i) => `line ${i}: some content`).join(
716+
"\n",
717+
);
718+
try {
719+
applyEditsToNormalizedContent(
720+
largeContent,
721+
[{ oldText: "nonexistent text", newText: "replacement" }],
722+
"large.js",
723+
);
724+
expect.fail("should have thrown");
725+
} catch (e) {
726+
const msg = (e as Error).message;
727+
expect(msg).toContain("Could not find");
728+
expect(msg.split("\n").length).toBeLessThan(10);
729+
}
730+
});
731+
});

0 commit comments

Comments
 (0)