feat: multi_edit tool — batched edits to one file in a single atomic write

Ported from origin/v0.6.0 and adapted to the local codebase (resolveWithinCwd
path guard, no setLastEdit). Reuses edit_file's applyEdit/countOccurrences.

- src/tools/multiEdit.ts: applies an ordered batch of {old_string, new_string,
  replace_all?} edits to one file. Each edit validates against the running
  result (an earlier edit can shift the text a later edit matches), so a
  mismatch names its edit index. One confirmation + one temp+rename atomic
  write instead of N edit_file round-trips — the biggest local-model win,
  since each edit_file is a full generation + permission prompt.
- editFile.ts: export applyEdit + countOccurrences for reuse.
- tools/index.ts: register multi_edit alongside edit_file.
- multiEdit.test.ts: 8 cases (ordered batch, chained edits, error on first
  mismatch w/ index, ambiguity guard, replace_all, path containment, diff
  preview, preview warning).

Verified: typecheck clean, build 254.81 KB, 250 tests pass (+8).
This commit is contained in:
kim
2026-08-21 13:32:51 +09:00
parent 0ddc822238
commit a458b3c478
4 changed files with 233 additions and 2 deletions
+2 -2
View File
@@ -12,11 +12,11 @@ const schema = z.object({
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
});
function countOccurrences(haystack: string, needle: string): number {
export function countOccurrences(haystack: string, needle: string): number {
return needle === "" ? 0 : haystack.split(needle).length - 1;
}
function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
export function applyEdit(original: string, oldString: string, newString: string, replaceAll?: boolean): string {
// Use split/join for both paths instead of String.prototype.replace, whose replacement string
// interprets special $-tokens ($$, $&, $`, $', $<name>, $1–$9) even when the *pattern* is a plain
// string — which would silently corrupt edits whose replacement text contains a literal "$".
+2
View File
@@ -4,6 +4,7 @@ import { bashTool } from "./bash.js";
import { bashKillTool } from "./bashKill.js";
import { bashOutputTool } from "./bashOutput.js";
import { editFileTool } from "./editFile.js";
import { multiEditTool } from "./multiEdit.js";
import { gitCommitTool, gitStatusTool } from "./git.js";
import { grepTool } from "./grep.js";
import { listFilesTool } from "./listFiles.js";
@@ -26,6 +27,7 @@ export const TOOLS: ToolDef[] = [
gitStatusTool,
writeFileTool,
editFileTool,
multiEditTool,
bashTool,
bashOutputTool,
bashKillTool,
+133
View File
@@ -0,0 +1,133 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { multiEditTool } from "./multiEdit.js";
import type { ToolContext } from "./types.js";
describe("multiEdit tool", () => {
let cwd: string;
let ctx: ToolContext;
beforeEach(() => {
cwd = mkdtempSync(path.join(os.tmpdir(), "locode-multiedit-"));
ctx = { cwd };
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it("applies an ordered batch to one file in a single atomic write", async () => {
writeFileSync(path.join(cwd, "code.ts"), "const A = 1;\nconst B = 2;\nconst C = 3;\n");
const result = (await multiEditTool.handler(
{
path: "code.ts",
edits: [
{ old_string: "const A = 1;", new_string: "const A = 10;" },
{ old_string: "const C = 3;", new_string: "const C = 30;" },
],
},
ctx,
)) as { applied: number };
expect(result.applied).toBe(2);
expect(readFileSync(path.join(cwd, "code.ts"), "utf-8")).toBe(
"const A = 10;\nconst B = 2;\nconst C = 30;\n",
);
});
it("an earlier edit can change the text a later edit matches", async () => {
writeFileSync(path.join(cwd, "f.txt"), "alpha\n");
await multiEditTool.handler(
{
path: "f.txt",
edits: [
{ old_string: "alpha", new_string: "beta" },
{ old_string: "beta", new_string: "gamma" },
],
},
ctx,
);
expect(readFileSync(path.join(cwd, "f.txt"), "utf-8")).toBe("gamma\n");
});
it("errors on the first edit that doesn't match, naming the edit index", async () => {
writeFileSync(path.join(cwd, "f.txt"), "alpha\n");
await expect(
multiEditTool.handler(
{
path: "f.txt",
edits: [
{ old_string: "alpha", new_string: "beta" },
{ old_string: "missing", new_string: "x" },
],
},
ctx,
),
).rejects.toThrow(/Edit 2: old_string not found/);
});
it("errors when an old_string is ambiguous and replace_all is not set", async () => {
writeFileSync(path.join(cwd, "f.txt"), "dup\ndup\n");
await expect(
multiEditTool.handler(
{
path: "f.txt",
edits: [{ old_string: "dup", new_string: "x" }],
},
ctx,
),
).rejects.toThrow(/Edit 1: old_string appears 2 times/);
});
it("replace_all applies to all occurrences within the batch step", async () => {
writeFileSync(path.join(cwd, "f.txt"), "dup\ndup\n");
await multiEditTool.handler(
{
path: "f.txt",
edits: [{ old_string: "dup", new_string: "x", replace_all: true }],
},
ctx,
);
expect(readFileSync(path.join(cwd, "f.txt"), "utf-8")).toBe("x\nx\n");
});
it("refuses to edit outside the working directory", async () => {
await expect(
multiEditTool.handler(
{ path: "../escape.txt", edits: [{ old_string: "a", new_string: "b" }] },
ctx,
),
).rejects.toThrow(/outside the working directory/);
});
it("preview produces a unified diff of the full batch", async () => {
writeFileSync(path.join(cwd, "f.txt"), "one\ntwo\n");
const preview = await multiEditTool.preview!(
{
path: "f.txt",
edits: [
{ old_string: "one", new_string: "ONE" },
{ old_string: "two", new_string: "TWO" },
],
},
ctx,
);
expect(preview).toMatch(/-one/);
expect(preview).toMatch(/\+ONE/);
expect(preview).toMatch(/-two/);
expect(preview).toMatch(/\+TWO/);
});
it("preview warns when a batch edit will fail", async () => {
writeFileSync(path.join(cwd, "f.txt"), "one\n");
const preview = await multiEditTool.preview!(
{
path: "f.txt",
edits: [{ old_string: "missing", new_string: "x" }],
},
ctx,
);
expect(preview).toMatch(/Edit 1: old_string not found/);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { createPatch } from "diff";
import { randomBytes } from "node:crypto";
import { readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile } from "node:fs/promises";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
import { applyEdit, countOccurrences } from "./editFile.js";
import type { ToolDef } from "./types.js";
// A single edit within a multi_edit batch. Mirrors edit_file's args minus `path` (which is shared
// across the whole batch). Each edit is applied in array order to the result of the previous one, so
// an earlier edit can shift the text a later edit matches — that's why each old_string is checked
// against the running result, not the original file.
const editSchema = z.object({
old_string: z.string().describe("Exact text to replace. Must match the current file content exactly at this point in the batch — earlier edits may have shifted it."),
new_string: z.string().describe("Replacement text."),
replace_all: z.boolean().optional().describe("Replace every occurrence instead of requiring a unique match."),
});
const schema = z.object({
path: z.string().describe("File path to edit, relative to the working directory or absolute."),
edits: z.array(editSchema).min(1).describe("Ordered list of edits to apply to the same file, one after another. Each edit sees the result of the previous one."),
});
/** Applies a batch of edits to an in-memory string, validating each. Throws on the first edit that
* doesn't match uniquely (unless its replace_all is set) or doesn't match at all. Edits apply to the
* running result, so an earlier edit can change the text a later edit matches. */
function applyBatch(
original: string,
edits: { old_string: string; new_string: string; replace_all?: boolean }[],
filePath: string,
): string {
let current = original;
edits.forEach((edit, i) => {
const occurrences = countOccurrences(current, edit.old_string);
if (occurrences === 0) {
throw new Error(
`Edit ${i + 1}: old_string not found in ${filePath}. Earlier edits may have shifted the text — re-read the file and adjust. Make sure it matches exactly, including whitespace.`,
);
}
if (occurrences > 1 && !edit.replace_all) {
throw new Error(
`Edit ${i + 1}: old_string appears ${occurrences} times in ${filePath}. Provide more surrounding context to make it unique, or set replace_all: true.`,
);
}
current = applyEdit(current, edit.old_string, edit.new_string, edit.replace_all);
});
return current;
}
export const multiEditTool: ToolDef<z.infer<typeof schema>> = {
name: "multi_edit",
description:
"Apply several edits to the same file in one call, in order. Each edit is {old_string, new_string, replace_all?}. " +
"Use this instead of repeated edit_file calls when you have multiple distinct changes to one file — it's one confirmation " +
"and one atomic write instead of N round-trips. Each old_string must match uniquely at its point in the batch " +
"(unless replace_all is set). Read the file first.",
schema,
mutating: true,
preview: async ({ path: filePath, edits }, ctx) => {
let resolved: string;
try {
resolved = resolveWithinCwd(ctx.cwd, filePath);
} catch (err) {
return (err as Error).message;
}
let original: string;
try {
original = await fsReadFile(resolved, "utf-8");
} catch {
return `File ${resolved} does not exist.`;
}
try {
const updated = applyBatch(original, edits, filePath);
return createPatch(resolved, original, updated, "", "");
} catch (err) {
return `Warning: ${(err as Error).message} — this edit will fail.`;
}
},
handler: async ({ path: filePath, edits }, ctx) => {
const resolved = resolveWithinCwd(ctx.cwd, filePath);
const original = await fsReadFile(resolved, "utf-8");
const updated = applyBatch(original, edits, filePath);
// Atomic write via temp+rename (same rationale as edit_file): a crash mid-write can't leave the
// user's source file half-overwritten — the live file stays intact until the rename swaps in the
// full new content. Clean up the temp file if anything fails so a stray `.tmp` doesn't accumulate.
const tmp = `${resolved}.locode-${randomBytes(4).toString("hex")}.tmp`;
try {
await fsWriteFile(tmp, updated, "utf-8");
await fsRename(tmp, resolved);
} catch (err) {
await fsUnlink(tmp).catch(() => {});
throw err;
}
return { path: resolved, applied: edits.length };
},
};