summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-edit-file/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 22:03:19 +0900
committerAdam Malczewski <[email protected]>2026-06-26 22:23:39 +0900
commit727c98c9dae516a2070eb950410314380a20c974 (patch)
tree52aa1022c54f11770be7e4e2a324f0a8b8b8deec /packages/tool-edit-file/src
parente59dc11f63b1df51142259bb2c406af8c9c8c2bb (diff)
downloaddispatch-727c98c9dae516a2070eb950410314380a20c974.tar.gz
dispatch-727c98c9dae516a2070eb950410314380a20c974.zip
style: switch from tabs to 2-space indentation
Diffstat (limited to 'packages/tool-edit-file/src')
-rw-r--r--packages/tool-edit-file/src/edit-file.test.ts786
-rw-r--r--packages/tool-edit-file/src/edit-file.ts474
-rw-r--r--packages/tool-edit-file/src/extension.ts90
3 files changed, 675 insertions, 675 deletions
diff --git a/packages/tool-edit-file/src/edit-file.test.ts b/packages/tool-edit-file/src/edit-file.test.ts
index 9341102..694a59d 100644
--- a/packages/tool-edit-file/src/edit-file.test.ts
+++ b/packages/tool-edit-file/src/edit-file.test.ts
@@ -5,31 +5,31 @@ import { localExecBackend } from "@dispatch/exec-backend";
import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
- computeReplacement,
- createEditFileTool,
- type DiagnosticsHook,
- validateArgs,
+ computeReplacement,
+ createEditFileTool,
+ type DiagnosticsHook,
+ validateArgs,
} from "./edit-file.js";
function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
- return {
- toolCallId: "test-call-1",
- onOutput: () => {},
- signal: AbortSignal.timeout(5000),
- log: createLogger(
- { extensionId: "test" },
- { emit: () => {} },
- { now: () => 0, newId: () => "id" },
- ),
- ...overrides,
- };
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
}
/** No-op diagnostics — the post-edit LSP hook returning "no diagnostics". */
const noopDiagnostics: DiagnosticsHook = async () => ({
- formatted: "",
- slow: false,
- timedOut: false,
+ formatted: "",
+ slow: false,
+ timedOut: false,
});
/**
@@ -40,397 +40,397 @@ const noopDiagnostics: DiagnosticsHook = async () => ({
* build the tool inline.
*/
function makeTool(
- diagnostics: DiagnosticsHook = noopDiagnostics,
+ diagnostics: DiagnosticsHook = noopDiagnostics,
): ReturnType<typeof createEditFileTool> {
- return createEditFileTool({
- resolveBackend: () => localExecBackend,
- workdir,
- diagnostics,
- });
+ return createEditFileTool({
+ resolveBackend: () => localExecBackend,
+ workdir,
+ diagnostics,
+ });
}
let workdir: string;
beforeEach(async () => {
- workdir = await mkdtemp(join(tmpdir(), "tool-edit-file-test-"));
+ workdir = await mkdtemp(join(tmpdir(), "tool-edit-file-test-"));
});
afterEach(async () => {
- await rm(workdir, { recursive: true, force: true });
+ await rm(workdir, { recursive: true, force: true });
});
describe("validateArgs", () => {
- it("returns validated args for valid input", () => {
- const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
- expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: false });
- });
-
- it("parses replaceAll true", () => {
- const result = validateArgs({
- path: "f.txt",
- oldString: "a",
- newString: "b",
- replaceAll: true,
- });
- expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: true });
- });
-
- it("defaults replaceAll to false when omitted", () => {
- const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
- expect(result).toHaveProperty("replaceAll", false);
- });
-
- it("returns error for null args", () => {
- const result = validateArgs(null);
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for missing path", () => {
- const result = validateArgs({ oldString: "a", newString: "b" });
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for missing oldString", () => {
- const result = validateArgs({ path: "f.txt", newString: "b" });
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for missing newString", () => {
- const result = validateArgs({ path: "f.txt", oldString: "a" });
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for non-string path", () => {
- const result = validateArgs({ path: 123, oldString: "a", newString: "b" });
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for non-string oldString", () => {
- const result = validateArgs({ path: "f.txt", oldString: 123, newString: "b" });
- expect(result).toHaveProperty("error");
- });
+ it("returns validated args for valid input", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
+ expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: false });
+ });
+
+ it("parses replaceAll true", () => {
+ const result = validateArgs({
+ path: "f.txt",
+ oldString: "a",
+ newString: "b",
+ replaceAll: true,
+ });
+ expect(result).toEqual({ path: "f.txt", oldString: "a", newString: "b", replaceAll: true });
+ });
+
+ it("defaults replaceAll to false when omitted", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("replaceAll", false);
+ });
+
+ it("returns error for null args", () => {
+ const result = validateArgs(null);
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing path", () => {
+ const result = validateArgs({ oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing oldString", () => {
+ const result = validateArgs({ path: "f.txt", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing newString", () => {
+ const result = validateArgs({ path: "f.txt", oldString: "a" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-string path", () => {
+ const result = validateArgs({ path: 123, oldString: "a", newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-string oldString", () => {
+ const result = validateArgs({ path: "f.txt", oldString: 123, newString: "b" });
+ expect(result).toHaveProperty("error");
+ });
});
describe("computeReplacement", () => {
- it("replaces a single occurrence", () => {
- const result = computeReplacement("hello world", "world", "there", false);
- expect(result).toEqual({ content: "hello there", count: 1 });
- });
-
- it("replaces all occurrences when replaceAll is true", () => {
- const result = computeReplacement("aaa", "a", "b", true);
- expect(result).toEqual({ content: "bbb", count: 3 });
- });
-
- it("returns identical error when newString equals oldString", () => {
- const result = computeReplacement("hello", "hello", "hello", false);
- expect(result).toEqual({ kind: "identical" });
- });
-
- it("returns notFound error when oldString is not in content", () => {
- const result = computeReplacement("hello", "xyz", "abc", false);
- expect(result).toEqual({ kind: "notFound" });
- });
-
- it("returns notUnique error when oldString occurs multiple times and replaceAll is false", () => {
- const result = computeReplacement("abc abc abc", "abc", "xyz", false);
- expect(result).toEqual({ kind: "notUnique", count: 3 });
- });
-
- it("replaces only the single match when unique", () => {
- const result = computeReplacement("foo bar baz", "bar", "qux", false);
- expect(result).toEqual({ content: "foo qux baz", count: 1 });
- });
-
- it("handles replaceAll with multiple occurrences", () => {
- const result = computeReplacement("one two one two", "two", "three", true);
- expect(result).toEqual({ content: "one three one three", count: 2 });
- });
-
- it("handles empty oldString as notFound (empty string not searched)", () => {
- // empty oldString would cause infinite loop in split, so we treat it as not-found
- const result = computeReplacement("hello", "", "x", false);
- expect(result).toEqual({ kind: "notFound" });
- });
-
- it("handles oldString at start of content", () => {
- const result = computeReplacement("hello world", "hello", "goodbye", false);
- expect(result).toEqual({ content: "goodbye world", count: 1 });
- });
-
- it("handles oldString at end of content", () => {
- const result = computeReplacement("hello world", "world", "there", false);
- expect(result).toEqual({ content: "hello there", count: 1 });
- });
-
- it("handles multiline oldString and newString", () => {
- const content = "line1\nold line\nline3";
- const result = computeReplacement(content, "old line", "new line", false);
- expect(result).toEqual({ content: "line1\nnew line\nline3", count: 1 });
- });
+ it("replaces a single occurrence", () => {
+ const result = computeReplacement("hello world", "world", "there", false);
+ expect(result).toEqual({ content: "hello there", count: 1 });
+ });
+
+ it("replaces all occurrences when replaceAll is true", () => {
+ const result = computeReplacement("aaa", "a", "b", true);
+ expect(result).toEqual({ content: "bbb", count: 3 });
+ });
+
+ it("returns identical error when newString equals oldString", () => {
+ const result = computeReplacement("hello", "hello", "hello", false);
+ expect(result).toEqual({ kind: "identical" });
+ });
+
+ it("returns notFound error when oldString is not in content", () => {
+ const result = computeReplacement("hello", "xyz", "abc", false);
+ expect(result).toEqual({ kind: "notFound" });
+ });
+
+ it("returns notUnique error when oldString occurs multiple times and replaceAll is false", () => {
+ const result = computeReplacement("abc abc abc", "abc", "xyz", false);
+ expect(result).toEqual({ kind: "notUnique", count: 3 });
+ });
+
+ it("replaces only the single match when unique", () => {
+ const result = computeReplacement("foo bar baz", "bar", "qux", false);
+ expect(result).toEqual({ content: "foo qux baz", count: 1 });
+ });
+
+ it("handles replaceAll with multiple occurrences", () => {
+ const result = computeReplacement("one two one two", "two", "three", true);
+ expect(result).toEqual({ content: "one three one three", count: 2 });
+ });
+
+ it("handles empty oldString as notFound (empty string not searched)", () => {
+ // empty oldString would cause infinite loop in split, so we treat it as not-found
+ const result = computeReplacement("hello", "", "x", false);
+ expect(result).toEqual({ kind: "notFound" });
+ });
+
+ it("handles oldString at start of content", () => {
+ const result = computeReplacement("hello world", "hello", "goodbye", false);
+ expect(result).toEqual({ content: "goodbye world", count: 1 });
+ });
+
+ it("handles oldString at end of content", () => {
+ const result = computeReplacement("hello world", "world", "there", false);
+ expect(result).toEqual({ content: "hello there", count: 1 });
+ });
+
+ it("handles multiline oldString and newString", () => {
+ const content = "line1\nold line\nline3";
+ const result = computeReplacement(content, "old line", "new line", false);
+ expect(result).toEqual({ content: "line1\nnew line\nline3", count: 1 });
+ });
});
describe("createEditFileTool", () => {
- it("replaces a single occurrence", async () => {
- const filePath = join(workdir, "test.txt");
- await writeFile(filePath, "hello world\n", "utf8");
-
- const tool = makeTool();
- const result = await tool.execute(
- { path: "test.txt", oldString: "world", newString: "there" },
- stubCtx(),
- );
-
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("Replaced 1 occurrence");
-
- const content = await readFile(filePath, "utf8");
- expect(content).toBe("hello there\n");
- });
-
- it("replaces all occurrences when replaceAll is true", async () => {
- const filePath = join(workdir, "test.txt");
- await writeFile(filePath, "aaa\n", "utf8");
-
- const tool = makeTool();
- const result = await tool.execute(
- { path: "test.txt", oldString: "a", newString: "b", replaceAll: true },
- stubCtx(),
- );
-
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("Replaced 3 occurrences");
-
- const content = await readFile(filePath, "utf8");
- expect(content).toBe("bbb\n");
- });
-
- it("errors when oldString is not found", async () => {
- const filePath = join(workdir, "test.txt");
- await writeFile(filePath, "hello\n", "utf8");
-
- const tool = makeTool();
- const result = await tool.execute(
- { path: "test.txt", oldString: "xyz", newString: "abc" },
- stubCtx(),
- );
-
- expect(result.isError).toBe(true);
- expect(result.content).toContain("oldString not found");
- });
-
- it("errors when oldString is non-unique and replaceAll is false", async () => {
- const filePath = join(workdir, "test.txt");
- await writeFile(filePath, "abc abc abc\n", "utf8");
-
- const tool = makeTool();
- const result = await tool.execute(
- { path: "test.txt", oldString: "abc", newString: "xyz" },
- stubCtx(),
- );
-
- expect(result.isError).toBe(true);
- expect(result.content).toContain("Found 3 matches");
- });
-
- it("errors when newString equals oldString", async () => {
- const filePath = join(workdir, "test.txt");
- await writeFile(filePath, "hello\n", "utf8");
-
- const tool = makeTool();
- const result = await tool.execute(
- { path: "test.txt", oldString: "hello", newString: "hello" },
- stubCtx(),
- );
-
- expect(result.isError).toBe(true);
- expect(result.content).toContain("newString must differ from oldString");
- });
-
- it("errors / not-found for a nonexistent file", async () => {
- const tool = makeTool();
- const result = await tool.execute(
- { path: "nonexistent.txt", oldString: "a", newString: "b" },
- stubCtx(),
- );
-
- expect(result.isError).toBe(true);
- expect(result.content).toContain("not found");
- });
-
- it("reads file under ctx.cwd when set", async () => {
- const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
- try {
- const filePath = join(ctxDir, "ctx-file.txt");
- await writeFile(filePath, "hello world", "utf8");
-
- const tool = makeTool();
- const result = await tool.execute(
- { path: "ctx-file.txt", oldString: "world", newString: "there" },
- stubCtx({ cwd: ctxDir }),
- );
-
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("Replaced 1 occurrence");
-
- const content = await readFile(filePath, "utf8");
- expect(content).toBe("hello there");
- } finally {
- await rm(ctxDir, { recursive: true, force: true });
- }
- });
-
- it("falls back to baked workdir when ctx.cwd is omitted", async () => {
- const filePath = join(workdir, "baked-file.txt");
- await writeFile(filePath, "hello world", "utf8");
-
- const tool = makeTool();
- const ctx = stubCtx();
- expect(ctx.cwd).toBeUndefined();
- const result = await tool.execute(
- { path: "baked-file.txt", oldString: "world", newString: "there" },
- ctx,
- );
-
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("Replaced 1 occurrence");
- });
-
- it("never throws on bad input (always returns ToolResult)", async () => {
- const tool = makeTool();
-
- const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
- for (const input of inputs) {
- const result = await tool.execute(input, stubCtx());
- expect(result).toHaveProperty("content");
- expect(typeof result.content).toBe("string");
- }
- });
-
- it("concurrencySafe is false", () => {
- const tool = makeTool();
- expect(tool.concurrencySafe).toBe(false);
- });
-
- it("has correct name and parameters shape", () => {
- const tool = makeTool();
- expect(tool.name).toBe("edit_file");
- expect(tool.parameters.type).toBe("object");
- expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]);
- expect(tool.parameters.properties?.path?.type).toBe("string");
- expect(tool.parameters.properties?.oldString?.type).toBe("string");
- expect(tool.parameters.properties?.newString?.type).toBe("string");
- expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean");
- });
-
- it("appends LSP diagnostics to the result when local and errors exist", async () => {
- const filePath = join(workdir, "diag.txt");
- await writeFile(filePath, "hello world\n", "utf8");
-
- let called = false;
- const diagnostics: DiagnosticsHook = async (opts) => {
- called = true;
- expect(opts.text).toBe("hello there\n");
- return { formatted: "⚠️ 2 errors", slow: false, timedOut: false };
- };
- const tool = makeTool(diagnostics);
-
- const result = await tool.execute(
- { path: "diag.txt", oldString: "world", newString: "there" },
- stubCtx(),
- );
-
- expect(called).toBe(true);
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("Replaced 1 occurrence");
- expect(result.content).toContain("⚠️ 2 errors");
- });
-
- it("appends the slow-diagnostics notice when LSP is slow", async () => {
- const filePath = join(workdir, "slow.txt");
- await writeFile(filePath, "hello\n", "utf8");
-
- const diagnostics: DiagnosticsHook = async () => ({
- formatted: "",
- slow: true,
- timedOut: false,
- });
- const tool = makeTool(diagnostics);
-
- const result = await tool.execute(
- { path: "slow.txt", oldString: "hello", newString: "hi" },
- stubCtx(),
- );
-
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("Replaced 1 occurrence");
- expect(result.content).toContain("LSP is taking unusually long");
- });
-
- it("calls LSP diagnostics when local (computerId undefined)", async () => {
- const filePath = join(workdir, "local.txt");
- await writeFile(filePath, "hello\n", "utf8");
-
- let called = false;
- const diagnostics: DiagnosticsHook = async () => {
- called = true;
- return { formatted: "", slow: false, timedOut: false };
- };
- const tool = makeTool(diagnostics);
-
- const result = await tool.execute(
- { path: "local.txt", oldString: "hello", newString: "hi" },
- stubCtx(), // computerId omitted → undefined → local
- );
-
- expect(called).toBe(true);
- expect(result.isError).toBeUndefined();
- expect(result.content).toBe('Replaced 1 occurrence in "local.txt".');
- });
-
- it("skips LSP diagnostics when computerId is set (remote)", async () => {
- const filePath = join(workdir, "remote.txt");
- await writeFile(filePath, "hello\n", "utf8");
-
- let called = false;
- const diagnostics: DiagnosticsHook = async () => {
- called = true;
- return { formatted: "DIAG-SHOULD-NOT-APPEAR", slow: false, timedOut: false };
- };
- const tool = makeTool(diagnostics);
-
- const result = await tool.execute(
- { path: "remote.txt", oldString: "hello", newString: "hi" },
- stubCtx({ computerId: "remote-host" }),
- );
-
- // Remote: the diagnostics hook is never invoked (LSP servers are local
- // processes that can't see remote files over SFTP).
- expect(called).toBe(false);
- expect(result.isError).toBeUndefined();
- // The edit itself still succeeded against the (local) backend.
- expect(result.content).toBe('Replaced 1 occurrence in "remote.txt".');
- expect(result.content).not.toContain("DIAG-SHOULD-NOT-APPEAR");
-
- const content = await readFile(filePath, "utf8");
- expect(content).toBe("hi\n");
- });
-
- it("swallows a throwing diagnostics hook (edit already succeeded)", async () => {
- const filePath = join(workdir, "throw.txt");
- await writeFile(filePath, "hello\n", "utf8");
-
- const diagnostics: DiagnosticsHook = async () => {
- throw new Error("LSP exploded");
- };
- const tool = makeTool(diagnostics);
-
- const result = await tool.execute(
- { path: "throw.txt", oldString: "hello", newString: "hi" },
- stubCtx(),
- );
-
- expect(result.isError).toBeUndefined();
- expect(result.content).toBe('Replaced 1 occurrence in "throw.txt".');
- });
+ it("replaces a single occurrence", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello world\n", "utf8");
+
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "world", newString: "there" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hello there\n");
+ });
+
+ it("replaces all occurrences when replaceAll is true", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "aaa\n", "utf8");
+
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "a", newString: "b", replaceAll: true },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 3 occurrences");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("bbb\n");
+ });
+
+ it("errors when oldString is not found", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "xyz", newString: "abc" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("oldString not found");
+ });
+
+ it("errors when oldString is non-unique and replaceAll is false", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "abc abc abc\n", "utf8");
+
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "abc", newString: "xyz" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Found 3 matches");
+ });
+
+ it("errors when newString equals oldString", async () => {
+ const filePath = join(workdir, "test.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "test.txt", oldString: "hello", newString: "hello" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("newString must differ from oldString");
+ });
+
+ it("errors / not-found for a nonexistent file", async () => {
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "nonexistent.txt", oldString: "a", newString: "b" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("not found");
+ });
+
+ it("reads file under ctx.cwd when set", async () => {
+ const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-"));
+ try {
+ const filePath = join(ctxDir, "ctx-file.txt");
+ await writeFile(filePath, "hello world", "utf8");
+
+ const tool = makeTool();
+ const result = await tool.execute(
+ { path: "ctx-file.txt", oldString: "world", newString: "there" },
+ stubCtx({ cwd: ctxDir }),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hello there");
+ } finally {
+ await rm(ctxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ const filePath = join(workdir, "baked-file.txt");
+ await writeFile(filePath, "hello world", "utf8");
+
+ const tool = makeTool();
+ const ctx = stubCtx();
+ expect(ctx.cwd).toBeUndefined();
+ const result = await tool.execute(
+ { path: "baked-file.txt", oldString: "world", newString: "there" },
+ ctx,
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+ });
+
+ it("never throws on bad input (always returns ToolResult)", async () => {
+ const tool = makeTool();
+
+ const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
+ for (const input of inputs) {
+ const result = await tool.execute(input, stubCtx());
+ expect(result).toHaveProperty("content");
+ expect(typeof result.content).toBe("string");
+ }
+ });
+
+ it("concurrencySafe is false", () => {
+ const tool = makeTool();
+ expect(tool.concurrencySafe).toBe(false);
+ });
+
+ it("has correct name and parameters shape", () => {
+ const tool = makeTool();
+ expect(tool.name).toBe("edit_file");
+ expect(tool.parameters.type).toBe("object");
+ expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]);
+ expect(tool.parameters.properties?.path?.type).toBe("string");
+ expect(tool.parameters.properties?.oldString?.type).toBe("string");
+ expect(tool.parameters.properties?.newString?.type).toBe("string");
+ expect(tool.parameters.properties?.replaceAll?.type).toBe("boolean");
+ });
+
+ it("appends LSP diagnostics to the result when local and errors exist", async () => {
+ const filePath = join(workdir, "diag.txt");
+ await writeFile(filePath, "hello world\n", "utf8");
+
+ let called = false;
+ const diagnostics: DiagnosticsHook = async (opts) => {
+ called = true;
+ expect(opts.text).toBe("hello there\n");
+ return { formatted: "⚠️ 2 errors", slow: false, timedOut: false };
+ };
+ const tool = makeTool(diagnostics);
+
+ const result = await tool.execute(
+ { path: "diag.txt", oldString: "world", newString: "there" },
+ stubCtx(),
+ );
+
+ expect(called).toBe(true);
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+ expect(result.content).toContain("⚠️ 2 errors");
+ });
+
+ it("appends the slow-diagnostics notice when LSP is slow", async () => {
+ const filePath = join(workdir, "slow.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const diagnostics: DiagnosticsHook = async () => ({
+ formatted: "",
+ slow: true,
+ timedOut: false,
+ });
+ const tool = makeTool(diagnostics);
+
+ const result = await tool.execute(
+ { path: "slow.txt", oldString: "hello", newString: "hi" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("Replaced 1 occurrence");
+ expect(result.content).toContain("LSP is taking unusually long");
+ });
+
+ it("calls LSP diagnostics when local (computerId undefined)", async () => {
+ const filePath = join(workdir, "local.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ let called = false;
+ const diagnostics: DiagnosticsHook = async () => {
+ called = true;
+ return { formatted: "", slow: false, timedOut: false };
+ };
+ const tool = makeTool(diagnostics);
+
+ const result = await tool.execute(
+ { path: "local.txt", oldString: "hello", newString: "hi" },
+ stubCtx(), // computerId omitted → undefined → local
+ );
+
+ expect(called).toBe(true);
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe('Replaced 1 occurrence in "local.txt".');
+ });
+
+ it("skips LSP diagnostics when computerId is set (remote)", async () => {
+ const filePath = join(workdir, "remote.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ let called = false;
+ const diagnostics: DiagnosticsHook = async () => {
+ called = true;
+ return { formatted: "DIAG-SHOULD-NOT-APPEAR", slow: false, timedOut: false };
+ };
+ const tool = makeTool(diagnostics);
+
+ const result = await tool.execute(
+ { path: "remote.txt", oldString: "hello", newString: "hi" },
+ stubCtx({ computerId: "remote-host" }),
+ );
+
+ // Remote: the diagnostics hook is never invoked (LSP servers are local
+ // processes that can't see remote files over SFTP).
+ expect(called).toBe(false);
+ expect(result.isError).toBeUndefined();
+ // The edit itself still succeeded against the (local) backend.
+ expect(result.content).toBe('Replaced 1 occurrence in "remote.txt".');
+ expect(result.content).not.toContain("DIAG-SHOULD-NOT-APPEAR");
+
+ const content = await readFile(filePath, "utf8");
+ expect(content).toBe("hi\n");
+ });
+
+ it("swallows a throwing diagnostics hook (edit already succeeded)", async () => {
+ const filePath = join(workdir, "throw.txt");
+ await writeFile(filePath, "hello\n", "utf8");
+
+ const diagnostics: DiagnosticsHook = async () => {
+ throw new Error("LSP exploded");
+ };
+ const tool = makeTool(diagnostics);
+
+ const result = await tool.execute(
+ { path: "throw.txt", oldString: "hello", newString: "hi" },
+ stubCtx(),
+ );
+
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toBe('Replaced 1 occurrence in "throw.txt".');
+ });
});
diff --git a/packages/tool-edit-file/src/edit-file.ts b/packages/tool-edit-file/src/edit-file.ts
index e588f66..2704bb6 100644
--- a/packages/tool-edit-file/src/edit-file.ts
+++ b/packages/tool-edit-file/src/edit-file.ts
@@ -5,102 +5,102 @@ import type { ToolContract, ToolResult } from "@dispatch/kernel";
// --- Pure types ---
interface ValidatedArgs {
- readonly path: string;
- readonly oldString: string;
- readonly newString: string;
- readonly replaceAll: boolean;
+ readonly path: string;
+ readonly oldString: string;
+ readonly newString: string;
+ readonly replaceAll: boolean;
}
export type ReplacementError =
- | { readonly kind: "identical" }
- | { readonly kind: "notFound" }
- | { readonly kind: "notUnique"; readonly count: number };
+ | { readonly kind: "identical" }
+ | { readonly kind: "notFound" }
+ | { readonly kind: "notUnique"; readonly count: number };
export interface ReplacementSuccess {
- readonly content: string;
- readonly count: number;
+ readonly content: string;
+ readonly count: number;
}
// --- Pure functions ---
/** Pure: validate and coerce args from the model. */
export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } {
- if (args === null || args === undefined || typeof args !== "object") {
- return { error: "Error: Arguments must be an object." };
- }
- const obj = args as Record<string, unknown>;
-
- const rawPath = obj.path;
- if (typeof rawPath !== "string" || rawPath.length === 0) {
- return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' };
- }
-
- const rawOld = obj.oldString;
- if (typeof rawOld !== "string" || rawOld.length === 0) {
- return {
- error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).',
- };
- }
-
- const rawNew = obj.newString;
- if (typeof rawNew !== "string") {
- return {
- error: 'Error: Missing or invalid "newString" parameter (must be a string).',
- };
- }
-
- const rawReplaceAll = obj.replaceAll;
- const replaceAll = rawReplaceAll === true;
-
- return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll };
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawPath = obj.path;
+ if (typeof rawPath !== "string" || rawPath.length === 0) {
+ return { error: 'Error: Missing or invalid "path" parameter (must be a non-empty string).' };
+ }
+
+ const rawOld = obj.oldString;
+ if (typeof rawOld !== "string" || rawOld.length === 0) {
+ return {
+ error: 'Error: Missing or invalid "oldString" parameter (must be a non-empty string).',
+ };
+ }
+
+ const rawNew = obj.newString;
+ if (typeof rawNew !== "string") {
+ return {
+ error: 'Error: Missing or invalid "newString" parameter (must be a string).',
+ };
+ }
+
+ const rawReplaceAll = obj.replaceAll;
+ const replaceAll = rawReplaceAll === true;
+
+ return { path: rawPath, oldString: rawOld, newString: rawNew, replaceAll };
}
/** Pure: compute the replacement result given file content + params. */
export function computeReplacement(
- content: string,
- oldString: string,
- newString: string,
- replaceAll: boolean,
+ content: string,
+ oldString: string,
+ newString: string,
+ replaceAll: boolean,
): ReplacementSuccess | ReplacementError {
- if (oldString === newString) {
- return { kind: "identical" };
- }
-
- if (oldString === "") {
- return { kind: "notFound" };
- }
-
- if (!content.includes(oldString)) {
- return { kind: "notFound" };
- }
-
- if (replaceAll) {
- const parts = content.split(oldString);
- const count = parts.length - 1;
- return { content: parts.join(newString), count };
- }
-
- // Single replacement — check uniqueness.
- const firstIndex = content.indexOf(oldString);
- const secondIndex = content.indexOf(oldString, firstIndex + oldString.length);
- if (secondIndex !== -1) {
- // Count total occurrences.
- let count = 0;
- let idx = 0;
- while (true) {
- idx = content.indexOf(oldString, idx);
- if (idx === -1) break;
- count++;
- idx += oldString.length;
- }
- return { kind: "notUnique", count };
- }
-
- return {
- content:
- content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length),
- count: 1,
- };
+ if (oldString === newString) {
+ return { kind: "identical" };
+ }
+
+ if (oldString === "") {
+ return { kind: "notFound" };
+ }
+
+ if (!content.includes(oldString)) {
+ return { kind: "notFound" };
+ }
+
+ if (replaceAll) {
+ const parts = content.split(oldString);
+ const count = parts.length - 1;
+ return { content: parts.join(newString), count };
+ }
+
+ // Single replacement — check uniqueness.
+ const firstIndex = content.indexOf(oldString);
+ const secondIndex = content.indexOf(oldString, firstIndex + oldString.length);
+ if (secondIndex !== -1) {
+ // Count total occurrences.
+ let count = 0;
+ let idx = 0;
+ while (true) {
+ idx = content.indexOf(oldString, idx);
+ if (idx === -1) break;
+ count++;
+ idx += oldString.length;
+ }
+ return { kind: "notUnique", count };
+ }
+
+ return {
+ content:
+ content.slice(0, firstIndex) + newString + content.slice(firstIndex + oldString.length),
+ count: 1,
+ };
}
// --- Diagnostics hook ---
@@ -111,13 +111,13 @@ export function computeReplacement(
* service; absent when no LSP is available (graceful degradation).
*/
export type DiagnosticsHook = (opts: {
- readonly filePath: string;
- readonly text: string;
- readonly cwd: string;
+ readonly filePath: string;
+ readonly text: string;
+ readonly cwd: string;
}) => Promise<{
- readonly formatted: string;
- readonly slow: boolean;
- readonly timedOut: boolean;
+ readonly formatted: string;
+ readonly slow: boolean;
+ readonly timedOut: boolean;
}>;
// --- Shell / edge ---
@@ -141,156 +141,156 @@ export type DiagnosticsHook = (opts: {
* remote files over SFTP, so the no-LSP degradation path is used instead.
*/
export function createEditFileTool(deps: {
- readonly resolveBackend: ExecBackendResolver;
- readonly workdir?: string;
- readonly diagnostics: DiagnosticsHook;
+ readonly resolveBackend: ExecBackendResolver;
+ readonly workdir?: string;
+ readonly diagnostics: DiagnosticsHook;
}): ToolContract {
- const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined;
-
- return {
- name: "edit_file",
- description:
- "Perform an exact string replacement in an existing file. " +
- "Provide oldString (the text to find) and newString (the replacement). " +
- "By default replaces a single occurrence; set replaceAll to replace every match.",
- parameters: {
- type: "object",
- properties: {
- path: {
- type: "string",
- description: "Path to the file, relative to the working directory.",
- },
- oldString: {
- type: "string",
- description: "The exact string to find and replace.",
- },
- newString: {
- type: "string",
- description: "The string to replace oldString with.",
- },
- replaceAll: {
- type: "boolean",
- description: "Replace all occurrences (default: false).",
- default: false,
- },
- },
- required: ["path", "oldString", "newString"],
- },
- concurrencySafe: false,
- async execute(args: unknown, ctx): Promise<ToolResult> {
- const validated = validateArgs(args);
- if ("error" in validated) {
- return { content: validated.error, isError: true };
- }
-
- const { path: relPath, oldString, newString, replaceAll } = validated;
-
- const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
- if (effectiveBase === undefined) {
- return {
- content:
- "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).",
- isError: true,
- };
- }
- const resolvedPath = resolve(effectiveBase, relPath);
-
- const backend: ExecBackend = deps.resolveBackend(ctx.computerId);
-
- // Read the file.
- let content: string;
- try {
- content = await backend.readFile(resolvedPath);
- } catch (err: unknown) {
- const code = (err as NodeJS.ErrnoException).code;
- if (code === "ENOENT") {
- return { content: `Error: File "${relPath}" not found.`, isError: true };
- }
- return {
- content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`,
- isError: true,
- };
- }
-
- // Pure replacement decision.
- const result = computeReplacement(content, oldString, newString, replaceAll);
-
- if ("kind" in result) {
- switch (result.kind) {
- case "identical":
- return {
- content: "Error: newString must differ from oldString.",
- isError: true,
- };
- case "notFound":
- return {
- content: `Error: oldString not found in content of "${relPath}".`,
- isError: true,
- };
- case "notUnique":
- return {
- content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`,
- isError: true,
- };
- }
- }
-
- // Write the modified content back.
- try {
- await backend.writeFile(resolvedPath, result.content);
- } catch (err: unknown) {
- return {
- content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
- isError: true,
- };
- }
-
- const plural = result.count === 1 ? "" : "s";
- let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`;
-
- // After a successful edit, query LSP diagnostics (if available).
- // Only append if there are actual errors/warnings (no noise on clean edits).
- const diagnostics = deps.diagnostics;
- if (diagnostics) {
- let diag: {
- readonly formatted: string;
- readonly slow: boolean;
- readonly timedOut: boolean;
- };
- if (ctx.computerId !== undefined) {
- // REMOTE: LSP servers are local processes that can't see remote
- // files over SFTP — skip the diagnostics call (the no-LSP
- // degradation path). Forward-compatible: computerId is always
- // undefined this wave, so behavior is byte-identical to today.
- diag = { formatted: "", slow: false, timedOut: false };
- } else {
- try {
- const cwd = ctx.cwd ?? process.cwd();
- diag = await diagnostics({
- filePath: resolvedPath,
- text: result.content,
- cwd,
- });
- } catch {
- // LSP diagnostics failure is non-fatal — the edit already succeeded.
- diag = { formatted: "", slow: false, timedOut: false };
- }
- }
- const suffix: string[] = [];
- if (diag.slow) {
- suffix.push(
- "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.",
- );
- }
- if (diag.formatted) {
- suffix.push(diag.formatted);
- }
- if (suffix.length > 0) {
- baseContent += `\n\n${suffix.join("\n\n")}`;
- }
- }
-
- return { content: baseContent };
- },
- };
+ const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined;
+
+ return {
+ name: "edit_file",
+ description:
+ "Perform an exact string replacement in an existing file. " +
+ "Provide oldString (the text to find) and newString (the replacement). " +
+ "By default replaces a single occurrence; set replaceAll to replace every match.",
+ parameters: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "Path to the file, relative to the working directory.",
+ },
+ oldString: {
+ type: "string",
+ description: "The exact string to find and replace.",
+ },
+ newString: {
+ type: "string",
+ description: "The string to replace oldString with.",
+ },
+ replaceAll: {
+ type: "boolean",
+ description: "Replace all occurrences (default: false).",
+ default: false,
+ },
+ },
+ required: ["path", "oldString", "newString"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { path: relPath, oldString, newString, replaceAll } = validated;
+
+ const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir;
+ if (effectiveBase === undefined) {
+ return {
+ content:
+ "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).",
+ isError: true,
+ };
+ }
+ const resolvedPath = resolve(effectiveBase, relPath);
+
+ const backend: ExecBackend = deps.resolveBackend(ctx.computerId);
+
+ // Read the file.
+ let content: string;
+ try {
+ content = await backend.readFile(resolvedPath);
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return { content: `Error: File "${relPath}" not found.`, isError: true };
+ }
+ return {
+ content: `Error reading file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ // Pure replacement decision.
+ const result = computeReplacement(content, oldString, newString, replaceAll);
+
+ if ("kind" in result) {
+ switch (result.kind) {
+ case "identical":
+ return {
+ content: "Error: newString must differ from oldString.",
+ isError: true,
+ };
+ case "notFound":
+ return {
+ content: `Error: oldString not found in content of "${relPath}".`,
+ isError: true,
+ };
+ case "notUnique":
+ return {
+ content: `Error: Found ${result.count} matches for oldString in "${relPath}"; provide more surrounding context to make it unique, or set replaceAll.`,
+ isError: true,
+ };
+ }
+ }
+
+ // Write the modified content back.
+ try {
+ await backend.writeFile(resolvedPath, result.content);
+ } catch (err: unknown) {
+ return {
+ content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ const plural = result.count === 1 ? "" : "s";
+ let baseContent = `Replaced ${result.count} occurrence${plural} in "${relPath}".`;
+
+ // After a successful edit, query LSP diagnostics (if available).
+ // Only append if there are actual errors/warnings (no noise on clean edits).
+ const diagnostics = deps.diagnostics;
+ if (diagnostics) {
+ let diag: {
+ readonly formatted: string;
+ readonly slow: boolean;
+ readonly timedOut: boolean;
+ };
+ if (ctx.computerId !== undefined) {
+ // REMOTE: LSP servers are local processes that can't see remote
+ // files over SFTP — skip the diagnostics call (the no-LSP
+ // degradation path). Forward-compatible: computerId is always
+ // undefined this wave, so behavior is byte-identical to today.
+ diag = { formatted: "", slow: false, timedOut: false };
+ } else {
+ try {
+ const cwd = ctx.cwd ?? process.cwd();
+ diag = await diagnostics({
+ filePath: resolvedPath,
+ text: result.content,
+ cwd,
+ });
+ } catch {
+ // LSP diagnostics failure is non-fatal — the edit already succeeded.
+ diag = { formatted: "", slow: false, timedOut: false };
+ }
+ }
+ const suffix: string[] = [];
+ if (diag.slow) {
+ suffix.push(
+ "⚠️ LSP is taking unusually long. If this happens more than once, raise it to the user.",
+ );
+ }
+ if (diag.formatted) {
+ suffix.push(diag.formatted);
+ }
+ if (suffix.length > 0) {
+ baseContent += `\n\n${suffix.join("\n\n")}`;
+ }
+ }
+
+ return { content: baseContent };
+ },
+ };
}
diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts
index 9dbebda..b50247c 100644
--- a/packages/tool-edit-file/src/extension.ts
+++ b/packages/tool-edit-file/src/extension.ts
@@ -4,51 +4,51 @@ import { type LspService, lspServiceHandle } from "@dispatch/lsp";
import { createEditFileTool, type DiagnosticsHook } from "./edit-file.js";
export const extension: Extension = {
- manifest: {
- id: "tool-edit-file",
- name: "Edit File Tool",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- activation: "eager",
- capabilities: { fs: true },
- contributes: { tools: ["edit_file"] },
- // Host activates exec-backend first → host.getService at activation is safe.
- // LSP stays lazy (looked up at edit time, not activation): the LSP extension
- // activates AFTER us in the CORE_EXTENSIONS array, so resolving it here would
- // throw; the diagnostics hook below defers the lookup to execute().
- dependsOn: ["exec-backend"],
- },
- activate(host) {
- const resolveBackend = host.getService(execBackendHandle);
+ manifest: {
+ id: "tool-edit-file",
+ name: "Edit File Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { fs: true },
+ contributes: { tools: ["edit_file"] },
+ // Host activates exec-backend first → host.getService at activation is safe.
+ // LSP stays lazy (looked up at edit time, not activation): the LSP extension
+ // activates AFTER us in the CORE_EXTENSIONS array, so resolving it here would
+ // throw; the diagnostics hook below defers the lookup to execute().
+ dependsOn: ["exec-backend"],
+ },
+ activate(host) {
+ const resolveBackend = host.getService(execBackendHandle);
- // Lazy LSP lookup: the LSP extension activates AFTER us in the
- // CORE_EXTENSIONS array, so host.getService would throw at activation
- // time. Instead, defer the lookup to edit time — by then all extensions
- // have activated. If LSP isn't loaded, the try/catch returns a no-op
- // (graceful degradation: edits proceed without diagnostics).
- const diagnostics: DiagnosticsHook = async (opts) => {
- let lspService: LspService | undefined;
- try {
- lspService = host.getService(lspServiceHandle);
- } catch {
- return { formatted: "", slow: false, timedOut: false };
- }
- if (!lspService) {
- return { formatted: "", slow: false, timedOut: false };
- }
- return lspService.getDiagnostics({
- filePath: opts.filePath,
- text: opts.text,
- cwd: opts.cwd,
- // 10s matches the LSP service's per-server cap (see packages/lsp).
- // The service clamps this anyway; stated explicitly so the call
- // site is honest about the effective live-diagnostics budget.
- timeoutMs: 10_000,
- minSeverity: 2, // errors + warnings only
- });
- };
+ // Lazy LSP lookup: the LSP extension activates AFTER us in the
+ // CORE_EXTENSIONS array, so host.getService would throw at activation
+ // time. Instead, defer the lookup to edit time — by then all extensions
+ // have activated. If LSP isn't loaded, the try/catch returns a no-op
+ // (graceful degradation: edits proceed without diagnostics).
+ const diagnostics: DiagnosticsHook = async (opts) => {
+ let lspService: LspService | undefined;
+ try {
+ lspService = host.getService(lspServiceHandle);
+ } catch {
+ return { formatted: "", slow: false, timedOut: false };
+ }
+ if (!lspService) {
+ return { formatted: "", slow: false, timedOut: false };
+ }
+ return lspService.getDiagnostics({
+ filePath: opts.filePath,
+ text: opts.text,
+ cwd: opts.cwd,
+ // 10s matches the LSP service's per-server cap (see packages/lsp).
+ // The service clamps this anyway; stated explicitly so the call
+ // site is honest about the effective live-diagnostics budget.
+ timeoutMs: 10_000,
+ minSeverity: 2, // errors + warnings only
+ });
+ };
- host.defineTool(createEditFileTool({ resolveBackend, workdir: process.cwd(), diagnostics }));
- },
+ host.defineTool(createEditFileTool({ resolveBackend, workdir: process.cwd(), diagnostics }));
+ },
};