Skip to content

Commit 0ece10a

Browse files
authored
fix(core): add mutation permission previews (#39578)
1 parent d1a02b1 commit 0ece10a

4 files changed

Lines changed: 95 additions & 37 deletions

File tree

packages/core/src/tool/plugin/edit.ts

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,6 @@ export const Plugin = {
153153
})
154154
}
155155

156-
yield* permission.assert({
157-
action: "edit",
158-
resources: [target.resource],
159-
save: ["*"],
160-
sessionID: context.sessionID,
161-
agent: context.agent,
162-
source: permissionSource,
163-
})
164156
const info = yield* fs.stat(target.canonical).pipe(
165157
Effect.catchReason("PlatformError", "NotFound", () =>
166158
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
@@ -184,6 +176,26 @@ export const Plugin = {
184176
: findLineOccurrences(source, oldString)
185177
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
186178
const replacements = matches.length
179+
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
180+
.toReversed()
181+
.reduce(
182+
(content, match) =>
183+
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
184+
source,
185+
)
186+
const preview =
187+
replacements > 0 && (replacements === 1 || input.replaceAll === true)
188+
? fileDiff(target.resource, source, replaced)
189+
: undefined
190+
yield* permission.assert({
191+
action: "edit",
192+
resources: [target.resource],
193+
save: ["*"],
194+
metadata: preview ? { files: [preview] } : undefined,
195+
sessionID: context.sessionID,
196+
agent: context.agent,
197+
source: permissionSource,
198+
})
187199
if (replacements === 0) {
188200
return yield* new ToolFailure({
189201
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
@@ -194,14 +206,6 @@ export const Plugin = {
194206
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
195207
})
196208
}
197-
198-
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
199-
.toReversed()
200-
.reduce(
201-
(content, match) =>
202-
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
203-
source,
204-
)
205209
const replacementBom = replaced.startsWith("\uFEFF")
206210
const result = yield* files.write({
207211
target,
@@ -211,22 +215,8 @@ export const Plugin = {
211215
const formatted = (yield* formatter.file(target.canonical))
212216
? yield* Bom.syncFile(fs, target.canonical, bom)
213217
: (yield* Bom.readFile(fs, target.canonical)).text
214-
const counts = diffLines(source, formatted).reduce(
215-
(result, item) => ({
216-
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
217-
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
218-
}),
219-
{ additions: 0, deletions: 0 },
220-
)
221218
return {
222-
files: [
223-
{
224-
file: result.resource,
225-
patch: createTwoFilesPatch(result.resource, result.resource, source, formatted),
226-
status: "modified" as const,
227-
...counts,
228-
},
229-
],
219+
files: [fileDiff(result.resource, source, formatted)],
230220
replacements,
231221
} satisfies Output
232222
}).pipe(
@@ -248,3 +238,19 @@ export const Plugin = {
248238
.pipe(Effect.orDie)
249239
}),
250240
}
241+
242+
function fileDiff(file: string, before: string, after: string): typeof FileDiff.Info.Type {
243+
const counts = diffLines(before, after).reduce(
244+
(result, item) => ({
245+
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
246+
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
247+
}),
248+
{ additions: 0, deletions: 0 },
249+
)
250+
return {
251+
file,
252+
patch: createTwoFilesPatch(file, file, before, after),
253+
status: "modified",
254+
...counts,
255+
}
256+
}

packages/core/src/tool/plugin/write.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export * as WriteTool from "./write"
88

99
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
1010
import { ToolFailure } from "@opencode-ai/ai"
11+
import { FileDiff } from "@opencode-ai/schema/file-diff"
1112
import { Effect, Schema } from "effect"
13+
import { createTwoFilesPatch, diffLines } from "diff"
1214
import { Bom } from "@opencode-ai/util/bom"
1315
import { FSUtil } from "@opencode-ai/util/fs-util"
1416
import { FileMutation } from "../../file-mutation"
@@ -21,8 +23,7 @@ export const name = "write"
2123
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
2224
export const Input = Schema.Struct({
2325
path: Schema.String.annotate({
24-
description:
25-
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
26+
description: "Path to the file to write to",
2627
}),
2728
content: Schema.String.annotate({ description: "Content to write to the file" }),
2829
})
@@ -59,7 +60,7 @@ export const Plugin = {
5960
name,
6061
options: { codemode: false, permission: "edit" },
6162
description:
62-
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
63+
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
6364
input: Input,
6465
output: Output,
6566
execute: (input, context) =>
@@ -78,10 +79,28 @@ export const Plugin = {
7879
agent: context.agent,
7980
source,
8081
})
82+
const current = yield* Bom.readFile(fs, target.canonical).pipe(
83+
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
84+
)
85+
const next = Bom.split(input.content)
86+
const counts = diffLines(current?.text ?? "", next.text).reduce(
87+
(result, item) => ({
88+
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
89+
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
90+
}),
91+
{ additions: 0, deletions: 0 },
92+
)
93+
const preview: typeof FileDiff.Info.Type = {
94+
file: target.resource,
95+
patch: createTwoFilesPatch(target.resource, target.resource, current?.text ?? "", next.text),
96+
status: current ? "modified" : "added",
97+
...counts,
98+
}
8199
yield* permission.assert({
82100
action: "edit",
83101
resources: [target.resource],
84102
save: ["*"],
103+
metadata: { files: [preview] },
85104
sessionID: context.sessionID,
86105
agent: context.agent,
87106
source,

packages/core/test/tool-edit.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,17 @@ describe("EditTool", () => {
179179
})
180180
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
181181
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
182+
expect(assertions[0]?.metadata).toMatchObject({
183+
files: [
184+
{
185+
file: "hello.txt",
186+
status: "modified",
187+
additions: 1,
188+
deletions: 1,
189+
patch: expect.stringContaining("-before\n+after"),
190+
},
191+
],
192+
})
182193
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
183194
}),
184195
),
@@ -343,7 +354,7 @@ describe("EditTool", () => {
343354
error: { type: "permission.rejected", message: "Permission denied: edit" },
344355
})
345356
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
346-
expect(reads).toBe(0)
357+
expect(reads).toBe(1)
347358
expect(writes).toEqual([])
348359
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
349360
}),
@@ -354,7 +365,7 @@ describe("EditTool", () => {
354365
),
355366
)
356367

357-
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
368+
it.live("denied edit does not disclose whether oldString matches", () =>
358369
Effect.acquireUseRelease(
359370
Effect.promise(() => tmpdir()),
360371
(tmp) => {
@@ -380,7 +391,7 @@ describe("EditTool", () => {
380391
})
381392
expect(missing).toEqual(matching)
382393
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
383-
expect(reads).toBe(0)
394+
expect(reads).toBe(2)
384395
expect(writes).toEqual([])
385396
}),
386397
),

packages/core/test/tool-write.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,17 @@ describe("WriteTool", () => {
140140
"created",
141141
)
142142
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
143+
expect(assertions[0]?.metadata).toMatchObject({
144+
files: [
145+
{
146+
file: "src/new.txt",
147+
status: "added",
148+
additions: 1,
149+
deletions: 0,
150+
patch: expect.stringContaining("+created"),
151+
},
152+
],
153+
})
143154
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
144155
}),
145156
)
@@ -187,6 +198,17 @@ describe("WriteTool", () => {
187198
if (settled.status !== "completed") return
188199
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
189200
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
201+
expect(assertions[0]?.metadata).toMatchObject({
202+
files: [
203+
{
204+
file: "existing.txt",
205+
status: "modified",
206+
additions: 1,
207+
deletions: 1,
208+
patch: expect.stringMatching(/-before[\s\S]*\+after/),
209+
},
210+
],
211+
})
190212
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
191213
"after",
192214
)

0 commit comments

Comments
 (0)