Ver Fonte

refactor(tool): convert write tool to Tool.defineEffect (#21901)

Kit Langton há 1 semana atrás
pai
commit
b139bc2ef3

+ 2 - 1
packages/opencode/src/tool/registry.ts

@@ -110,6 +110,7 @@ export namespace ToolRegistry {
       const bash = yield* BashTool
       const codesearch = yield* CodeSearchTool
       const globtool = yield* GlobTool
+      const writetool = yield* WriteTool
 
       const state = yield* InstanceState.make<State>(
         Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -173,7 +174,7 @@ export namespace ToolRegistry {
             glob: Tool.init(globtool),
             grep: Tool.init(GrepTool),
             edit: Tool.init(EditTool),
-            write: Tool.init(WriteTool),
+            write: Tool.init(writetool),
             task: Tool.init(task),
             fetch: Tool.init(webfetch),
             todo: Tool.init(todo),

+ 76 - 61
packages/opencode/src/tool/write.ts

@@ -1,5 +1,6 @@
 import z from "zod"
 import * as path from "path"
+import { Effect } from "effect"
 import { Tool } from "./tool"
 import { LSP } from "../lsp"
 import { createTwoFilesPatch } from "diff"
@@ -9,76 +10,90 @@ import { File } from "../file"
 import { FileWatcher } from "../file/watcher"
 import { Format } from "../format"
 import { FileTime } from "../file/time"
-import { Filesystem } from "../util/filesystem"
+import { AppFileSystem } from "../filesystem"
 import { Instance } from "../project/instance"
 import { trimDiff } from "./edit"
-import { assertExternalDirectory } from "./external-directory"
+import { assertExternalDirectoryEffect } from "./external-directory"
 
 const MAX_DIAGNOSTICS_PER_FILE = 20
 const MAX_PROJECT_DIAGNOSTICS_FILES = 5
 
-export const WriteTool = Tool.define("write", {
-  description: DESCRIPTION,
-  parameters: z.object({
-    content: z.string().describe("The content to write to the file"),
-    filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
-  }),
-  async execute(params, ctx) {
-    const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
-    await assertExternalDirectory(ctx, filepath)
+export const WriteTool = Tool.defineEffect(
+  "write",
+  Effect.gen(function* () {
+    const lsp = yield* LSP.Service
+    const fs = yield* AppFileSystem.Service
+    const filetime = yield* FileTime.Service
 
-    const exists = await Filesystem.exists(filepath)
-    const contentOld = exists ? await Filesystem.readText(filepath) : ""
-    if (exists) await FileTime.assert(ctx.sessionID, filepath)
+    return {
+      description: DESCRIPTION,
+      parameters: z.object({
+        content: z.string().describe("The content to write to the file"),
+        filePath: z.string().describe("The absolute path to the file to write (must be absolute, not relative)"),
+      }),
+      execute: (params: { content: string; filePath: string }, ctx: Tool.Context) =>
+        Effect.gen(function* () {
+          const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
+          yield* assertExternalDirectoryEffect(ctx, filepath)
 
-    const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
-    await ctx.ask({
-      permission: "edit",
-      patterns: [path.relative(Instance.worktree, filepath)],
-      always: ["*"],
-      metadata: {
-        filepath,
-        diff,
-      },
-    })
+          const exists = yield* fs.existsSafe(filepath)
+          const contentOld = exists ? yield* fs.readFileString(filepath) : ""
+          if (exists) yield* filetime.assert(ctx.sessionID, filepath)
 
-    await Filesystem.write(filepath, params.content)
-    await Format.file(filepath)
-    Bus.publish(File.Event.Edited, { file: filepath })
-    await Bus.publish(FileWatcher.Event.Updated, {
-      file: filepath,
-      event: exists ? "change" : "add",
-    })
-    await FileTime.read(ctx.sessionID, filepath)
+          const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
+          yield* Effect.promise(() =>
+            ctx.ask({
+              permission: "edit",
+              patterns: [path.relative(Instance.worktree, filepath)],
+              always: ["*"],
+              metadata: {
+                filepath,
+                diff,
+              },
+            }),
+          )
 
-    let output = "Wrote file successfully."
-    await LSP.touchFile(filepath, true)
-    const diagnostics = await LSP.diagnostics()
-    const normalizedFilepath = Filesystem.normalizePath(filepath)
-    let projectDiagnosticsCount = 0
-    for (const [file, issues] of Object.entries(diagnostics)) {
-      const errors = issues.filter((item) => item.severity === 1)
-      if (errors.length === 0) continue
-      const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
-      const suffix =
-        errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
-      if (file === normalizedFilepath) {
-        output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
-        continue
-      }
-      if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
-      projectDiagnosticsCount++
-      output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
-    }
+          yield* fs.writeWithDirs(filepath, params.content)
+          yield* Effect.promise(() => Format.file(filepath))
+          Bus.publish(File.Event.Edited, { file: filepath })
+          yield* Effect.promise(() =>
+            Bus.publish(FileWatcher.Event.Updated, {
+              file: filepath,
+              event: exists ? "change" : "add",
+            }),
+          )
+          yield* filetime.read(ctx.sessionID, filepath)
 
-    return {
-      title: path.relative(Instance.worktree, filepath),
-      metadata: {
-        diagnostics,
-        filepath,
-        exists: exists,
-      },
-      output,
+          let output = "Wrote file successfully."
+          yield* lsp.touchFile(filepath, true)
+          const diagnostics = yield* lsp.diagnostics()
+          const normalizedFilepath = AppFileSystem.normalizePath(filepath)
+          let projectDiagnosticsCount = 0
+          for (const [file, issues] of Object.entries(diagnostics)) {
+            const errors = issues.filter((item) => item.severity === 1)
+            if (errors.length === 0) continue
+            const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
+            const suffix =
+              errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
+            if (file === normalizedFilepath) {
+              output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
+              continue
+            }
+            if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
+            projectDiagnosticsCount++
+            output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
+          }
+
+          return {
+            title: path.relative(Instance.worktree, filepath),
+            metadata: {
+              diagnostics,
+              filepath,
+              exists: exists,
+            },
+            output,
+          }
+        }).pipe(Effect.orDie, Effect.runPromise),
     }
-  },
-})
+  }),
+)

+ 177 - 287
packages/opencode/test/tool/write.test.ts

@@ -1,10 +1,17 @@
-import { afterEach, describe, test, expect } from "bun:test"
+import { afterEach, describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
 import path from "path"
 import fs from "fs/promises"
 import { WriteTool } from "../../src/tool/write"
 import { Instance } from "../../src/project/instance"
-import { tmpdir } from "../fixture/fixture"
+import { LSP } from "../../src/lsp"
+import { AppFileSystem } from "../../src/filesystem"
+import { FileTime } from "../../src/file/time"
+import { Tool } from "../../src/tool/tool"
 import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
 
 const ctx = {
   sessionID: SessionID.make("ses_test-write-session"),
@@ -21,333 +28,216 @@ afterEach(async () => {
   await Instance.disposeAll()
 })
 
+const it = testEffect(
+  Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
+)
+
+const init = Effect.fn("WriteToolTest.init")(function* () {
+  const info = yield* WriteTool
+  return yield* Effect.promise(() => info.init())
+})
+
+const run = Effect.fn("WriteToolTest.run")(function* (
+  args: Tool.InferParameters<typeof WriteTool>,
+  next: Tool.Context = ctx,
+) {
+  const tool = yield* init()
+  return yield* Effect.promise(() => tool.execute(args, next))
+})
+
+const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
+  const ft = yield* FileTime.Service
+  yield* ft.read(sessionID as any, filepath)
+})
+
 describe("tool.write", () => {
   describe("new file creation", () => {
-    test("writes content to new file", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "newfile.txt")
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          const result = await write.execute(
-            {
-              filePath: filepath,
-              content: "Hello, World!",
-            },
-            ctx,
-          )
+    it.live("writes content to new file", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "newfile.txt")
+          const result = yield* run({ filePath: filepath, content: "Hello, World!" })
 
           expect(result.output).toContain("Wrote file successfully")
           expect(result.metadata.exists).toBe(false)
 
-          const content = await fs.readFile(filepath, "utf-8")
+          const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
           expect(content).toBe("Hello, World!")
-        },
-      })
-    })
-
-    test("creates parent directories if needed", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content: "nested content",
-            },
-            ctx,
-          )
-
-          const content = await fs.readFile(filepath, "utf-8")
+        }),
+      ),
+    )
+
+    it.live("creates parent directories if needed", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "nested", "deep", "file.txt")
+          yield* run({ filePath: filepath, content: "nested content" })
+
+          const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
           expect(content).toBe("nested content")
