fix(core): match dev patch behavior (#37709)

This commit is contained in:
Aiden Cline 2026-07-20 16:46:11 -05:00 committed by GitHub
commit 455b5d3165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 886 additions and 188 deletions

View file

@ -5,12 +5,13 @@ import { ToolFailure } from "@opencode-ai/ai"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Schema } from "effect"
import { FileMutation } from "../file-mutation"
import path from "path"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Location } from "../location"
import { Patch } from "../patch"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import DESCRIPTION from "./patch.txt"
export const name = "patch"
@ -34,7 +35,7 @@ export type Output = typeof Output.Type
export const toModelOutput = (output: Output) =>
[
"Applied patch sequentially:",
"Success. Updated the following files:",
...output.applied.map(
(item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`,
),
@ -42,24 +43,32 @@ export const toModelOutput = (output: Output) =>
type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & {
readonly target: LocationMutation.Target
readonly target: Target
readonly before: string
readonly after: string
})
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationMutation.Target
readonly source: Uint8Array
readonly target: Target
readonly content: string
readonly before: string
readonly after: string
readonly moveTarget?: Target
})
interface Target {
readonly canonical: string
readonly resource: string
readonly externalDirectory?: {
readonly directory: string
readonly resource: string
}
}
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
@ -68,8 +77,7 @@ export const Plugin = {
name,
Tool.withPermission(
Tool.make({
description:
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
description: DESCRIPTION,
input: Input,
output: Output,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
@ -88,100 +96,163 @@ export const Plugin = {
messageID: context.messageID,
callID: context.callID,
}
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" })
const hunks = yield* Effect.try({
try: () => Patch.parse(input.patchText),
catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }),
})
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" })
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
for (const hunk of hunks)
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
for (const { target } of targets) {
const external = target.externalDirectory
if (external) externalDirectories.set(external.resource, external)
if (hunks.length === 0) {
const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
}
return yield* new ToolFailure({ message: "patch verification failed: no hunks found" })
}
for (const external of externalDirectories.values()) {
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map(({ target }) => target.resource))],
save: ["*"],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const prepared: Prepared[] = []
for (const { hunk, target } of targets) {
const targets: Target[] = []
for (const hunk of hunks) {
yield* Effect.gen(function* () {
const target = resolveTarget(location, hunk.path)
targets.push(target)
if (target.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [target.externalDirectory.resource],
save: [target.externalDirectory.resource],
metadata: {
filepath: target.canonical,
parentDir: target.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
if (hunk.type === "add") {
prepared.push({
...hunk,
target,
before: "",
after:
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`,
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
? hunk.contents
: `${hunk.contents}\n`
).replace(/^\uFEFF/, ""),
})
return
}
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
const source = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source)
const before = original.replace(/^\uFEFF/, "")
if (hunk.type === "delete") {
prepared.push({ ...hunk, target, before, after: "" })
const content = yield* fs.readFile(target.canonical).pipe(
Effect.mapError(
(error) =>
new ToolFailure({
message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
}),
),
)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
return
}
const update = Patch.derive(hunk.path, hunk.chunks, original)
const stats = yield* fs
.stat(target.canonical)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stats || stats.type === "Directory") {
return yield* new ToolFailure({
message: `patch verification failed: Failed to read file to update: ${target.canonical}`,
})
}
const content = yield* fs.readFile(target.canonical)
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
const before = original.replace(/^\uFEFF/, "")
const update = yield* Effect.try({
try: () => Patch.derive(hunk.path, hunk.chunks, original),
catch: (error) =>
new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
})
const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined
if (moveTarget?.externalDirectory) {
yield* permission.assert({
action: "external_directory",
resources: [moveTarget.externalDirectory.resource],
save: [moveTarget.externalDirectory.resource],
metadata: {
filepath: moveTarget.canonical,
parentDir: moveTarget.externalDirectory.directory,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
}
prepared.push({
...hunk,
target,
source,
content: Patch.joinBom(update.content, update.bom),
before,
after: update.content,
moveTarget,
})
}).pipe(Effect.mapError((error) => fail(hunk.path, error)))
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error))))
}
const patchFiles = prepared.map(patchFile)
yield* permission.assert({
action: "edit",
resources: [...new Set(targets.map((target) => target.resource))],
save: ["*"],
metadata: {
filepath: targets.map((target) => target.resource).join(", "),
diff: patchFiles.map((file) => `${file.patch}\n`).join(""),
files: patchFiles,
},
sessionID: context.sessionID,
agent: context.agent,
source,
})
yield* Effect.forEach(
prepared,
(change) =>
Effect.gen(function* () {
if (change.type === "add") {
const result = yield* files.create({
target: change.target,
content:
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
yield* fs.writeWithDirs(
change.target.canonical,
change.contents.endsWith("\n") || change.contents === ""
? change.contents
: `${change.contents}\n`,
)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
return
}
if (change.type === "delete") {
const result = yield* files.remove({ target: change.target })
applied.push({ type: change.type, resource: result.resource, target: result.target })
yield* fs.remove(change.target.canonical)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
return
}
const result = yield* files.writeIfUnchanged({
target: change.target,
expected: change.source,
content: change.content,
if (change.moveTarget) {
yield* fs.writeWithDirs(change.moveTarget.canonical, change.content)
yield* fs.remove(change.target.canonical)
applied.push({
type: change.type,
resource: change.moveTarget.resource,
target: change.moveTarget.canonical,
})
return
}
yield* fs.writeWithDirs(change.target.canonical, change.content)
applied.push({
type: change.type,
resource: change.target.resource,
target: change.target.canonical,
})
applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError((error) => fail(change.path, error))),
{ discard: true },
)
@ -199,7 +270,7 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
if (usePatch) {
delete event.tools.edit
delete event.tools.write
@ -212,17 +283,74 @@ export const Plugin = {
}
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
const patch = trimDiff(
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
)
const counts =
change.type === "delete"
? { additions: 0, deletions: change.before.split("\n").length }
: diffLines(change.before, change.after).reduce(
(result, item) => ({
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
}),
{ additions: 0, deletions: 0 },
)
return {
file: change.target.resource,
patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after),
file: target,
patch,
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
...counts,
}
}
function trimDiff(diff: string) {
const lines = diff.split("\n")
const content = lines.filter(
(line) =>
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++"),
)
if (content.length === 0) return diff
const indent = content.reduce((result, line) => {
const value = line.slice(1)
if (value.trim().length === 0) return result
return Math.min(result, value.match(/^(\s*)/)?.[1].length ?? result)
}, Infinity)
if (indent === Infinity || indent === 0) return diff
return lines
.map((line) => {
if (
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++")
) {
return line[0] + line.slice(1 + indent)
}
return line
})
.join("\n")
}
function resolveTarget(location: Location.Interface, value: string): Target {
const canonical =
process.platform === "win32"
? FSUtil.normalizePath(path.resolve(location.directory, value))
: path.resolve(location.directory, value)
const projectRoot = path.parse(location.project.directory).root
const external =
!FSUtil.contains(location.directory, canonical) &&
(location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical))
const directory = path.dirname(canonical)
const resource =
process.platform === "win32"
? FSUtil.normalizePathPattern(path.join(directory, "*"))
: path.join(directory, "*").replaceAll("\\", "/")
return {
canonical,
resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".",
externalDirectory: external ? { directory, resource } : undefined,
}
}

View file

@ -0,0 +1,33 @@
Use the `patch` tool to edit files. Your patch language is a strippeddown, fileoriented diff format designed to be easy to parse and safe to apply. You can think of it as a highlevel envelope:
*** Begin Patch
[ one or more file sections ]
*** End Patch
Within that envelope, you get a sequence of file operations.
You MUST include a header to specify the action you are taking.
Each operation starts with one of three headers:
*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
*** Delete File: <path> - remove an existing file. Nothing follows.
*** Update File: <path> - patch an existing file in place (optionally with a rename).
Example patch:
```
*** Begin Patch
*** Add File: hello.txt
+Hello world
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** Delete File: obsolete.txt
*** End Patch
```
It is important to remember:
- You must include a header with your intended action (Add/Delete/Update)
- You must prefix new lines with `+` even when creating a new file