Skip to content

Commit be67a51

Browse files
committed
fix(tools): isolate fuzzy-equivalent edit no-ops
1 parent 63000f6 commit be67a51

3 files changed

Lines changed: 48 additions & 8 deletions

File tree

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,29 @@ export function validateNoOpEditTargets(
455455
}
456456
}
457457

458+
export function splitNoOpEdits(
459+
normalizedContent: string,
460+
edits: Edit[],
461+
path: string,
462+
): { noOpEdits: Edit[]; realEdits: Edit[] } {
463+
const noOpEdits: Edit[] = [];
464+
const realEdits: Edit[] = [];
465+
for (const edit of edits) {
466+
const fuzzyNoOp = normalizeForFuzzyMatch(edit.oldText) === normalizeForFuzzyMatch(edit.newText);
467+
if (edit.oldText === edit.newText || fuzzyNoOp) {
468+
applyEditsToNormalizedContent(
469+
normalizedContent,
470+
[{ oldText: edit.oldText, newText: "" }],
471+
path,
472+
);
473+
noOpEdits.push(edit);
474+
} else {
475+
realEdits.push(edit);
476+
}
477+
}
478+
return { noOpEdits, realEdits };
479+
}
480+
458481
/**
459482
* Compute the diff for one or more edit operations without applying them.
460483
* Used for preview rendering in the TUI before the tool executes.
@@ -496,8 +519,7 @@ export async function computeEditsDiff(
496519
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
497520
const { text: content } = stripBom(rawContent);
498521
const normalizedContent = normalizeToLF(content);
499-
const noOpEdits = edits.filter((edit) => edit.oldText === edit.newText);
500-
const realEdits = edits.filter((edit) => edit.oldText !== edit.newText);
522+
const { noOpEdits, realEdits } = splitNoOpEdits(normalizedContent, edits, path);
501523
validateNoOpEditTargets(normalizedContent, noOpEdits, realEdits, path);
502524
if (realEdits.length === 0) {
503525
return { diff: "", firstChangedLine: undefined };

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,25 @@ describe("edit tool", () => {
423423
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("bazbar\n");
424424
});
425425

426+
it("preserves unrelated whitespace beside a fuzzy-equivalent no-op", async () => {
427+
const filePath = await createTempFile("foo \nkeep \n");
428+
const tool = createEditTool(tmpDir);
429+
430+
await tool.execute(
431+
"call-1",
432+
{
433+
path: filePath,
434+
edits: [
435+
{ oldText: "foo ", newText: "foo" },
436+
{ oldText: "keep", newText: "changed" },
437+
],
438+
},
439+
undefined,
440+
);
441+
442+
await expect(fs.readFile(filePath, "utf-8")).resolves.toBe("foo \nchanged \n");
443+
});
444+
426445
it("rejects duplicate no-op entries", async () => {
427446
const filePath = await createTempFile("foo\n");
428447
const tool = createEditTool(tmpDir);

src/agents/sessions/tools/edit.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
generateUnifiedPatch,
2828
normalizeToLF,
2929
restoreLineEndings,
30+
splitNoOpEdits,
3031
stripBom,
3132
validateNoOpEditTargets,
3233
} from "./edit-diff.js";
@@ -42,10 +43,6 @@ type EditRenderState = {
4243
callComponent?: EditCallRenderComponent;
4344
};
4445

45-
function filterNoOpEdits(edits: Edit[]): Edit[] {
46-
return edits.filter((edit) => edit.oldText !== edit.newText);
47-
}
48-
4946
const replaceEditSchema = Type.Object(
5047
{
5148
oldText: Type.String({
@@ -406,7 +403,7 @@ export function createEditToolDefinition(
406403
throw new Error("Operation aborted");
407404
}
408405

409-
const realEdits = filterNoOpEdits(originalEdits);
406+
let realEdits: Edit[] = [];
410407

411408
try {
412409
await ops.access(absolutePath);
@@ -430,7 +427,9 @@ export function createEditToolDefinition(
430427
const { bom, text: content } = stripBom(rawContent);
431428
const originalEnding = detectLineEnding(content);
432429
const normalizedContent = normalizeToLF(content);
433-
const noOpEdits = originalEdits.filter((edit) => edit.oldText === edit.newText);
430+
const editSets = splitNoOpEdits(normalizedContent, originalEdits, path);
431+
const noOpEdits = editSets.noOpEdits;
432+
realEdits = editSets.realEdits;
434433
validateNoOpEditTargets(normalizedContent, noOpEdits, realEdits, path);
435434
if (realEdits.length === 0) {
436435
return {

0 commit comments

Comments
 (0)