-        },
-      })
-    })
-
-    test("handles relative paths by resolving to instance directory", async () => {
-      await using tmp = await tmpdir()
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: "relative.txt",
-              content: "relative content",
-            },
-            ctx,
-          )
-
-          const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
+        }),
+      ),
+    )
+
+    it.live("handles relative paths by resolving to instance directory", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          yield* run({ filePath: "relative.txt", content: "relative content" })
+
+          const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8"))
           expect(content).toBe("relative content")
-        },
-      })
-    })
+        }),
+      ),
+    )
   })
 
   describe("existing file overwrite", () => {
-    test("overwrites existing file content", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "existing.txt")
-      await fs.writeFile(filepath, "old content", "utf-8")
-
-      // First read the file to satisfy FileTime requirement
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const { FileTime } = await import("../../src/file/time")
-          await FileTime.read(ctx.sessionID, filepath)
-
-          const write = await WriteTool.init()
-          const result = await write.execute(
-            {
-              filePath: filepath,
-              content: "new content",
-            },
-            ctx,
-          )
+    it.live("overwrites existing file content", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "existing.txt")
+          yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
+          yield* markRead(ctx.sessionID, filepath)
+
+          const result = yield* run({ filePath: filepath, content: "new content" })
 
           expect(result.output).toContain("Wrote file successfully")
           expect(result.metadata.exists).toBe(true)
 
-          const content = await fs.readFile(filepath, "utf-8")
+          const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
           expect(content).toBe("new content")
-        },
-      })
-    })
-
-    test("returns diff in metadata for existing files", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "file.txt")
-      await fs.writeFile(filepath, "old", "utf-8")
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const { FileTime } = await import("../../src/file/time")
-          await FileTime.read(ctx.sessionID, filepath)
-
-          const write = await WriteTool.init()
-          const result = await write.execute(
-            {
-              filePath: filepath,
-              content: "new",
-            },
-            ctx,
-          )
-
-          // Diff should be in metadata
+        }),
+      ),
+    )
+
+    it.live("returns diff in metadata for existing files", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "file.txt")
+          yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
+          yield* markRead(ctx.sessionID, filepath)
+
+          const result = yield* run({ filePath: filepath, content: "new" })
+
           expect(result.metadata).toHaveProperty("filepath", filepath)
           expect(result.metadata).toHaveProperty("exists", true)
