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:
@@ -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 "$".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user