diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 22:03:19 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 22:23:39 +0900 |
| commit | 727c98c9dae516a2070eb950410314380a20c974 (patch) | |
| tree | 52aa1022c54f11770be7e4e2a324f0a8b8b8deec /packages/tool-edit-file/src | |
| parent | e59dc11f63b1df51142259bb2c406af8c9c8c2bb (diff) | |
| download | dispatch-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.ts | 786 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/edit-file.ts | 474 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/extension.ts | 90 |
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 })); + }, }; |
