Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix(edit): keep candidate truncation branch-compatible
  • Loading branch information
vincentkoc committed Jul 6, 2026
commit 59b8f921f6d57ed186ed46a2b33b2e95d0601b3d
148 changes: 82 additions & 66 deletions src/agents/sessions/tools/edit-diff.test.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,100 @@
// edit-diff tests cover not-found candidate hints and error diagnostics.
import { describe, expect, it } from "vitest";
import { applyEditsToNormalizedContent, normalizeToLF } from "./edit-diff.js";

function getMismatchMessage(
content: string,
edits: Array<{ oldText: string; newText: string }>,
): string {
try {
applyEditsToNormalizedContent(normalizeToLF(content), edits, "test.ts");
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
throw new Error("Expected edit mismatch");
}

describe("applyEditsToNormalizedContent", () => {
it("includes near-match candidate hints when oldText is not found", () => {
const content = normalizeToLF(
"line one\n" +
"this is a test line\n" +
"another line here\n" +
"const value = 42;\n" +
"final line\n",
it("shows the closest matching line with expected, found, and difference marker", () => {
const message = getMismatchMessage(
"line one\nthis is a test line\nanother line here\nconst value = 42;\nfinal line\n",
[{ oldText: "const value = 99;", newText: "" }],
);
expect(() =>
applyEditsToNormalizedContent(
content,
[{ oldText: "const value = 99;", newText: "" }],
"test.ts",
),
).toThrow(/near line 4:[\s\S]*const value = 42[\s\S]*\d+% match/);

expect(message).toMatch(/near line 4 \(\d+% match\)/);
expect(message).toContain('expected: "const value = 99;"');
expect(message).toContain('found: "const value = 42;"');
expect(message).toMatch(/\^{1,12}/);
});

it("shows up to 3 best candidates sorted by similarity", () => {
const content = normalizeToLF(
"function alpha() {}\n" +
"function beta() {}\n" +
"function gamma() {}\n" +
"function delta() {}\n",
const message = getMismatchMessage(
"function alpha() {}\nfunction beta() {}\nfunction bet() {}\nfunction delta() {}\n",
[{ oldText: "function betaa() {}", newText: "" }],
);
expect(() =>
applyEditsToNormalizedContent(
content,
[{ oldText: "function betaa() {}", newText: "" }],
"test.ts",
),
).toThrow(/near line 2:[\s\S]*function beta/);

expect(message.match(/near line/g)).toHaveLength(3);
expect(message.indexOf("near line 2")).toBeLessThan(message.indexOf("near line 3"));
});

it("uses the most meaningful oldText line for multiline diagnostics", () => {
const message = getMismatchMessage("header\nconst actualValue = 42;\nfooter\n", [
{ oldText: "x\nconst actualValue = 99;\n", newText: "" },
]);

expect(message).toContain("near line 2");
expect(message).toContain('expected: "const actualValue = 99;"');
});

it("calls out indentation differences", () => {
const message = getMismatchMessage(" const value = 42;\n", [
{ oldText: " const value = 42;", newText: "" },
]);

expect(message).toContain("indentation differs (expected 12 spaces, found 6 spaces)");
});

it("omits candidate hints when no line passes the similarity threshold", () => {
const content = normalizeToLF("abc\nxyz\n123\n");
expect(() =>
applyEditsToNormalizedContent(
content,
[{ oldText: "completely different text here", newText: "" }],
"test.ts",
),
).toThrow(/Could not find the exact text/);
// The error should NOT contain candidate hint lines
expect(() =>
applyEditsToNormalizedContent(
content,
[{ oldText: "completely different text here", newText: "" }],
"test.ts",
),
).not.toThrow(/near line/);
it("calls out escaping differences", () => {
const message = getMismatchMessage('const pattern = "\\bword\\b";\n', [
{ oldText: 'const pattern = "\\\\bword\\\\b";', newText: "" },
]);

expect(message).toContain("escaping differs (expected 4 backslashes, found 2)");
});

it("caps candidate scanning at MAX_LINES to avoid unbounded work", () => {
// Generate content with many similar lines — should not OOM or hang.
const lines = Array.from({ length: 5000 }, (_, i) => `line-${i}-const value = ${i}`);
const content = normalizeToLF(lines.join("\n"));
expect(() =>
applyEditsToNormalizedContent(
content,
[{ oldText: "const value = 99999;", newText: "" }],
"large.ts",
),
).toThrow(/Could not find/);
it("omits candidates below the similarity threshold", () => {
const message = getMismatchMessage("abc\nxyz\n123\n", [
{ oldText: "completely different text here", newText: "" },
]);

expect(message).toContain("Could not find the exact text");
expect(message).not.toContain("Closest matching lines");
});

it("bounds candidate scanning and displayed line length", () => {
const content = `${Array.from({ length: 1000 }, () => "unrelated").join("\n")}\n${"x".repeat(
200_000,
)}const value = 42;`;
const message = getMismatchMessage(content, [{ oldText: "const value = 99;", newText: "" }]);

expect(message).not.toContain("const value = 42");
expect(message.length).toBeLessThan(1000);
});

it("does not split surrogate pairs at the displayed line boundary", () => {
const message = getMismatchMessage(`${"x".repeat(119)}🙂found\n`, [
{ oldText: `${"x".repeat(119)}🙂expected`, newText: "" },
]);

expect(message).toContain("Closest matching lines");
expect(message).not.toContain("\\ud83d");
});

it("includes candidate hints for multi-edit failures", () => {
const content = normalizeToLF("alpha\nbeta\ngamma\n");
expect(() =>
applyEditsToNormalizedContent(
content,
[
{ oldText: "alpha", newText: "A" },
{ oldText: "bta", newText: "B" },
],
"test.ts",
),
).toThrow(/Could not find edits\[1\][\s\S]*near line 2:[\s\S]*beta/);
const message = getMismatchMessage("alpha\nbeta\ngamma\n", [
{ oldText: "alpha", newText: "A" },
{ oldText: "bta", newText: "B" },
]);

expect(message).toMatch(/Could not find edits\[1\][\s\S]*near line 2/);
});
});
178 changes: 143 additions & 35 deletions src/agents/sessions/tools/edit-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,47 +256,155 @@ function countOccurrences(content: string, oldText: string): number {
return fuzzyContent.split(fuzzyOldText).length - 1;
}

const EDIT_CANDIDATE_LIMIT = 3;
const EDIT_CANDIDATE_MAX_LINES = 1000;
const EDIT_CANDIDATE_MAX_SCAN_CHARS = 128 * 1024;
const EDIT_CANDIDATE_MAX_LINE_CHARS = 120;
const EDIT_CANDIDATE_MIN_SCORE = 0.45;

interface EditCandidate {
lineNumber: number;
line: string;
score: number;
}

function truncateCandidateText(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
const cut =
maxChars > 0 &&
/[\uD800-\uDBFF]/.test(text[maxChars - 1]) &&
/[\uDC00-\uDFFF]/.test(text[maxChars])
? maxChars - 1
: maxChars;
return text.slice(0, cut);
}

function getBoundedLines(text: string, maxLines: number, maxScanChars: number): string[] {
return truncateCandidateText(text, maxScanChars)
.split("\n", maxLines)
.map((line) => truncateCandidateText(line, EDIT_CANDIDATE_MAX_LINE_CHARS));
}

function scoreCandidate(expected: string, candidate: string): number {
const normalizedExpected = expected.trim();
const normalizedCandidate = candidate.trim();
const maxLength = Math.max(normalizedExpected.length, normalizedCandidate.length);
if (maxLength === 0) {
return 0;
}

// Length alone sets an upper bound on the possible similarity score.
if (
Math.min(normalizedExpected.length, normalizedCandidate.length) / maxLength <
EDIT_CANDIDATE_MIN_SCORE
) {
return 0;
}

return 1 - levenshteinDistance(normalizedExpected, normalizedCandidate) / maxLength;
}

function describeIndentation(line: string): string {
const indentation = line.match(/^[ \t]*/)?.[0] ?? "";
if (!indentation) {
return "none";
}
const tabs = indentation.match(/\t/g)?.length ?? 0;
const spaces = indentation.length - tabs;
return tabs === 0 ? `${spaces} spaces` : `${spaces} spaces and ${tabs} tabs`;
}

function firstDifferenceIndex(left: string, right: string): number {
const sharedLength = Math.min(left.length, right.length);
for (let index = 0; index < sharedLength; index++) {
if (left[index] !== right[index]) {
return index;
}
}
return left.length === right.length ? -1 : sharedLength;
}

function describeCandidateDifference(expected: string, found: string): string {
const expectedIndentation = expected.match(/^[ \t]*/)?.[0] ?? "";
const foundIndentation = found.match(/^[ \t]*/)?.[0] ?? "";
if (expectedIndentation !== foundIndentation) {
return `indentation differs (expected ${describeIndentation(expected)}, found ${describeIndentation(found)})`;
}

const expectedBackslashes = expected.match(/\\/g)?.length ?? 0;
const foundBackslashes = found.match(/\\/g)?.length ?? 0;
if (expectedBackslashes !== foundBackslashes) {
return `escaping differs (expected ${expectedBackslashes} backslashes, found ${foundBackslashes})`;
}

const differenceIndex = firstDifferenceIndex(expected, found);
return differenceIndex === -1
? "this line matches; surrounding lines differ"
: `first difference at column ${differenceIndex + 1}`;
}

function getCandidateHint(content: string, oldText: string): string {
const expected = getBoundedLines(oldText, 32, 4096).reduce(
(best, line) => (line.trim().length > best.trim().length ? line : best),
"",
);
if (!expected.trim()) {
return "";
}
const candidates = getBoundedLines(
content,
EDIT_CANDIDATE_MAX_LINES,
EDIT_CANDIDATE_MAX_SCAN_CHARS,
)
.map((line, index): EditCandidate | undefined => {
const score = scoreCandidate(expected, line);
return score >= EDIT_CANDIDATE_MIN_SCORE ? { lineNumber: index + 1, line, score } : undefined;
})
.filter((candidate): candidate is EditCandidate => candidate !== undefined)
.toSorted((left, right) => right.score - left.score || left.lineNumber - right.lineNumber)
.slice(0, EDIT_CANDIDATE_LIMIT);
if (candidates.length === 0) {
return "";
}
const expectedDisplay = JSON.stringify(expected);
return (
"\nClosest matching lines:\n" +
candidates
.map((candidate) => {
const foundDisplay = JSON.stringify(candidate.line);
const differenceIndex = firstDifferenceIndex(expectedDisplay, foundDisplay);
const markerIndex =
differenceIndex === -1
? Math.min(expectedDisplay.length, foundDisplay.length)
: differenceIndex;
const markerWidth = Math.max(
1,
Math.min(12, Math.max(expectedDisplay.length, foundDisplay.length) - markerIndex),
);
return [
` near line ${candidate.lineNumber} (${Math.round(candidate.score * 100)}% match):`,
` expected: ${expectedDisplay}`,
` found: ${foundDisplay}`,
` ${" ".repeat(markerIndex)}${"^".repeat(markerWidth)}`,
` hint: ${describeCandidateDifference(expected, candidate.line)}`,
].join("\n");
})
.join("\n")
);
}

function getNotFoundError(
path: string,
editIndex: number,
totalEdits: number,
content?: string,
oldText?: string,
content: string,
oldText: string,
): Error {
const prefix =
totalEdits === 1 ? "Could not find the exact text" : `Could not find edits[${editIndex}]`;
let hint = "";
if (content && oldText) {
// Score each line by similarity to the first line of oldText.
// Cap inputs to avoid unbounded CPU on large / minified files (P1).
const oldTextFirstLine = oldText.split("\n")[0]?.trim().slice(0, 200) ?? "";
if (oldTextFirstLine.length > 0) {
const MAX_LINES = 3000;
const MAX_LINE_LEN = 200;
const lines = content.split("\n");
const scored = lines
.slice(0, MAX_LINES)
.map((line, idx) => {
const trimmed = line.trim().slice(0, MAX_LINE_LEN);
const dist = levenshteinDistance(trimmed, oldTextFirstLine);
const maxLen = Math.max(trimmed.length, oldTextFirstLine.length);
return { lineNum: idx + 1, line: trimmed, score: maxLen > 0 ? 1 - dist / maxLen : 0 };
})
.filter((s) => s.score > 0.3)
.toSorted((a, b) => b.score - a.score)
.slice(0, 3);
if (scored.length > 0) {
hint =
"\n" +
scored
.map(
(s) =>
` near line ${s.lineNum}: "${s.line}" (${Math.round(s.score * 100)}% match, check whitespace/indentation)`,
)
.join("\n");
}
}
}
const hint = getCandidateHint(content, oldText);
return new Error(
`${prefix} in ${path}. The old text must match exactly including all whitespace and newlines.${hint}`,
);
Expand Down Expand Up @@ -372,7 +480,7 @@ export function applyEditsToNormalizedContent(
const edit = normalizedEdits[i];
const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText);
if (!matchResult.found) {
throw getNotFoundError(path, i, normalizedEdits.length, baseContent, edit.oldText);
throw getNotFoundError(path, i, normalizedEdits.length, normalizedContent, edit.oldText);
}

const occurrences = countOccurrences(replacementBaseContent, edit.oldText);
Expand Down
Loading