-        },
-      })
-    })
+        }),
+      ),
+    )
   })
 
   describe("file permissions", () => {
-    test("sets file permissions when writing sensitive data", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "sensitive.json")
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content: JSON.stringify({ secret: "data" }),
-            },
-            ctx,
-          )
-
-          // On Unix systems, check permissions
+    it.live("sets file permissions when writing sensitive data", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "sensitive.json")
+          yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
+
           if (process.platform !== "win32") {
-            const stats = await fs.stat(filepath)
+            const stats = yield* Effect.promise(() => fs.stat(filepath))
             expect(stats.mode & 0o777).toBe(0o644)
           }
-        },
-      })
-    })
+        }),
+      ),
+    )
   })
 
   describe("content types", () => {
-    test("writes JSON content", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "data.json")
-      const data = { key: "value", nested: { array: [1, 2, 3] } }
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content: JSON.stringify(data, null, 2),
-            },
-            ctx,
-          )
-
-          const content = await fs.readFile(filepath, "utf-8")
+    it.live("writes JSON content", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "data.json")
+          const data = { key: "value", nested: { array: [1, 2, 3] } }
+          yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
+
+          const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
           expect(JSON.parse(content)).toEqual(data)
-        },
-      })
-    })
-
-    test("writes binary-safe content", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "binary.bin")
-      const content = "Hello\x00World\x01\x02\x03"
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content,
-            },
-            ctx,
-          )
-
-          const buf = await fs.readFile(filepath)
+        }),
+      ),
+    )
+
+    it.live("writes binary-safe content", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "binary.bin")
+          const content = "Hello\x00World\x01\x02\x03"
+          yield* run({ filePath: filepath, content })
+
+          const buf = yield* Effect.promise(() => fs.readFile(filepath))
           expect(buf.toString()).toBe(content)
