refactor: convert edit tool to Tool.defineEffect (#21904)

This commit is contained in:
Kit Langton
2026-04-10 17:10:28 -04:00
committed by GitHub
parent 57b2e64345
commit b41fa8e318
4 changed files with 240 additions and 184 deletions
+42 -16
View File
@@ -5,6 +5,7 @@
import z from "zod" import z from "zod"
import * as path from "path" import * as path from "path"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
@@ -17,7 +18,7 @@ import { FileTime } from "../file/time"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_DIAGNOSTICS_PER_FILE = 20 const MAX_DIAGNOSTICS_PER_FILE = 20
@@ -34,15 +35,24 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
return text.replaceAll("\n", "\r\n") return text.replaceAll("\n", "\r\n")
} }
export const EditTool = Tool.define("edit", { const Parameters = z.object({
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"), filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"), oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"), newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}), })
async execute(params, ctx) {
export const EditTool = Tool.defineEffect(
"edit",
Effect.gen(function* () {
const lsp = yield* LSP.Service
const filetime = yield* FileTime.Service
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.filePath) { if (!params.filePath) {
throw new Error("filePath is required") throw new Error("filePath is required")
} }
@@ -51,13 +61,15 @@ export const EditTool = Tool.define("edit", {
throw new Error("No changes to apply: oldString and newString are identical.") throw new Error("No changes to apply: oldString and newString are identical.")
} }
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) const filePath = path.isAbsolute(params.filePath)
await assertExternalDirectory(ctx, filePath) ? params.filePath
: path.join(Instance.directory, params.filePath)
yield* assertExternalDirectoryEffect(ctx, filePath)
let diff = "" let diff = ""
let contentOld = "" let contentOld = ""
let contentNew = "" let contentNew = ""
await FileTime.withLock(filePath, async () => { yield* filetime.withLock(filePath, async () => {
if (params.oldString === "") { if (params.oldString === "") {
const existed = await Filesystem.exists(filePath) const existed = await Filesystem.exists(filePath)
contentNew = params.newString contentNew = params.newString
@@ -95,7 +107,12 @@ export const EditTool = Tool.define("edit", {
contentNew = replace(contentOld, old, next, params.replaceAll) contentNew = replace(contentOld, old, next, params.replaceAll)
diff = trimDiff( diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
) )
await ctx.ask({ await ctx.ask({
permission: "edit", permission: "edit",
@@ -116,7 +133,12 @@ export const EditTool = Tool.define("edit", {
}) })
contentNew = await Filesystem.readText(filePath) contentNew = await Filesystem.readText(filePath)
diff = trimDiff( diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), createTwoFilesPatch(
filePath,
filePath,
normalizeLineEndings(contentOld),
normalizeLineEndings(contentNew),
),
) )
await FileTime.read(ctx.sessionID, filePath) await FileTime.read(ctx.sessionID, filePath)
}) })
@@ -141,15 +163,17 @@ export const EditTool = Tool.define("edit", {
}) })
let output = "Edit applied successfully." let output = "Edit applied successfully."
await LSP.touchFile(filePath, true) yield* lsp.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics() const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath) const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? [] const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1) const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) { if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix = const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" errors.length > MAX_DIAGNOSTICS_PER_FILE
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
: ""
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>` 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>`
} }
@@ -162,8 +186,10 @@ export const EditTool = Tool.define("edit", {
title: `${path.relative(Instance.worktree, filePath)}`, title: `${path.relative(Instance.worktree, filePath)}`,
output, output,
} }
}, }).pipe(Effect.orDie, Effect.runPromise),
}) }
}),
)
export type Replacer = (content: string, find: string) => Generator<string, void, unknown> export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
+21 -10
View File
@@ -1,11 +1,18 @@
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { EditTool } from "./edit" import { EditTool } from "./edit"
import DESCRIPTION from "./multiedit.txt" import DESCRIPTION from "./multiedit.txt"
import path from "path" import path from "path"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
export const MultiEditTool = Tool.define("multiedit", { export const MultiEditTool = Tool.defineEffect(
"multiedit",
Effect.gen(function* () {
const editInfo = yield* EditTool
const edit = yield* Effect.promise(() => editInfo.init())
return {
description: DESCRIPTION, description: DESCRIPTION,
parameters: z.object({ parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"), filePath: z.string().describe("The absolute path to the file to modify"),
@@ -20,18 +27,20 @@ export const MultiEditTool = Tool.define("multiedit", {
) )
.describe("Array of edit operations to perform sequentially on the file"), .describe("Array of edit operations to perform sequentially on the file"),
}), }),
async execute(params, ctx) { execute: (params: { filePath: string; edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> }, ctx: Tool.Context) =>
const tool = await EditTool.init() Effect.gen(function* () {
const results = [] const results = []
for (const [, edit] of params.edits.entries()) { for (const [, entry] of params.edits.entries()) {
const result = await tool.execute( const result = yield* Effect.promise(() =>
edit.execute(
{ {
filePath: params.filePath, filePath: params.filePath,
oldString: edit.oldString, oldString: entry.oldString,
newString: edit.newString, newString: entry.newString,
replaceAll: edit.replaceAll, replaceAll: entry.replaceAll,
}, },
ctx, ctx,
),
) )
results.push(result) results.push(result)
} }
@@ -42,5 +51,7 @@ export const MultiEditTool = Tool.define("multiedit", {
}, },
output: results.at(-1)!.output, output: results.at(-1)!.output,
} }
}, }).pipe(Effect.orDie, Effect.runPromise),
}) }
}),
)
+2 -1
View File
@@ -111,6 +111,7 @@ export namespace ToolRegistry {
const codesearch = yield* CodeSearchTool const codesearch = yield* CodeSearchTool
const globtool = yield* GlobTool const globtool = yield* GlobTool
const writetool = yield* WriteTool const writetool = yield* WriteTool
const edit = yield* EditTool
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) { Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -173,7 +174,7 @@ export namespace ToolRegistry {
read: Tool.init(read), read: Tool.init(read),
glob: Tool.init(globtool), glob: Tool.init(globtool),
grep: Tool.init(GrepTool), grep: Tool.init(GrepTool),
edit: Tool.init(EditTool), edit: Tool.init(edit),
write: Tool.init(writetool), write: Tool.init(writetool),
task: Tool.init(task), task: Tool.init(task),
fetch: Tool.init(webfetch), fetch: Tool.init(webfetch),
+37 -19
View File
@@ -1,10 +1,12 @@
import { afterEach, describe, test, expect } from "bun:test" import { afterAll, afterEach, describe, test, expect } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Effect, Layer, ManagedRuntime } from "effect"
import { EditTool } from "../../src/tool/edit" import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time" import { FileTime } from "../../src/file/time"
import { LSP } from "../../src/lsp"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
const ctx = { const ctx = {
@@ -27,6 +29,22 @@ async function touch(file: string, time: number) {
await fs.utimes(file, date, date) await fs.utimes(file, date, date)
} }
const runtime = ManagedRuntime.make(
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer),
)
afterAll(async () => {
await runtime.dispose()
})
const resolve = () =>
runtime.runPromise(
Effect.gen(function* () {
const info = yield* EditTool
return yield* Effect.promise(() => info.init())
}),
)
describe("tool.edit", () => { describe("tool.edit", () => {
describe("creating new files", () => { describe("creating new files", () => {
test("creates new file when oldString is empty", async () => { test("creates new file when oldString is empty", async () => {
@@ -36,7 +54,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
const result = await edit.execute( const result = await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -61,7 +79,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -91,7 +109,7 @@ describe("tool.edit", () => {
const events: string[] = [] const events: string[] = []
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -119,7 +137,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
const result = await edit.execute( const result = await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -146,7 +164,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -169,7 +187,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -194,7 +212,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -217,7 +235,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -249,7 +267,7 @@ describe("tool.edit", () => {
await touch(filepath, 2_000) await touch(filepath, 2_000)
// Try to edit with the new content // Try to edit with the new content
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -274,7 +292,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -307,7 +325,7 @@ describe("tool.edit", () => {
const events: string[] = [] const events: string[] = []
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated")) const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -335,7 +353,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -361,7 +379,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
await edit.execute( await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -385,7 +403,7 @@ describe("tool.edit", () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -410,7 +428,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, dirpath) await FileTime.read(ctx.sessionID, dirpath)
const edit = await EditTool.init() const edit = await resolve()
await expect( await expect(
edit.execute( edit.execute(
{ {
@@ -435,7 +453,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
const result = await edit.execute( const result = await edit.execute(
{ {
filePath: filepath, filePath: filepath,
@@ -502,7 +520,7 @@ describe("tool.edit", () => {
return await Instance.provide({ return await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const edit = await EditTool.init() const edit = await resolve()
const filePath = path.join(tmp.path, "test.txt") const filePath = path.join(tmp.path, "test.txt")
await FileTime.read(ctx.sessionID, filePath) await FileTime.read(ctx.sessionID, filePath)
await edit.execute( await edit.execute(
@@ -647,7 +665,7 @@ describe("tool.edit", () => {
fn: async () => { fn: async () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init() const edit = await resolve()
// Two concurrent edits // Two concurrent edits
const promise1 = edit.execute( const promise1 = edit.execute(