summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-edit-file/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
committerAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
commit1ff0eac44cd44751af979c51c746a1774c268e8a (patch)
treebf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-edit-file/src
parent54db4583e66134010375a1fa94256f36034ffdff (diff)
downloaddispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.tar.gz
dispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.zip
feat(ssh): wave 2 — route filesystem/shell tools behind ExecBackend
Wave 2 of transparent SSH support (4 parallel owner-agents on disjoint tool packages). The tools now resolve an ExecBackend per-call from ctx.computerId and call backend.spawn / backend.readFile / etc. instead of node:fs and node:child_process directly — so they are transport-agnostic (local now; remote over SSH later, transparent to the agent). Still LOCAL-ONLY this wave (computerId always undefined -> LocalExecBackend, behavior-identical). - tool-shell: factory takes resolveBackend; execute calls backend.spawn. spawn.ts DELETED (realSpawn was a verbatim duplicate of exec-backend's LocalExecBackend.spawn — logic moved to the sanctioned shared package). manifest dependsOn:[exec-backend]; host.getService at activation. - tool-read-file: readFile/stat/readdir -> backend.* (pure logic untouched; ENOENT .code branches kept). - tool-write-file: exists/stat/writeFile -> backend.* (pure logic untouched). - tool-edit-file: readFile/writeFile -> backend.* + forward-compatible REMOTE diagnostics skip (ctx.computerId set -> skip LSP, return empty — plan §6.1; local path byte-identical to today). LSP lookup stays lazy. - orchestrator: pre-wired @dispatch/exec-backend dep into the 4 tool package.jsons + bun install (build/config, my lane) so isolated verify resolved cleanly; agents added the ../exec-backend tsconfig ref. Verified: tsc -b EXIT 0, biome clean, 1599 vitest pass (was 1592). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/tool-edit-file/src')
-rw-r--r--packages/tool-edit-file/src/edit-file.test.ts165
-rw-r--r--packages/tool-edit-file/src/edit-file.ts101
-rw-r--r--packages/tool-edit-file/src/extension.ts10
3 files changed, 231 insertions, 45 deletions
diff --git a/packages/tool-edit-file/src/edit-file.test.ts b/packages/tool-edit-file/src/edit-file.test.ts
index 5ef8376..9341102 100644
--- a/packages/tool-edit-file/src/edit-file.test.ts
+++ b/packages/tool-edit-file/src/edit-file.test.ts
@@ -1,9 +1,15 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
+import { localExecBackend } from "@dispatch/exec-backend";
import { createLogger, type ToolExecuteContext } from "@dispatch/kernel";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { computeReplacement, createEditFileTool, validateArgs } from "./edit-file.js";
+import {
+ computeReplacement,
+ createEditFileTool,
+ type DiagnosticsHook,
+ validateArgs,
+} from "./edit-file.js";
function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
return {
@@ -19,6 +25,30 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
};
}
+/** No-op diagnostics — the post-edit LSP hook returning "no diagnostics". */
+const noopDiagnostics: DiagnosticsHook = async () => ({
+ formatted: "",
+ slow: false,
+ timedOut: false,
+});
+
+/**
+ * Build an edit_file tool wired to the real local ExecBackend (node:fs,
+ * behavior-identical to today's inline calls) and a no-op diagnostics hook.
+ * No `@dispatch/*` mocking — the real fs edge is exercised, matching the
+ * constitution's strict-core rule. Tests that need a real diagnostics hook
+ * build the tool inline.
+ */
+function makeTool(
+ diagnostics: DiagnosticsHook = noopDiagnostics,
+): ReturnType<typeof createEditFileTool> {
+ return createEditFileTool({
+ resolveBackend: () => localExecBackend,
+ workdir,
+ diagnostics,
+ });
+}
+
let workdir: string;
beforeEach(async () => {
@@ -145,7 +175,7 @@ describe("createEditFileTool", () => {
const filePath = join(workdir, "test.txt");
await writeFile(filePath, "hello world\n", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "test.txt", oldString: "world", newString: "there" },
stubCtx(),
@@ -162,7 +192,7 @@ describe("createEditFileTool", () => {
const filePath = join(workdir, "test.txt");
await writeFile(filePath, "aaa\n", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "test.txt", oldString: "a", newString: "b", replaceAll: true },
stubCtx(),
@@ -179,7 +209,7 @@ describe("createEditFileTool", () => {
const filePath = join(workdir, "test.txt");
await writeFile(filePath, "hello\n", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "test.txt", oldString: "xyz", newString: "abc" },
stubCtx(),
@@ -193,7 +223,7 @@ describe("createEditFileTool", () => {
const filePath = join(workdir, "test.txt");
await writeFile(filePath, "abc abc abc\n", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "test.txt", oldString: "abc", newString: "xyz" },
stubCtx(),
@@ -207,7 +237,7 @@ describe("createEditFileTool", () => {
const filePath = join(workdir, "test.txt");
await writeFile(filePath, "hello\n", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "test.txt", oldString: "hello", newString: "hello" },
stubCtx(),
@@ -218,7 +248,7 @@ describe("createEditFileTool", () => {
});
it("errors / not-found for a nonexistent file", async () => {
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "nonexistent.txt", oldString: "a", newString: "b" },
stubCtx(),
@@ -234,7 +264,7 @@ describe("createEditFileTool", () => {
const filePath = join(ctxDir, "ctx-file.txt");
await writeFile(filePath, "hello world", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const result = await tool.execute(
{ path: "ctx-file.txt", oldString: "world", newString: "there" },
stubCtx({ cwd: ctxDir }),
@@ -254,7 +284,7 @@ describe("createEditFileTool", () => {
const filePath = join(workdir, "baked-file.txt");
await writeFile(filePath, "hello world", "utf8");
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const ctx = stubCtx();
expect(ctx.cwd).toBeUndefined();
const result = await tool.execute(
@@ -267,7 +297,7 @@ describe("createEditFileTool", () => {
});
it("never throws on bad input (always returns ToolResult)", async () => {
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }];
for (const input of inputs) {
@@ -278,12 +308,12 @@ describe("createEditFileTool", () => {
});
it("concurrencySafe is false", () => {
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
expect(tool.concurrencySafe).toBe(false);
});
it("has correct name and parameters shape", () => {
- const tool = createEditFileTool(workdir);
+ const tool = makeTool();
expect(tool.name).toBe("edit_file");
expect(tool.parameters.type).toBe("object");
expect(tool.parameters.required).toEqual(["path", "oldString", "newString"]);
@@ -292,4 +322,115 @@ describe("createEditFileTool", () => {
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 1719ea3..e588f66 100644
--- a/packages/tool-edit-file/src/edit-file.ts
+++ b/packages/tool-edit-file/src/edit-file.ts
@@ -1,5 +1,5 @@
-import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
+import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend";
import type { ToolContract, ToolResult } from "@dispatch/kernel";
// --- Pure types ---
@@ -123,16 +123,29 @@ export type DiagnosticsHook = (opts: {
// --- Shell / edge ---
/**
- * Factory: create an edit_file ToolContract bound to a working directory.
- * The working directory is injected so the tool is testable.
- * `diagnostics` is optional — when provided, errors+warnings from LSP servers
- * are appended to successful edit results (only when errors exist).
+ * Factory: create an edit_file ToolContract.
+ *
+ * `resolveBackend` is the injected seam: each `execute` resolves an
+ * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set
+ * id → a remote SSH backend in a later wave). The tool programs against the
+ * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic.
+ *
+ * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is
+ * injected so the tool is testable; `execute` prefers `ctx.cwd` when present.
+ *
+ * `diagnostics` is the post-edit LSP hook (errors+warnings from LSP servers
+ * are appended to successful edit results, only when errors exist). It is
+ * invoked LAZILY at edit time — the extension defers the LSP service lookup so
+ * it resolves after LSP activates. When `ctx.computerId` is set (REMOTE) the
+ * diagnostics call is skipped: LSP servers are local processes that can't see
+ * remote files over SFTP, so the no-LSP degradation path is used instead.
*/
-export function createEditFileTool(
- workingDirectory: string,
- diagnostics?: DiagnosticsHook,
-): ToolContract {
- const workdir = resolve(workingDirectory);
+export function createEditFileTool(deps: {
+ readonly resolveBackend: ExecBackendResolver;
+ readonly workdir?: string;
+ readonly diagnostics: DiagnosticsHook;
+}): ToolContract {
+ const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined;
return {
name: "edit_file",
@@ -173,12 +186,21 @@ export function createEditFileTool(
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 readFile(resolvedPath, "utf8");
+ content = await backend.readFile(resolvedPath);
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
@@ -215,7 +237,7 @@ export function createEditFileTool(
// Write the modified content back.
try {
- await writeFile(resolvedPath, result.content, "utf8");
+ await backend.writeFile(resolvedPath, result.content);
} catch (err: unknown) {
return {
content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`,
@@ -228,28 +250,43 @@ export function createEditFileTool(
// 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) {
- try {
- const cwd = ctx.cwd ?? process.cwd();
- const diag = await diagnostics({
- filePath: resolvedPath,
- text: result.content,
- cwd,
- });
- 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")}`;
+ 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 };
}
- } catch {
- // LSP diagnostics failure is non-fatal — the edit already succeeded.
+ }
+ 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")}`;
}
}
diff --git a/packages/tool-edit-file/src/extension.ts b/packages/tool-edit-file/src/extension.ts
index 3929634..2eaa0e9 100644
--- a/packages/tool-edit-file/src/extension.ts
+++ b/packages/tool-edit-file/src/extension.ts
@@ -1,3 +1,4 @@
+import { execBackendHandle } from "@dispatch/exec-backend";
import type { Extension } from "@dispatch/kernel";
import { type LspService, lspServiceHandle } from "@dispatch/lsp";
import { createEditFileTool, type DiagnosticsHook } from "./edit-file.js";
@@ -12,8 +13,15 @@ export const extension: Extension = {
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
@@ -38,6 +46,6 @@ export const extension: Extension = {
});
};
- host.defineTool(createEditFileTool(process.cwd(), diagnostics));
+ host.defineTool(createEditFileTool({ resolveBackend, workdir: process.cwd(), diagnostics }));
},
};