-        },
-      })
-    })
-
-    test("writes empty content", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "empty.txt")
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content: "",
-            },
-            ctx,
-          )
-
-          const content = await fs.readFile(filepath, "utf-8")
+        }),
+      ),
+    )
+
+    it.live("writes empty content", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "empty.txt")
+          yield* run({ filePath: filepath, content: "" })
+
+          const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
           expect(content).toBe("")
 
-          const stats = await fs.stat(filepath)
+          const stats = yield* Effect.promise(() => fs.stat(filepath))
           expect(stats.size).toBe(0)
-        },
-      })
-    })
-
-    test("writes multi-line content", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "multiline.txt")
-      const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content: lines,
-            },
-            ctx,
-          )
-
-          const content = await fs.readFile(filepath, "utf-8")
+        }),
+      ),
+    )
+
+    it.live("writes multi-line content", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "multiline.txt")
+          const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
+          yield* run({ filePath: filepath, content: lines })
+
+          const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
           expect(content).toBe(lines)
-        },
-      })
-    })
-
-    test("handles different line endings", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "crlf.txt")
-      const content = "Line 1\r\nLine 2\r\nLine 3"
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          await write.execute(
-            {
-              filePath: filepath,
-              content,
-            },
-            ctx,
-          )
-
-          const buf = await fs.readFile(filepath)
+        }),
+      ),
+    )
+
+    it.live("handles different line endings", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "crlf.txt")
+          const content = "Line 1\r\nLine 2\r\nLine 3"
+          yield* run({ filePath: filepath, content })
+
+          const buf = yield* Effect.promise(() => fs.readFile(filepath))
           expect(buf.toString()).toBe(content)
-        },
-      })
-    })
+        }),
+      ),
+    )
   })
 
   describe("error handling", () => {
-    test("throws error when OS denies write access", async () => {
-      await using tmp = await tmpdir()
-      const readonlyPath = path.join(tmp.path, "readonly.txt")
-
-      // Create a read-only file
-      await fs.writeFile(readonlyPath, "test", "utf-8")
-      await fs.chmod(readonlyPath, 0o444)
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const { FileTime } = await import("../../src/file/time")
-          await FileTime.read(ctx.sessionID, readonlyPath)
-
-          const write = await WriteTool.init()
-          await expect(
-            write.execute(
-              {
-                filePath: readonlyPath,
-                content: "new content",
-              },
-              ctx,
-            ),
-          ).rejects.toThrow()
-        },
-      })
-    })
+    it.live("throws error when OS denies write access", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const readonlyPath = path.join(dir, "readonly.txt")
+          yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
+          yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
+          yield* markRead(ctx.sessionID, readonlyPath)
+
+          const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
+          expect(exit._tag).toBe("Failure")
+        }),
+      ),
+    )
   })
 
   describe("title generation", () => {
-    test("returns relative path as title", async () => {
-      await using tmp = await tmpdir()
-      const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
-      await fs.mkdir(path.dirname(filepath), { recursive: true })
-
-      await Instance.provide({
-        directory: tmp.path,
-        fn: async () => {
-          const write = await WriteTool.init()
-          const result = await write.execute(
-            {
-              filePath: filepath,
-              content: "export const Button = () => {}",
-            },
-            ctx,
-          )
+    it.live("returns relative path as title", () =>
+      provideTmpdirInstance((dir) =>
+        Effect.gen(function* () {
+          const filepath = path.join(dir, "src", "components", "Button.tsx")
+          yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
 
+          const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
           expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
-        },
-      })
-    })
+        }),
+      ),
+    )
   })
 })