diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 14:06:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 14:06:23 +0900 |
| commit | 1ff0eac44cd44751af979c51c746a1774c268e8a (patch) | |
| tree | bf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-edit-file/src | |
| parent | 54db4583e66134010375a1fa94256f36034ffdff (diff) | |
| download | dispatch-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.ts | 165 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/edit-file.ts | 101 | ||||
| -rw-r--r-- | packages/tool-edit-file/src/extension.ts | 10 |
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 })); }, }; |
