Skip to content

Commit b41fa8e

Browse files
authored
refactor: convert edit tool to Tool.defineEffect (#21904)
1 parent 57b2e64 commit b41fa8e

4 files changed

Lines changed: 236 additions & 180 deletions

File tree

packages/opencode/src/tool/edit.ts

Lines changed: 149 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import z from "zod"
77
import * as path from "path"
8+
import { Effect } from "effect"
89
import { Tool } from "./tool"
910
import { LSP } from "../lsp"
1011
import { createTwoFilesPatch, diffLines } from "diff"
@@ -17,7 +18,7 @@ import { FileTime } from "../file/time"
1718
import { Filesystem } from "../util/filesystem"
1819
import { Instance } from "../project/instance"
1920
import { Snapshot } from "@/snapshot"
20-
import { assertExternalDirectory } from "./external-directory"
21+
import { assertExternalDirectoryEffect } from "./external-directory"
2122

2223
const MAX_DIAGNOSTICS_PER_FILE = 20
2324

@@ -34,136 +35,161 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string {
3435
return text.replaceAll("\n", "\r\n")
3536
}
3637

37-
export const EditTool = Tool.define("edit", {
38-
description: DESCRIPTION,
39-
parameters: z.object({
40-
filePath: z.string().describe("The absolute path to the file to modify"),
41-
oldString: z.string().describe("The text to replace"),
42-
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
43-
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
44-
}),
45-
async execute(params, ctx) {
46-
if (!params.filePath) {
47-
throw new Error("filePath is required")
48-
}
38+
const Parameters = z.object({
39+
filePath: z.string().describe("The absolute path to the file to modify"),
40+
oldString: z.string().describe("The text to replace"),
41+
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
42+
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
43+
})
4944

50-
if (params.oldString === params.newString) {
51-
throw new Error("No changes to apply: oldString and newString are identical.")
52-
}
45+
export const EditTool = Tool.defineEffect(
46+
"edit",
47+
Effect.gen(function* () {
48+
const lsp = yield* LSP.Service
49+
const filetime = yield* FileTime.Service
5350

54-
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
55-
await assertExternalDirectory(ctx, filePath)
56-
57-
let diff = ""
58-
let contentOld = ""
59-
let contentNew = ""
60-
await FileTime.withLock(filePath, async () => {
61-
if (params.oldString === "") {
62-
const existed = await Filesystem.exists(filePath)
63-
contentNew = params.newString
64-
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
65-
await ctx.ask({
66-
permission: "edit",
67-
patterns: [path.relative(Instance.worktree, filePath)],
68-
always: ["*"],
69-
metadata: {
70-
filepath: filePath,
71-
diff,
72-
},
73-
})
74-
await Filesystem.write(filePath, params.newString)
75-
await Format.file(filePath)
76-
Bus.publish(File.Event.Edited, { file: filePath })
77-
await Bus.publish(FileWatcher.Event.Updated, {
78-
file: filePath,
79-
event: existed ? "change" : "add",
80-
})
81-
await FileTime.read(ctx.sessionID, filePath)
82-
return
83-
}
51+
return {
52+
description: DESCRIPTION,
53+
parameters: Parameters,
54+
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
55+
Effect.gen(function* () {
56+
if (!params.filePath) {
57+
throw new Error("filePath is required")
58+
}
8459

85-
const stats = Filesystem.stat(filePath)
86-
if (!stats) throw new Error(`File ${filePath} not found`)
87-
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
88-
await FileTime.assert(ctx.sessionID, filePath)
89-
contentOld = await Filesystem.readText(filePath)
90-
91-
const ending = detectLineEnding(contentOld)
92-
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
93-
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
94-
95-
contentNew = replace(contentOld, old, next, params.replaceAll)
96-
97-
diff = trimDiff(
98-
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
99-
)
100-
await ctx.ask({
101-
permission: "edit",
102-
patterns: [path.relative(Instance.worktree, filePath)],
103-
always: ["*"],
104-
metadata: {
105-
filepath: filePath,
106-
diff,
107-
},
108-
})
109-
110-
await Filesystem.write(filePath, contentNew)
111-
await Format.file(filePath)
112-
Bus.publish(File.Event.Edited, { file: filePath })
113-
await Bus.publish(FileWatcher.Event.Updated, {
114-
file: filePath,
115-
event: "change",
116-
})
117-
contentNew = await Filesystem.readText(filePath)
118-
diff = trimDiff(
119-
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
120-
)
121-
await FileTime.read(ctx.sessionID, filePath)
122-
})
60+
if (params.oldString === params.newString) {
61+
throw new Error("No changes to apply: oldString and newString are identical.")
62+
}
12363

124-
const filediff: Snapshot.FileDiff = {
125-
file: filePath,
126-
patch: diff,
127-
additions: 0,
128-
deletions: 0,
129-
}
130-
for (const change of diffLines(contentOld, contentNew)) {
131-
if (change.added) filediff.additions += change.count || 0
132-
if (change.removed) filediff.deletions += change.count || 0
133-
}
64+
const filePath = path.isAbsolute(params.filePath)
65+
? params.filePath
66+
: path.join(Instance.directory, params.filePath)
67+
yield* assertExternalDirectoryEffect(ctx, filePath)
68+
69+
let diff = ""
70+
let contentOld = ""
71+
let contentNew = ""
72+
yield* filetime.withLock(filePath, async () => {
73+
if (params.oldString === "") {
74+
const existed = await Filesystem.exists(filePath)
75+
contentNew = params.newString
76+
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
77+
await ctx.ask({
78+
permission: "edit",
79+
patterns: [path.relative(Instance.worktree, filePath)],
80+
always: ["*"],
81+
metadata: {
82+
filepath: filePath,
83+
diff,
84+
},
85+
})
86+
await Filesystem.write(filePath, params.newString)
87+
await Format.file(filePath)
88+
Bus.publish(File.Event.Edited, { file: filePath })
89+
await Bus.publish(FileWatcher.Event.Updated, {
90+
file: filePath,
91+
event: existed ? "change" : "add",
92+
})
93+
await FileTime.read(ctx.sessionID, filePath)
94+
return
95+
}
13496

135-
ctx.metadata({
136-
metadata: {
137-
diff,
138-
filediff,
139-
diagnostics: {},
140-
},
141-
})
97+
const stats = Filesystem.stat(filePath)
98+
if (!stats) throw new Error(`File ${filePath} not found`)
99+
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
100+
await FileTime.assert(ctx.sessionID, filePath)
101+
contentOld = await Filesystem.readText(filePath)
102+
103+
const ending = detectLineEnding(contentOld)
104+
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
105+
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
106+
107+
contentNew = replace(contentOld, old, next, params.replaceAll)
108+
109+
diff = trimDiff(
110+
createTwoFilesPatch(
111+
filePath,
112+
filePath,
113+
normalizeLineEndings(contentOld),
114+
normalizeLineEndings(contentNew),
115+
),
116+
)
117+
await ctx.ask({
118+
permission: "edit",
119+
patterns: [path.relative(Instance.worktree, filePath)],
120+
always: ["*"],
121+
metadata: {
122+
filepath: filePath,
123+
diff,
124+
},
125+
})
126+
127+
await Filesystem.write(filePath, contentNew)
128+
await Format.file(filePath)
129+
Bus.publish(File.Event.Edited, { file: filePath })
130+
await Bus.publish(FileWatcher.Event.Updated, {
131+
file: filePath,
132+
event: "change",
133+
})
134+
contentNew = await Filesystem.readText(filePath)
135+
diff = trimDiff(
136+
createTwoFilesPatch(
137+
filePath,
138+
filePath,
139+
normalizeLineEndings(contentOld),
140+
normalizeLineEndings(contentNew),
141+
),
142+
)
143+
await FileTime.read(ctx.sessionID, filePath)
144+
})
145+
146+
const filediff: Snapshot.FileDiff = {
147+
file: filePath,
148+
patch: diff,
149+
additions: 0,
150+
deletions: 0,
151+
}
152+
for (const change of diffLines(contentOld, contentNew)) {
153+
if (change.added) filediff.additions += change.count || 0
154+
if (change.removed) filediff.deletions += change.count || 0
155+
}
142156

143-
let output = "Edit applied successfully."
144-
await LSP.touchFile(filePath, true)
145-
const diagnostics = await LSP.diagnostics()
146-
const normalizedFilePath = Filesystem.normalizePath(filePath)
147-
const issues = diagnostics[normalizedFilePath] ?? []
148-
const errors = issues.filter((item) => item.severity === 1)
149-
if (errors.length > 0) {
150-
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
151-
const suffix =
152-
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
153-
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>`
154-
}
157+
ctx.metadata({
158+
metadata: {
159+
diff,
160+
filediff,
161+
diagnostics: {},
162+
},
163+
})
164+
165+
let output = "Edit applied successfully."
166+
yield* lsp.touchFile(filePath, true)
167+
const diagnostics = yield* lsp.diagnostics()
168+
const normalizedFilePath = Filesystem.normalizePath(filePath)
169+
const issues = diagnostics[normalizedFilePath] ?? []
170+
const errors = issues.filter((item) => item.severity === 1)
171+
if (errors.length > 0) {
172+
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
173+
const suffix =
174+
errors.length > MAX_DIAGNOSTICS_PER_FILE
175+
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
176+
: ""
177+
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>`
178+
}
155179

156-
return {
157-
metadata: {
158-
diagnostics,
159-
diff,
160-
filediff,
161-
},
162-
title: `${path.relative(Instance.worktree, filePath)}`,
163-
output,
180+
return {
181+
metadata: {
182+
diagnostics,
183+
diff,
184+
filediff,
185+
},
186+
title: `${path.relative(Instance.worktree, filePath)}`,
187+
output,
188+
}
189+
}).pipe(Effect.orDie, Effect.runPromise),
164190
}
165-
},
166-
})
191+
}),
192+
)
167193

168194
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
169195

Lines changed: 48 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,57 @@
11
import z from "zod"
2+
import { Effect } from "effect"
23
import { Tool } from "./tool"
34
import { EditTool } from "./edit"
45
import DESCRIPTION from "./multiedit.txt"
56
import path from "path"
67
import { Instance } from "../project/instance"
78

8-
export const MultiEditTool = Tool.define("multiedit", {
9-
description: DESCRIPTION,
10-
parameters: z.object({
11-
filePath: z.string().describe("The absolute path to the file to modify"),
12-
edits: z
13-
.array(
14-
z.object({
15-
filePath: z.string().describe("The absolute path to the file to modify"),
16-
oldString: z.string().describe("The text to replace"),
17-
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
18-
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
19-
}),
20-
)
21-
.describe("Array of edit operations to perform sequentially on the file"),
22-
}),
23-
async execute(params, ctx) {
24-
const tool = await EditTool.init()
25-
const results = []
26-
for (const [, edit] of params.edits.entries()) {
27-
const result = await tool.execute(
28-
{
29-
filePath: params.filePath,
30-
oldString: edit.oldString,
31-
newString: edit.newString,
32-
replaceAll: edit.replaceAll,
33-
},
34-
ctx,
35-
)
36-
results.push(result)
37-
}
9+
export const MultiEditTool = Tool.defineEffect(
10+
"multiedit",
11+
Effect.gen(function* () {
12+
const editInfo = yield* EditTool
13+
const edit = yield* Effect.promise(() => editInfo.init())
14+
3815
return {
39-
title: path.relative(Instance.worktree, params.filePath),
40-
metadata: {
41-
results: results.map((r) => r.metadata),
42-
},
43-
output: results.at(-1)!.output,
16+
description: DESCRIPTION,
17+
parameters: z.object({
18+
filePath: z.string().describe("The absolute path to the file to modify"),
19+
edits: z
20+
.array(
21+
z.object({
22+
filePath: z.string().describe("The absolute path to the file to modify"),
23+
oldString: z.string().describe("The text to replace"),
24+
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
25+
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
26+
}),
27+
)
28+
.describe("Array of edit operations to perform sequentially on the file"),
29+
}),
30+
execute: (params: { filePath: string; edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> }, ctx: Tool.Context) =>
31+
Effect.gen(function* () {
32+
const results = []
33+
for (const [, entry] of params.edits.entries()) {
34+
const result = yield* Effect.promise(() =>
35+
edit.execute(
36+
{
37+
filePath: params.filePath,
38+
oldString: entry.oldString,
39+
newString: entry.newString,
40+
replaceAll: entry.replaceAll,
41+
},
42+
ctx,
43+
),
44+
)
45+
results.push(result)
46+
}
47+
return {
48+
title: path.relative(Instance.worktree, params.filePath),
49+
metadata: {
50+
results: results.map((r) => r.metadata),
51+
},
52+
output: results.at(-1)!.output,
53+
}
54+
}).pipe(Effect.orDie, Effect.runPromise),
4455
}
45-
},
46-
})
56+
}),
57+
)

0 commit comments

Comments
 (0)