Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e50080ca8 | |||
| 517a4a1047 | |||
| c8167aa03c |
+22
-15
@@ -23,7 +23,7 @@ export interface FileUpdate {
|
||||
}
|
||||
|
||||
export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
const lines = stripHeredoc(patchText.trim()).split("\n")
|
||||
const lines = stripHeredoc(patchText.replaceAll("\r\n", "\n").trim()).split("\n")
|
||||
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
|
||||
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
|
||||
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
|
||||
@@ -34,7 +34,10 @@ export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
const line = lines[index]!
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const path = line.slice("*** Add File:".length).trim()
|
||||
if (!path) throw new Error("Invalid add file path")
|
||||
if (!path) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
const parsed = parseAdd(lines, index + 1)
|
||||
hunks.push({ type: "add", path, contents: parsed.content })
|
||||
index = parsed.next
|
||||
@@ -42,28 +45,32 @@ export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
}
|
||||
if (line.startsWith("*** Delete File:")) {
|
||||
const path = line.slice("*** Delete File:".length).trim()
|
||||
if (!path) throw new Error("Invalid delete file path")
|
||||
if (!path) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
hunks.push({ type: "delete", path })
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const path = line.slice("*** Update File:".length).trim()
|
||||
if (!path) throw new Error("Invalid update file path")
|
||||
if (!path) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
let next = index + 1
|
||||
let movePath: string | undefined
|
||||
if (lines[next]?.startsWith("*** Move to:")) {
|
||||
movePath = lines[next]!.slice("*** Move to:".length).trim()
|
||||
if (!movePath) throw new Error("Invalid move file path")
|
||||
movePath = lines[next]!.slice("*** Move to:".length).trim() || undefined
|
||||
next++
|
||||
}
|
||||
const parsed = parseUpdate(lines, next)
|
||||
if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
|
||||
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
throw new Error(`Invalid patch line: ${line}`)
|
||||
index++
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
@@ -89,8 +96,7 @@ function parseAdd(lines: ReadonlyArray<string>, start: number) {
|
||||
const content: string[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
|
||||
content.push(lines[index]!.slice(1))
|
||||
if (lines[index]!.startsWith("+")) content.push(lines[index]!.slice(1))
|
||||
index++
|
||||
}
|
||||
return { content: content.join("\n"), next: index }
|
||||
@@ -100,14 +106,16 @@ function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
const chunks: UpdateFileChunk[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("@@")) {
|
||||
throw new Error(`Invalid update file line: ${lines[index]}`)
|
||||
const explicit = lines[index]!.startsWith("@@")
|
||||
if (!explicit && (chunks.length > 0 || !/^[ +-]/.test(lines[index]!))) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
const changeContext = lines[index]!.slice(2).trim() || undefined
|
||||
const changeContext = explicit ? lines[index]!.slice(2).trim() || undefined : undefined
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
let endOfFile = false
|
||||
index++
|
||||
if (explicit) index++
|
||||
while (index < lines.length && !lines[index]!.startsWith("@@")) {
|
||||
const line = lines[index]!
|
||||
if (line === "*** End of File") {
|
||||
@@ -121,7 +129,6 @@ function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
newLines.push(line.slice(1))
|
||||
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
|
||||
else if (line.startsWith("+")) newLines.push(line.slice(1))
|
||||
else throw new Error(`Invalid update chunk line: ${line}`)
|
||||
index++
|
||||
}
|
||||
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
|
||||
|
||||
@@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
- Try to use patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
|
||||
@@ -24,8 +24,8 @@ If you notice unexpected changes in the worktree or staging area that you did no
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
|
||||
- Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch.
|
||||
- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.
|
||||
- Always use patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with patch.
|
||||
- Do not use Python to read/write files when a simple shell command or patch would suffice.
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as PatchTool from "./patch"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
@@ -11,6 +11,7 @@ import { LocationMutation } from "../location-mutation"
|
||||
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}`,
|
||||
),
|
||||
@@ -52,6 +53,7 @@ type Prepared =
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
@@ -68,8 +70,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,22 +89,39 @@ 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" }) })
|
||||
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" })
|
||||
}
|
||||
const targets: Array<{
|
||||
readonly hunk: Patch.Hunk
|
||||
readonly target: LocationMutation.Target
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
}> = []
|
||||
for (const hunk of hunks) {
|
||||
targets.push({
|
||||
hunk,
|
||||
target: yield* mutation.resolve({ path: hunk.path, kind: "file" }),
|
||||
moveTarget:
|
||||
hunk.type === "update" && hunk.movePath
|
||||
? yield* mutation.resolve({ path: hunk.movePath, kind: "file" })
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { target } of targets) {
|
||||
const external = target.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
for (const target of targets) {
|
||||
for (const item of [target.target, target.moveTarget]) {
|
||||
const external = item?.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
}
|
||||
}
|
||||
for (const external of externalDirectories.values()) {
|
||||
yield* permission.assert({
|
||||
@@ -123,15 +141,17 @@ export const Plugin = {
|
||||
})
|
||||
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, target } of targets) {
|
||||
for (const { hunk, target, moveTarget } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
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
|
||||
}
|
||||
@@ -143,7 +163,11 @@ export const Plugin = {
|
||||
prepared.push({ ...hunk, target, before, after: "" })
|
||||
return
|
||||
}
|
||||
const update = Patch.derive(hunk.path, hunk.chunks, original)
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
new ToolFailure({ message: `patch verification failed: ${String(error)}` }),
|
||||
})
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
@@ -151,8 +175,9 @@ export const Plugin = {
|
||||
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)
|
||||
@@ -161,7 +186,7 @@ export const Plugin = {
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({
|
||||
const result = yield* files.write({
|
||||
target: change.target,
|
||||
content:
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
@@ -176,6 +201,15 @@ export const Plugin = {
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.moveTarget) {
|
||||
const result = yield* files.write({
|
||||
target: change.moveTarget,
|
||||
content: change.content,
|
||||
})
|
||||
yield* files.remove({ target: change.target })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({
|
||||
target: change.target,
|
||||
expected: change.source,
|
||||
@@ -199,7 +233,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,16 +246,18 @@ 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),
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const diff = structuredPatch(change.target.resource, target, change.before, change.after)
|
||||
const counts = diff.hunks.flatMap((hunk) => hunk.lines).reduce(
|
||||
(result, line) => ({
|
||||
additions: result.additions + (line.startsWith("+") ? 1 : 0),
|
||||
deletions: result.deletions + (line.startsWith("-") ? 1 : 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: formatPatch(diff),
|
||||
status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified",
|
||||
...counts,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
Use the `patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level 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
|
||||
@@ -25,12 +25,63 @@ describe("Patch", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("strips a heredoc wrapper without cat", () => {
|
||||
expect(Patch.parse("<<EOF\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([
|
||||
{ type: "add", path: "add.txt", contents: "added" },
|
||||
])
|
||||
})
|
||||
|
||||
test("derives fuzzy line updates while preserving BOM", () => {
|
||||
const update = Patch.derive("update.txt", [{ oldLines: [" old "], newLines: ["new"] }], "\uFEFFold\n")
|
||||
expect(update).toEqual({ content: "new\n", bom: true })
|
||||
expect(Patch.joinBom(update.content, update.bom)).toBe("\uFEFFnew\n")
|
||||
})
|
||||
|
||||
test("derives multiple update chunks", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[
|
||||
{ oldLines: ["line 2"], newLines: ["LINE 2"] },
|
||||
{ oldLines: ["line 4"], newLines: ["LINE 4"] },
|
||||
],
|
||||
"line 1\nline 2\nline 3\nline 4\n",
|
||||
).content,
|
||||
).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
|
||||
})
|
||||
|
||||
test("updates empty files and adds a trailing newline", () => {
|
||||
expect(Patch.derive("empty.txt", [{ oldLines: [], newLines: ["First line"] }], "").content).toBe(
|
||||
"First line\n",
|
||||
)
|
||||
expect(Patch.derive("no-newline.txt", [{ oldLines: ["old"], newLines: ["new"] }], "old").content).toBe(
|
||||
"new\n",
|
||||
)
|
||||
})
|
||||
|
||||
test("disambiguates updates with change context", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
"update.txt",
|
||||
[{ oldLines: ["x=10"], newLines: ["x=11"], changeContext: "fn b" }],
|
||||
"fn a\nx=10\nfn b\nx=10\n",
|
||||
).content,
|
||||
).toBe("fn a\nx=10\nfn b\nx=11\n")
|
||||
})
|
||||
|
||||
test("matches leading, trailing, and Unicode punctuation differences", () => {
|
||||
expect(Patch.derive("leading.txt", [{ oldLines: ["line"], newLines: ["next"] }], " line\n").content).toBe(
|
||||
"next\n",
|
||||
)
|
||||
expect(Patch.derive("trailing.txt", [{ oldLines: ["line"], newLines: ["next"] }], "line \n").content).toBe(
|
||||
"next\n",
|
||||
)
|
||||
expect(
|
||||
Patch.derive('unicode.txt', [{ oldLines: ['He said "hello"'], newLines: ['He said "hi"'] }], 'He said “hello”\n')
|
||||
.content,
|
||||
).toBe('He said "hi"\n')
|
||||
})
|
||||
|
||||
test("matches EOF-anchored chunks from the end", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
@@ -54,15 +105,56 @@ describe("Patch", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects malformed hunk bodies", () => {
|
||||
expect(() => Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toThrow(
|
||||
"Invalid add file line",
|
||||
)
|
||||
expect(() => Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toThrow(
|
||||
"expected at least one @@ chunk",
|
||||
)
|
||||
expect(() => Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toThrow(
|
||||
"Invalid patch line",
|
||||
)
|
||||
test("parses an initial update chunk without an explicit header", () => {
|
||||
expect(
|
||||
Patch.parse("*** Begin Patch\n*** Update File: file.py\n import foo\n+bar\n*** End Patch"),
|
||||
).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.py",
|
||||
movePath: undefined,
|
||||
chunks: [
|
||||
{
|
||||
oldLines: ["import foo"],
|
||||
newLines: ["import foo", "bar"],
|
||||
changeContext: undefined,
|
||||
endOfFile: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes CRLF patch lines without removing content carriage returns", () => {
|
||||
expect(
|
||||
Patch.parse(
|
||||
"*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\n+new\r\n*** End Patch\r\n",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "update",
|
||||
path: "file.txt",
|
||||
movePath: undefined,
|
||||
chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
Patch.parse(
|
||||
"*** Begin Patch\r\n*** Update File: file.txt\r\n@@\r\n-old\r\r\n+new\r\n*** End Patch\r\n",
|
||||
)[0],
|
||||
).toMatchObject({ chunks: [{ oldLines: ["old\r"], newLines: ["new"] }] })
|
||||
})
|
||||
|
||||
test("matches V1 lenient parsing of malformed hunk bodies", () => {
|
||||
expect(Patch.parse("*** Begin Patch\n*** Add File: add.txt\nmissing plus\n*** End Patch")).toEqual([
|
||||
{ type: "add", path: "add.txt", contents: "" },
|
||||
])
|
||||
expect(Patch.parse("*** Begin Patch\n*** Update File: update.txt\n*** End Patch")).toEqual([
|
||||
{ type: "update", path: "update.txt", movePath: undefined, chunks: [] },
|
||||
])
|
||||
expect(Patch.parse("*** Begin Patch\n*** Delete File: delete.txt\nunexpected body\n*** End Patch")).toEqual([
|
||||
{ type: "delete", path: "delete.txt" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
@@ -167,7 +167,7 @@ describe("PatchTool", () => {
|
||||
)
|
||||
expect(settled.result).toEqual({
|
||||
type: "text",
|
||||
value: "Applied patch sequentially:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt",
|
||||
})
|
||||
expect(settled.output?.structured).toMatchObject({
|
||||
applied: [
|
||||
@@ -217,7 +217,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects moves before applying any hunk", () =>
|
||||
it.live("moves and updates a file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -234,9 +234,17 @@ describe("PatchTool", () => {
|
||||
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
),
|
||||
).toEqual({ type: "error", value: "patch moves are not supported yet" })
|
||||
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
|
||||
expect(assertions).toEqual([])
|
||||
).toEqual({
|
||||
type: "text",
|
||||
value: "Success. Updated the following files:\nA created.txt\nM moved.txt",
|
||||
})
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe(
|
||||
"after\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "created.txt"), "utf8"))).toBe(
|
||||
"created\n",
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -246,6 +254,201 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("moves a file over an existing destination", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const source = path.join(tmp.path, "old.txt")
|
||||
const destination = path.join(tmp.path, "nested", "moved.txt")
|
||||
return Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(source, "before\n"),
|
||||
fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")),
|
||||
]),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(yield* exists(source)).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects missing, invalid, and empty patches", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" })
|
||||
expect(yield* executeTool(registry, call("invalid patch", "invalid"))).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining("patch verification failed"),
|
||||
})
|
||||
expect(
|
||||
yield* executeTool(registry, call("*** Begin Patch\n*** End Patch", "empty")),
|
||||
).toEqual({ type: "error", value: "patch rejected: empty patch" })
|
||||
expect(
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch", "unknown"),
|
||||
),
|
||||
).toEqual({ type: "error", value: "patch verification failed: no hunks found" })
|
||||
expect(yield* executeTool(registry, call(" ", "whitespace"))).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining("patch verification failed"),
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("matches V1 update, BOM, heredoc, and fuzzy matching behavior", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const run = (patchText: string, id: string) => executeTool(registry, call(patchText, id))
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "multi.txt"), "a\nb\nc\nd\n"))
|
||||
expect(
|
||||
yield* run(
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch",
|
||||
"multi",
|
||||
),
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "multi.txt"), "utf8"))).toBe(
|
||||
"a\nB\nc\nD\n",
|
||||
)
|
||||
|
||||
const bom = "\uFEFF"
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "bom.txt"), `${bom}first\nsecond\n`))
|
||||
const bomResult = yield* settleTool(
|
||||
registry,
|
||||
call(
|
||||
"*** Begin Patch\n*** Update File: bom.txt\n@@\n-second\n+changed\n*** End Patch",
|
||||
"bom",
|
||||
),
|
||||
)
|
||||
const bomOutput = Schema.decodeUnknownSync(PatchTool.Output)(bomResult.output?.structured)
|
||||
expect(bomOutput.files[0]?.patch).not.toContain(bom)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "bom.txt"), "utf8"))).toBe(
|
||||
`${bom}first\nchanged\n`,
|
||||
)
|
||||
|
||||
const bomAddResult = yield* settleTool(
|
||||
registry,
|
||||
call(`*** Begin Patch\n*** Add File: bom-add.txt\n+${bom}first\n*** End Patch`, "bom-add"),
|
||||
)
|
||||
const bomAddOutput = Schema.decodeUnknownSync(PatchTool.Output)(bomAddResult.output?.structured)
|
||||
expect(bomAddOutput.files[0]?.patch).not.toContain(bom)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "bom-add.txt"), "utf8"))).toBe(
|
||||
`${bom}first\n`,
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "no-newline.txt"), "old"))
|
||||
yield* run(
|
||||
"*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-old\n+new\n*** End Patch",
|
||||
"no-newline",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "no-newline.txt"), "utf8"))).toBe(
|
||||
"new\n",
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "context.txt"), "fn a\nx=10\nfn b\nx=10\n"))
|
||||
yield* run(
|
||||
"*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch",
|
||||
"context",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "context.txt"), "utf8"))).toBe(
|
||||
"fn a\nx=10\nfn b\nx=11\n",
|
||||
)
|
||||
|
||||
yield* run(
|
||||
"cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF",
|
||||
"heredoc-cat",
|
||||
)
|
||||
yield* run(
|
||||
"<<EOF\n*** Begin Patch\n*** Add File: heredoc-plain.txt\n+without cat\n*** End Patch\nEOF",
|
||||
"heredoc-plain",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "heredoc.txt"), "utf8"))).toBe(
|
||||
"with cat\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "heredoc-plain.txt"), "utf8"))).toBe(
|
||||
"without cat\n",
|
||||
)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.writeFile(path.join(tmp.path, "leading.txt"), " line\n"),
|
||||
fs.writeFile(path.join(tmp.path, "trailing.txt"), "line \n"),
|
||||
fs.writeFile(path.join(tmp.path, "unicode.txt"), 'He said “hello”\n'),
|
||||
]),
|
||||
)
|
||||
yield* run(
|
||||
"*** Begin Patch\n*** Update File: leading.txt\n@@\n-line\n+leading\n*** Update File: trailing.txt\n@@\n-line\n+trailing\n*** Update File: unicode.txt\n@@\n-He said \"hello\"\n+He said \"hi\"\n*** End Patch",
|
||||
"fuzzy",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "leading.txt"), "utf8"))).toBe(
|
||||
"leading\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "trailing.txt"), "utf8"))).toBe(
|
||||
"trailing\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "unicode.txt"), "utf8"))).toBe(
|
||||
'He said "hi"\n',
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "unchanged.txt"), "line1\nline2\n"))
|
||||
expect(
|
||||
yield* run(
|
||||
"*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch",
|
||||
"missing-context",
|
||||
),
|
||||
).toMatchObject({
|
||||
type: "error",
|
||||
value: expect.stringContaining("Failed to find expected lines"),
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "unchanged.txt"), "utf8"))).toBe(
|
||||
"line1\nline2\n",
|
||||
)
|
||||
expect(
|
||||
yield* run(
|
||||
"*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch",
|
||||
"missing-update",
|
||||
),
|
||||
).toMatchObject({ type: "error" })
|
||||
expect(
|
||||
yield* run("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch", "missing-delete"),
|
||||
).toMatchObject({ type: "error" })
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("approves an external directory and the batch before reading external update content", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
@@ -369,7 +572,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects add hunks targeting an existing file without replacing it", () =>
|
||||
it.live("adds files by overwriting existing targets", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -384,8 +587,8 @@ describe("PatchTool", () => {
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -395,7 +598,7 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects an add target that appears during permission approval", () =>
|
||||
it.live("overwrites an add target that appears during permission approval", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -409,8 +612,8 @@ describe("PatchTool", () => {
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
|
||||
),
|
||||
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
|
||||
).toMatchObject({ type: "text" })
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("replacement\n")
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user