From c8167aa03c0d9c245b0a30b9d50b99d07b9393b0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 18 Jul 2026 23:44:15 -0500 Subject: [PATCH 1/8] fix(core): restore patch tool parity --- packages/core/src/patch.ts | 28 ++- .../core/src/plugin/system-prompt/codex.txt | 2 +- .../core/src/plugin/system-prompt/gpt.txt | 4 +- packages/core/src/tool/patch.ts | 85 +++++-- packages/core/src/tool/patch.txt | 33 +++ packages/core/test/patch.test.ts | 71 +++++- packages/core/test/tool-patch.test.ts | 227 +++++++++++++++++- 7 files changed, 391 insertions(+), 59 deletions(-) create mode 100644 packages/core/src/tool/patch.txt diff --git a/packages/core/src/patch.ts b/packages/core/src/patch.ts index a4370d44aac1..bd053cd7e492 100644 --- a/packages/core/src/patch.ts +++ b/packages/core/src/patch.ts @@ -34,7 +34,10 @@ export function parse(patchText: string): ReadonlyArray { 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 { } 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, 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 } @@ -101,7 +107,8 @@ function parseUpdate(lines: ReadonlyArray, start: number) { let index = start while (index < lines.length && !lines[index]!.startsWith("***")) { if (!lines[index]!.startsWith("@@")) { - throw new Error(`Invalid update file line: ${lines[index]}`) + index++ + continue } const changeContext = lines[index]!.slice(2).trim() || undefined const oldLines: string[] = [] @@ -121,7 +128,6 @@ function parseUpdate(lines: ReadonlyArray, 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 }) diff --git a/packages/core/src/plugin/system-prompt/codex.txt b/packages/core/src/plugin/system-prompt/codex.txt index 949390068d68..be926be5f8bd 100644 --- a/packages/core/src/plugin/system-prompt/codex.txt +++ b/packages/core/src/plugin/system-prompt/codex.txt @@ -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: diff --git a/packages/core/src/plugin/system-prompt/gpt.txt b/packages/core/src/plugin/system-prompt/gpt.txt index 8870e5421e3f..9839d5a6c687 100644 --- a/packages/core/src/plugin/system-prompt/gpt.txt +++ b/packages/core/src/plugin/system-prompt/gpt.txt @@ -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. diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 6925b8779338..103f08277f86 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -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() - 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 @@ -220,8 +254,13 @@ function patchFile(change: Prepared): typeof FileDiff.Info.Type { { additions: 0, deletions: 0 }, ) return { - file: change.target.resource, - patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + file: (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource, + patch: createTwoFilesPatch( + change.target.resource, + (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource, + change.before, + change.after, + ), status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", ...counts, } diff --git a/packages/core/src/tool/patch.txt b/packages/core/src/tool/patch.txt new file mode 100644 index 000000000000..72c0489c0d3c --- /dev/null +++ b/packages/core/src/tool/patch.txt @@ -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: - create a new file. Every following line is a + line (the initial contents). +*** Delete File: - remove an existing file. Nothing follows. +*** Update File: - 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 diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index 10560bf92884..9a8492914ef8 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -25,12 +25,63 @@ describe("Patch", () => { ]) }) + test("strips a heredoc wrapper without cat", () => { + expect(Patch.parse("< { 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,15 @@ 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("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" }, + ]) }) }) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 458f8c3758cf..2d9073660573 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -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,52 @@ 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", + ) + }), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + 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") }), ), ), @@ -246,6 +289,166 @@ describe("PatchTool", () => { ), ) + 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( + "< 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") }), ) }, From 517a4a1047ddb1551cfd0185aff3537bd3eaa1e9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 09:52:33 -0500 Subject: [PATCH 2/8] fix(core): match Codex patch update syntax --- packages/core/src/patch.ts | 9 +++---- packages/core/test/patch.test.ts | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/core/src/patch.ts b/packages/core/src/patch.ts index bd053cd7e492..e7c4bd530f33 100644 --- a/packages/core/src/patch.ts +++ b/packages/core/src/patch.ts @@ -23,7 +23,7 @@ export interface FileUpdate { } export function parse(patchText: string): ReadonlyArray { - 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") @@ -106,15 +106,16 @@ function parseUpdate(lines: ReadonlyArray, start: number) { const chunks: UpdateFileChunk[] = [] let index = start while (index < lines.length && !lines[index]!.startsWith("***")) { - if (!lines[index]!.startsWith("@@")) { + 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") { diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index 9a8492914ef8..abb8a5c9a2c5 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -105,6 +105,47 @@ describe("Patch", () => { ]) }) + 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: "" }, From 3e50080ca8549dde90a31c9edb66b69305e5ad3f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 10:06:32 -0500 Subject: [PATCH 3/8] perf(core): compute patch diffs once --- packages/core/src/tool/patch.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 103f08277f86..b6c5c2b74fbf 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -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" @@ -246,21 +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.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource, - patch: createTwoFilesPatch( - change.target.resource, - (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource, - change.before, - change.after, - ), + file: target, + patch: formatPatch(diff), status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", ...counts, } From be8cdef7e6c3213c441bdc39c158a88fd0445f0b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 14:36:23 -0500 Subject: [PATCH 4/8] fix(core): preserve same-target patch moves --- packages/core/src/tool/patch.ts | 12 ++++++----- packages/core/test/tool-patch.test.ts | 31 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index b6c5c2b74fbf..e171959f96ae 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -107,13 +107,15 @@ export const Plugin = { readonly moveTarget?: LocationMutation.Target }> = [] for (const hunk of hunks) { + const target = yield* mutation.resolve({ path: hunk.path, kind: "file" }) + const moveTarget = + hunk.type === "update" && hunk.movePath + ? yield* mutation.resolve({ path: hunk.movePath, kind: "file" }) + : undefined 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, + target, + moveTarget: moveTarget?.canonical === target.canonical ? undefined : moveTarget, }) } const externalDirectories = new Map() diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 2d9073660573..b1f81bb9657c 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -254,6 +254,37 @@ describe("PatchTool", () => { ), ) + it.live("treats a move to the same canonical path as an update", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "same.txt") + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: same.txt\n*** Move to: ./same.txt\n@@\n-before\n+after\n*** End Patch", + ), + ), + ).toEqual({ + type: "text", + value: "Success. Updated the following files:\nM same.txt", + }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") + }), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("moves a file over an existing destination", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), From 34c19bd54bd6bdabf28e6633eac73e893d78fea1 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 15:11:36 -0500 Subject: [PATCH 5/8] fix(core): match dev patch behavior --- packages/core/src/patch.ts | 19 +- packages/core/src/tool/patch.ts | 209 ++++++--- packages/core/test/patch.test.ts | 73 +-- packages/core/test/tool-patch.test.ts | 614 ++++++++++++++++---------- 4 files changed, 545 insertions(+), 370 deletions(-) diff --git a/packages/core/src/patch.ts b/packages/core/src/patch.ts index e7c4bd530f33..404986cbc831 100644 --- a/packages/core/src/patch.ts +++ b/packages/core/src/patch.ts @@ -23,7 +23,7 @@ export interface FileUpdate { } export function parse(patchText: string): ReadonlyArray { - const lines = stripHeredoc(patchText.replaceAll("\r\n", "\n").trim()).split("\n") + const lines = stripHeredoc(patchText.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") @@ -62,7 +62,7 @@ export function parse(patchText: string): ReadonlyArray { let next = index + 1 let movePath: string | undefined if (lines[next]?.startsWith("*** Move to:")) { - movePath = lines[next]!.slice("*** Move to:".length).trim() || undefined + movePath = lines[next]!.slice("*** Move to:".length).trim() next++ } const parsed = parseUpdate(lines, next) @@ -106,24 +106,17 @@ function parseUpdate(lines: ReadonlyArray, start: number) { const chunks: UpdateFileChunk[] = [] let index = start while (index < lines.length && !lines[index]!.startsWith("***")) { - const explicit = lines[index]!.startsWith("@@") - if (!explicit && (chunks.length > 0 || !/^[ +-]/.test(lines[index]!))) { + if (!lines[index]!.startsWith("@@")) { index++ continue } - const changeContext = explicit ? lines[index]!.slice(2).trim() || undefined : undefined + const changeContext = lines[index]!.slice(2).trim() || undefined const oldLines: string[] = [] const newLines: string[] = [] let endOfFile = false - if (explicit) index++ - while (index < lines.length && !lines[index]!.startsWith("@@")) { + index++ + while (index < lines.length && !lines[index]!.startsWith("@@") && !lines[index]!.startsWith("***")) { const line = lines[index]! - if (line === "*** End of File") { - endOfFile = true - index++ - break - } - if (line.startsWith("***")) break if (line.startsWith(" ")) { oldLines.push(line.slice(1)) newLines.push(line.slice(1)) diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index e171959f96ae..0bcfbdbfbba9 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -3,10 +3,11 @@ 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 { formatPatch, structuredPatch } from "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 { Location } from "../location" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" import { PermissionV2 } from "../permission" @@ -49,7 +50,6 @@ type Prepared = }) | (Extract & { readonly target: LocationMutation.Target - readonly source: Uint8Array readonly content: string readonly before: string readonly after: string @@ -60,8 +60,8 @@ 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 @@ -107,44 +107,44 @@ export const Plugin = { readonly moveTarget?: LocationMutation.Target }> = [] for (const hunk of hunks) { - const target = yield* mutation.resolve({ path: hunk.path, kind: "file" }) + const resolvedTarget = yield* mutation.resolve({ path: hunk.path, kind: "file" }) + const targetPath = path.resolve(location.directory, hunk.path) + const target = { + ...resolvedTarget, + canonical: targetPath, + resource: path.relative(location.project.directory, targetPath).replaceAll("\\", "/") || ".", + } + const movePath = hunk.type === "update" ? hunk.movePath : undefined const moveTarget = - hunk.type === "update" && hunk.movePath - ? yield* mutation.resolve({ path: hunk.movePath, kind: "file" }) + movePath + ? yield* Effect.gen(function* () { + const resolved = yield* mutation.resolve({ path: movePath, kind: "file" }) + const target = path.resolve(location.directory, movePath) + return { + ...resolved, + canonical: target, + resource: + path.relative(location.project.directory, target).replaceAll("\\", "/") || ".", + } + }) : undefined targets.push({ hunk, target, - moveTarget: moveTarget?.canonical === target.canonical ? undefined : moveTarget, - }) - } - const externalDirectories = new Map() - 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({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source, + moveTarget, }) } - 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, moveTarget } of targets) { yield* Effect.gen(function* () { + if (target.externalDirectory) { + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(target.externalDirectory), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } if (hunk.type === "add") { prepared.push({ ...hunk, @@ -157,23 +157,46 @@ export const Plugin = { }) 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 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)}` }), }) + if (moveTarget?.externalDirectory) { + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } prepared.push({ ...hunk, target, - source, content: Patch.joinBom(update.content, update.bom), before, after: update.content, @@ -183,41 +206,63 @@ export const Plugin = { } 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.write({ - 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 } if (change.moveTarget) { - const result = yield* files.write({ - target: change.moveTarget, - content: change.content, + 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, }) - 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, - content: change.content, + 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 }, ) @@ -249,18 +294,52 @@ export const Plugin = { function patchFile(change: Prepared): typeof FileDiff.Info.Type { 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 }, + 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: target, - patch: formatPatch(diff), + 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") +} diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index abb8a5c9a2c5..7dd332e388fc 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -19,6 +19,25 @@ describe("Patch", () => { ]) }) + test("parses a file move", () => { + expect( + Patch.parse( + "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch", + ), + ).toEqual([ + { + type: "update", + path: "old.txt", + movePath: "new.txt", + chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined, endOfFile: undefined }], + }, + ]) + }) + + test("rejects invalid patch format", () => { + expect(() => Patch.parse("This is not a valid patch")).toThrow("Invalid patch format") + }) + test("strips a heredoc wrapper", () => { expect(Patch.parse("cat <<'EOF'\n*** Begin Patch\n*** Add File: add.txt\n+added\n*** End Patch\nEOF")).toEqual([ { type: "add", path: "add.txt", contents: "added" }, @@ -92,60 +111,6 @@ describe("Patch", () => { ).toBe("marker\nmiddle\nmarker changed\nend\n") }) - test("parses the EOF marker inside update chunks", () => { - expect( - Patch.parse("*** Begin Patch\n*** Update File: update.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"), - ).toEqual([ - { - type: "update", - path: "update.txt", - movePath: undefined, - chunks: [{ oldLines: ["last"], newLines: ["end"], changeContext: undefined, endOfFile: true }], - }, - ]) - }) - - 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: "" }, diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index b1f81bb9657c..a2fa962eecda 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -1,10 +1,9 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" +import { Effect, Exit, 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" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" @@ -23,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), - deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], + deps: [ToolRegistry.toolsNode, LocationMutation.node, FSUtil.node, Location.node, PermissionV2.node], }) const sessionID = SessionV2.ID.make("ses_patch_tool_test") @@ -32,9 +31,6 @@ let denyAction: string | undefined let failRemoveTarget: string | undefined let readsBeforeEditApproval = 0 let editApproved = false -let blockRemoveTarget: string | undefined -let removeStarted: Deferred.Deferred | undefined -let releaseRemove: Deferred.Deferred | undefined let afterEditApproval = (): Effect.Effect => Effect.void const permission = Layer.succeed( @@ -72,9 +68,6 @@ const reset = () => { failRemoveTarget = undefined readsBeforeEditApproval = 0 editApproved = false - blockRemoveTarget = undefined - removeStarted = undefined - releaseRemove = undefined afterEditApproval = () => Effect.void } @@ -90,11 +83,6 @@ const filesystem = Layer.effect( }).pipe(Effect.andThen(fs.readFile(target))), remove: (target, options) => { if (failRemoveTarget && path.basename(target) === failRemoveTarget) return Effect.die("forced remove failure") - if (blockRemoveTarget && path.basename(target) === blockRemoveTarget && removeStarted && releaseRemove) - return Deferred.succeed(removeStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseRemove)), - Effect.andThen(fs.remove(target, options)), - ) return fs.remove(target, options) }, }) @@ -115,7 +103,6 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, - FileMutation.node, patchToolNode, ]), [ @@ -143,6 +130,15 @@ const exists = (target: string) => ), ) const it = testEffect(Layer.empty) +const withTempTool = (body: (directory: string, registry: ToolRegistry.Interface) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withTool(tmp.path, (registry) => body(tmp.path, registry)) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) describe("PatchTool", () => { it.live("registers and sequentially applies add, update, and delete hunks", () => @@ -169,6 +165,7 @@ describe("PatchTool", () => { type: "text", value: "Success. Updated the following files:\nA nested/new.txt\nM update.txt\nD remove.txt", }) + if (process.platform === "win32") expect(settled.result.value).not.toContain("\\") expect(settled.output?.structured).toMatchObject({ applied: [ { type: "add", resource: "nested/new.txt" }, @@ -194,15 +191,25 @@ describe("PatchTool", () => { file: "remove.txt", status: "deleted", additions: 0, - deletions: 1, + deletions: 2, patch: expect.stringContaining("-remove"), }, ], }) expect(assertions).toMatchObject([ - { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, + { + sessionID, + action: "edit", + resources: ["nested/new.txt", "update.txt", "remove.txt"], + save: ["*"], + metadata: { + filepath: "nested/new.txt, update.txt, remove.txt", + diff: expect.stringContaining("Index:"), + files: expect.any(Array), + }, + }, ]) - expect(readsBeforeEditApproval).toBe(0) + expect(readsBeforeEditApproval).toBe(2) expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "nested/new.txt"), "utf8"))).toBe( "created\n", ) @@ -254,37 +261,6 @@ describe("PatchTool", () => { ), ) - it.live("treats a move to the same canonical path as an update", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const target = path.join(tmp.path, "same.txt") - return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( - Effect.andThen( - withTool(tmp.path, (registry) => - Effect.gen(function* () { - expect( - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: same.txt\n*** Move to: ./same.txt\n@@\n-before\n+after\n*** End Patch", - ), - ), - ).toEqual({ - type: "text", - value: "Success. Updated the following files:\nM same.txt", - }) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") - }), - ), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) - it.live("moves a file over an existing destination", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), @@ -320,167 +296,352 @@ describe("PatchTool", () => { ), ) - 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"), - }) - }), + it.live("moves a symlink without deleting its target", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + if (process.platform === "win32") return + const target = path.join(directory, "target.txt") + const source = path.join(directory, "link.txt") + const moved = path.join(directory, "moved.txt") + yield* Effect.promise(() => fs.writeFile(target, "before\n")) + yield* Effect.promise(() => fs.symlink(target, source)) + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: link.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", + ), ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + expect(yield* exists(source)).toBe(false) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n") + expect(yield* Effect.promise(() => fs.readFile(moved, "utf8"))).toBe("after\n") + }), ), ) - 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)) + it.live("includes move file info in structured output", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const source = path.join(directory, "old", "name.txt") + yield* Effect.promise(() => fs.mkdir(path.dirname(source), { recursive: true })) + yield* Effect.promise(() => fs.writeFile(source, "old content\n")) + const settled = yield* settleTool( + registry, + call( + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch", + ), + ) + expect(settled.output?.structured).toMatchObject({ + applied: [{ type: "update", resource: "renamed/dir/name.txt" }], + files: [ + { + file: "renamed/dir/name.txt", + status: "modified", + patch: expect.stringContaining("-old content\n+new content"), + }, + ], + }) + }), + ), + ) - 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( - "< 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" }) - }), + it.live("inserts lines with an insert-only hunk", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "insert-only.txt") + yield* Effect.promise(() => fs.writeFile(target, "alpha\nomega\n")) + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: insert-only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"), ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nbeta\nomega\n") + }), + ), + ) + + it.live("updates an empty file", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "empty.txt") + yield* Effect.promise(() => fs.writeFile(target, "")) + yield* executeTool(registry, call("*** Begin Patch\n*** Update File: empty.txt\n@@\n+First line\n*** End Patch")) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("First line\n") + }), + ), + ) + + it.live("rejects deleting a directory", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(directory, "dir"))) + expect( + yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: dir\n*** End Patch")), + ).toMatchObject({ type: "error" }) + expect(yield* exists(path.join(directory, "dir"))).toBe(true) + }), + ), + ) + + it.live("supports an end-of-file anchor", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "tail.txt") + yield* Effect.promise(() => fs.writeFile(target, "alpha\nlast\n")) + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch", + ), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("alpha\nend\n") + }), + ), + ) + + it.live("rejects a missing second chunk context", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "two-chunks.txt") + yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n")) + expect( + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: two-chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch", + ), + ), + ).toMatchObject({ type: "error" }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nb\nc\nd\n") + }), + ), + ) + + it.live("requires patchText", () => + withTempTool((_directory, registry) => + Effect.gen(function* () { + expect(yield* executeTool(registry, call(""))).toEqual({ type: "error", value: "patchText is required" }) + }), + ), + ) + + it.live("rejects invalid patch format", () => + withTempTool((_directory, registry) => + Effect.gen(function* () { + expect(yield* executeTool(registry, call("invalid patch"))).toMatchObject({ + type: "error", + value: expect.stringContaining("patch verification failed"), + }) + }), ), ) - it.live("approves an external directory and the batch before reading external update content", () => + it.live("rejects an empty patch", () => + withTempTool((_directory, registry) => + Effect.gen(function* () { + expect(yield* executeTool(registry, call("*** Begin Patch\n*** End Patch"))).toEqual({ + type: "error", + value: "patch rejected: empty patch", + }) + }), + ), + ) + + it.live("rejects an invalid hunk header", () => + withTempTool((_directory, registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"), + ), + ).toEqual({ type: "error", value: "patch verification failed: no hunks found" }) + }), + ), + ) + + it.live("applies multiple hunks to one file", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "multi.txt") + yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n")) + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch"), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nB\nc\nD\n") + }), + ), + ) + + it.live("does not invent a first-line diff for BOM files", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const bom = "\uFEFF" + const target = path.join(directory, "example.cs") + yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`)) + const settled = yield* settleTool( + registry, + call( + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch", + ), + ) + const output = Schema.decodeUnknownSync(PatchTool.Output)(settled.output?.structured) + expect(output.files[0]?.patch).not.toContain(bom) + expect(output.files[0]?.patch).not.toContain("-using System;") + expect(output.files[0]?.patch).not.toContain("+using System;") + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( + `${bom}using System;\n\nclass Test {}\nclass Next {}\n`, + ) + }), + ), + ) + + it.live("appends a trailing newline on update", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "no-newline.txt") + yield* Effect.promise(() => fs.writeFile(target, "no newline at end")) + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch", + ), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first line\nsecond line\n") + }), + ), + ) + + it.live("disambiguates change context with an @@ header", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "context.txt") + yield* Effect.promise(() => fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")) + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( + "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n", + ) + }), + ), + ) + + it.live("parses a heredoc-wrapped patch", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + yield* executeTool( + registry, + call("cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF"), + ) + expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe( + "with cat\n", + ) + }), + ), + ) + + it.live("parses a heredoc-wrapped patch without cat", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + yield* executeTool( + registry, + call("< fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe( + "without cat\n", + ) + }), + ), + ) + + it.live("matches with trailing whitespace differences", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "trailing.txt") + yield* Effect.promise(() => fs.writeFile(target, "line1 \nline2\nline3 \n")) + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: trailing.txt\n@@\n-line2\n+changed\n*** End Patch"), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1 \nchanged\nline3 \n") + }), + ), + ) + + it.live("matches with leading whitespace differences", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "leading.txt") + yield* Effect.promise(() => fs.writeFile(target, " line1\nline2\n line3\n")) + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: leading.txt\n@@\n-line2\n+changed\n*** End Patch"), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(" line1\nchanged\n line3\n") + }), + ), + ) + + it.live("matches with Unicode punctuation differences", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "unicode.txt") + yield* Effect.promise(() => fs.writeFile(target, "He said “hello”\nsome—dash\nend\n")) + yield* executeTool( + registry, + call( + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch', + ), + ) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe('He said "hi"\nsome—dash\nend\n') + }), + ), + ) + + it.live("rejects an update with missing context", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + const target = path.join(directory, "unchanged.txt") + yield* Effect.promise(() => fs.writeFile(target, "line1\nline2\n")) + expect( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: unchanged.txt\n@@\n-missing\n+changed\n*** End Patch"), + ), + ).toMatchObject({ type: "error", value: expect.stringContaining("Failed to find expected lines") }) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1\nline2\n") + }), + ), + ) + + it.live("rejects an update when the target file is missing", () => + withTempTool((_directory, registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"), + ), + ).toMatchObject({ + type: "error", + value: expect.stringContaining("patch verification failed: Failed to read file to update"), + }) + }), + ), + ) + + it.live("rejects a delete when the target file is missing", () => + withTempTool((_directory, registry) => + Effect.gen(function* () { + expect( + yield* executeTool(registry, call("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch")), + ).toMatchObject({ type: "error", value: expect.stringContaining("patch verification failed") }) + }), + ), + ) + + it.live("approves an external directory before reading and requests edit permission afterward", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { @@ -497,7 +658,7 @@ describe("PatchTool", () => { ), ).toMatchObject({ type: "text" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(readsBeforeEditApproval).toBe(0) + expect(readsBeforeEditApproval).toBe(1) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), ), @@ -511,7 +672,7 @@ describe("PatchTool", () => { ), ) - it.live("approves a relative external target before reading update content", () => + it.live("approves a relative external target before reading and requests edit permission afterward", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { @@ -529,7 +690,7 @@ describe("PatchTool", () => { ), ).toMatchObject({ type: "text" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(readsBeforeEditApproval).toBe(0) + expect(readsBeforeEditApproval).toBe(1) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") }), ), @@ -543,7 +704,7 @@ describe("PatchTool", () => { ), ) - it.live("approves one external directory scope for multiple files under the same parent", () => + it.live("approves each external file under the same parent", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), ([active, outside]) => { @@ -564,10 +725,15 @@ describe("PatchTool", () => { ), ), ).toMatchObject({ type: "text" }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) + expect(assertions.map((input) => input.action)).toEqual([ + "external_directory", + "external_directory", + "edit", + ]) expect(assertions[0]?.resources).toEqual([ path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"), ]) + expect(assertions[1]?.resources).toEqual(assertions[0]?.resources) }), ), ), @@ -594,7 +760,10 @@ describe("PatchTool", () => { "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch", ), ), - ).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" }) + ).toMatchObject({ + type: "error", + value: expect.stringContaining("patch verification failed: Failed to read file to update"), + }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) }), ) @@ -683,35 +852,4 @@ describe("PatchTool", () => { ), ) - it.live("finishes the sequential commit phase when interrupted after the first mutation", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const first = path.join(tmp.path, "first.txt") - const second = path.join(tmp.path, "second.txt") - blockRemoveTarget = path.basename(second) - return Effect.gen(function* () { - removeStarted = yield* Deferred.make() - releaseRemove = yield* Deferred.make() - yield* Effect.promise(() => Promise.all([fs.writeFile(first, "first"), fs.writeFile(second, "second")])) - yield* withTool(tmp.path, (registry) => - Effect.gen(function* () { - const run = yield* executeTool( - registry, - call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), - ).pipe(Effect.forkChild) - yield* Deferred.await(removeStarted!) - const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild) - yield* Deferred.succeed(releaseRemove!, undefined) - yield* Fiber.join(interrupt) - expect(yield* exists(first)).toBe(false) - expect(yield* exists(second)).toBe(false) - }), - ) - }) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), - ) }) From 94ecee3ed670893e30cb559ec2b2e5cfc6f38eed Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 15:27:57 -0500 Subject: [PATCH 6/8] fix(core): match dev path permissions --- packages/core/src/tool/patch.ts | 97 +++++++++++---------- packages/core/test/tool-patch.test.ts | 116 ++++++++++++++++++++++++-- 2 files changed, 165 insertions(+), 48 deletions(-) diff --git a/packages/core/src/tool/patch.ts b/packages/core/src/tool/patch.ts index 0bcfbdbfbba9..990abcab0e08 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -8,7 +8,6 @@ import { Effect, Schema } from "effect" import path from "path" import { FSUtil } from "../fs-util" import { Location } from "../location" -import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -44,22 +43,30 @@ export const toModelOutput = (output: Output) => type Prepared = | (Extract & { - readonly target: LocationMutation.Target + readonly target: Target readonly before: string readonly after: string }) | (Extract & { - readonly target: LocationMutation.Target + readonly target: Target readonly content: string readonly before: string readonly after: string - readonly moveTarget?: LocationMutation.Target + 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 fs = yield* FSUtil.Service const location = yield* Location.Service const permission = yield* PermissionV2.Service @@ -101,45 +108,21 @@ export const Plugin = { } 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) { - const resolvedTarget = yield* mutation.resolve({ path: hunk.path, kind: "file" }) - const targetPath = path.resolve(location.directory, hunk.path) - const target = { - ...resolvedTarget, - canonical: targetPath, - resource: path.relative(location.project.directory, targetPath).replaceAll("\\", "/") || ".", - } - const movePath = hunk.type === "update" ? hunk.movePath : undefined - const moveTarget = - movePath - ? yield* Effect.gen(function* () { - const resolved = yield* mutation.resolve({ path: movePath, kind: "file" }) - const target = path.resolve(location.directory, movePath) - return { - ...resolved, - canonical: target, - resource: - path.relative(location.project.directory, target).replaceAll("\\", "/") || ".", - } - }) - : undefined - targets.push({ - hunk, - target, - moveTarget, - }) - } const prepared: Prepared[] = [] - for (const { hunk, target, moveTarget } 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({ - ...LocationMutation.externalDirectoryPermission(target.externalDirectory), + 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, @@ -186,9 +169,16 @@ export const Plugin = { 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({ - ...LocationMutation.externalDirectoryPermission(moveTarget.externalDirectory), + 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, @@ -208,10 +198,10 @@ export const Plugin = { const patchFiles = prepared.map(patchFile) yield* permission.assert({ action: "edit", - resources: [...new Set(targets.map(({ target }) => target.resource))], + resources: [...new Set(targets.map((target) => target.resource))], save: ["*"], metadata: { - filepath: targets.map(({ target }) => target.resource).join(", "), + filepath: targets.map((target) => target.resource).join(", "), diff: patchFiles.map((file) => `${file.patch}\n`).join(""), files: patchFiles, }, @@ -343,3 +333,24 @@ function trimDiff(diff: string) { }) .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, + } +} diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index a2fa962eecda..93103eb48197 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -6,7 +6,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -22,7 +21,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti const patchToolNode = makeLocationNode({ name: "test/patch-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), - deps: [ToolRegistry.toolsNode, LocationMutation.node, FSUtil.node, Location.node, PermissionV2.node], + deps: [ToolRegistry.toolsNode, FSUtil.node, Location.node, PermissionV2.node], }) const sessionID = SessionV2.ID.make("ses_patch_tool_test") @@ -89,10 +88,19 @@ const filesystem = Layer.effect( }), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) -const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { +const withTool = ( + directory: string, + body: (registry: ToolRegistry.Interface) => Effect.Effect, + projectDirectory = directory, +) => { const activeLocation = Layer.succeed( Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory) })), + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(projectDirectory) }, + ), + ), ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) @@ -102,7 +110,6 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte LayerNode.group([ ToolRegistry.node, ToolRegistry.toolsNode, - LocationMutation.node, patchToolNode, ]), [ @@ -672,6 +679,105 @@ describe("PatchTool", () => { ), ) + it.live("does not inspect an external file when external permission is denied", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + denyAction = "external_directory" + const target = path.join(outside.path, "external.txt") + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen( + withTool( + active.path, + (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), + ), + ).toMatchObject({ type: "error" }) + expect(assertions.map((input) => input.action)).toEqual(["external_directory"]) + expect(readsBeforeEditApproval).toBe(0) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("before\n") + }), + path.parse(active.path).root, + ), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + + it.live("treats a sibling path inside the project worktree as internal", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const active = path.join(tmp.path, "active") + const target = path.join(tmp.path, "sibling.txt") + return Effect.promise(() => Promise.all([fs.mkdir(active), fs.writeFile(target, "before\n")])).pipe( + Effect.andThen( + withTool( + active, + (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), + ), + ).toMatchObject({ type: "text" }) + expect(assertions.map((input) => input.action)).toEqual(["edit"]) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") + }), + tmp.path, + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + it.live("follows an internal symlink to an external file without external permission", () => + Effect.acquireUseRelease( + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { + reset() + if (process.platform === "win32") return Effect.void + const target = path.join(outside.path, "external.txt") + const link = path.join(active.path, "link.txt") + return Effect.promise(() => fs.writeFile(target, "before\n")).pipe( + Effect.andThen(Effect.promise(() => fs.symlink(target, link))), + Effect.andThen( + withTool(active.path, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call("*** Begin Patch\n*** Update File: link.txt\n@@\n-before\n+after\n*** End Patch"), + ), + ).toMatchObject({ type: "text" }) + expect(assertions.map((input) => input.action)).toEqual(["edit"]) + expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") + }), + ), + ), + ) + }, + ([active, outside]) => + Effect.promise(() => + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), + ), + ), + ) + it.live("approves a relative external target before reading and requests edit permission afterward", () => Effect.acquireUseRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), From f4ada41c11b9ae2bad16f21644d62d3c94c27c4b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 15:55:46 -0500 Subject: [PATCH 7/8] test(core): match Windows patch permission glob --- packages/core/test/tool-patch.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 93103eb48197..829b9f68401f 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -837,7 +837,9 @@ describe("PatchTool", () => { "edit", ]) expect(assertions[0]?.resources).toEqual([ - path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"), + process.platform === "win32" + ? FSUtil.normalizePathPattern(path.join(outside.path, "*")) + : path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"), ]) expect(assertions[1]?.resources).toEqual(assertions[0]?.resources) }), From c2c22331dc1395eae7ceb6a6e4e1fd95bbb5ed79 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 16:30:07 -0500 Subject: [PATCH 8/8] test(core): update patch prompt recording --- .../recordings/session-runner/openai-chat-streams-text.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index ddafa3a3e78b..06e048fb1406 100644 --- a/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json +++ b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json @@ -13,7 +13,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.\\n\\nYou are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.\\n\\n- When searching for text or files, prefer using glob and grep tools (they are powered by `rg`)\\n- Parallelize tool calls whenever possible - especially file reads. When independent tool calls have no dependencies, issue them together in the same assistant message. Never chain together shell commands with separators like `echo \\\"====\\\";` as this renders to the user poorly.\\n\\n## Editing Approach\\n\\n- The best changes are often the smallest correct changes.\\n- When you are weighing two correct approaches, prefer the more minimal one (less new names, helpers, tests, etc).\\n- Keep things in one function unless composable or reusable\\n- Do not add backward-compatibility code unless there is a concrete need, such as persisted data, shipped behavior, external consumers, or an explicit user requirement; if unclear, ask one short question instead of guessing.\\n\\n## Autonomy and persistence\\n\\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\\n\\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\\n\\nIf you notice unexpected changes in the worktree or staging area that you did not make, continue with your task. NEVER revert, undo, or modify changes you did not make unless the user explicitly asks you to. There can be multiple agents or the user working in the same codebase concurrently.\\n\\n## Editing constraints\\n\\n- 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.\\n- 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.\\n- 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.\\n- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.\\n- You may be in a dirty git worktree.\\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\\n * 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.\\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\\n * If the changes are in unrelated files, just ignore them and don't revert them.\\n- Do not amend a commit unless explicitly requested to do so.\\n- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them, or were autogenerated. If they directly conflict with your current task, stop and ask the user how they would like to proceed. Otherwise, focus on the task at hand.\\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\\n- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.\\n\\n## Special user requests\\n\\nIf the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\\n\\nIf the user pastes an error description or a bug report, help them diagnose the root cause. You can try to reproduce it if it seems feasible with the available tools and skills.\\n\\nIf the user asks for a \\\"review\\\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\\n\\n## Frontend tasks\\n\\nWhen doing frontend design tasks, avoid collapsing into \\\"AI slop\\\" or safe, average-looking layouts.\\n- Ensure the page loads properly on both desktop and mobile\\n- For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.\\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\\n\\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\\n\\n# Working with the user\\n\\n## General\\n\\nDo not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (\\\"Done —\\\", \\\"Got it\\\", \\\"Great question, \\\") or framing phrases.\\n\\nBalance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.\\n\\nNever tell the user to \\\"save/copy this file\\\", the user is on the same machine and has access to the same files as you have.\\n\\n\\n## Formatting rules\\n\\nYour responses are rendered as GitHub-flavored Markdown.\\n\\nNever use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.\\n\\nHeaders are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.\\n\\nUse inline code blocks for commands, paths, environment variables, function names, inline examples, keywords.\\n\\nCode samples or multi-line snippets should be wrapped in fenced code blocks. Include a language tag when possible.\\n\\nDon’t use emojis or em dashes unless explicitly instructed.\\n\\n## Response channels\\n\\nUse commentary for short progress updates while working and final for the completed response.\\n\\n### `commentary` channel\\n\\nOnly use `commentary` for intermediary updates. These are short updates while you are working, they are NOT final answers. Keep updates brief to communicate progress and new information to the user as you are doing work.\\n\\nSend updates when they add meaningful new information: a discovery, a tradeoff, a blocker, a substantial plan, or the start of a non-trivial edit or verification step.\\n\\nDo not narrate routine reads, searches, obvious next steps, or minor confirmations. Combine related progress into a single update.\\n\\nDo not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (\\\"Done —\\\", \\\"Got it\\\", \\\"Great question\\\") or framing phrases.\\n\\nBefore substantial work, send a short update describing your first step. Before editing files, send an update describing the edit.\\n\\nAfter you have sufficient context, and the work is substantial you can provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).\\n\\n### `final` channel\\n\\nUse final for the completed response.\\n\\nStructure your final response if necessary. The complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.\\n\\nIf the user asks for a code explanation, include code references. For simple tasks, just state the outcome without heavy formatting.\\n\\nFor large or complex changes, lead with the solution, then explain what you did and why. For casual chat, just chat. If something couldn’t be done (tests, builds, etc.), say so. Suggest next steps only when they are natural and useful; if you list options, use numbered items.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.\\n\\nYou are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.\\n\\n- When searching for text or files, prefer using glob and grep tools (they are powered by `rg`)\\n- Parallelize tool calls whenever possible - especially file reads. When independent tool calls have no dependencies, issue them together in the same assistant message. Never chain together shell commands with separators like `echo \\\"====\\\";` as this renders to the user poorly.\\n\\n## Editing Approach\\n\\n- The best changes are often the smallest correct changes.\\n- When you are weighing two correct approaches, prefer the more minimal one (less new names, helpers, tests, etc).\\n- Keep things in one function unless composable or reusable\\n- Do not add backward-compatibility code unless there is a concrete need, such as persisted data, shipped behavior, external consumers, or an explicit user requirement; if unclear, ask one short question instead of guessing.\\n\\n## Autonomy and persistence\\n\\nUnless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.\\n\\nPersist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.\\n\\nIf you notice unexpected changes in the worktree or staging area that you did not make, continue with your task. NEVER revert, undo, or modify changes you did not make unless the user explicitly asks you to. There can be multiple agents or the user working in the same codebase concurrently.\\n\\n## Editing constraints\\n\\n- 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.\\n- 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.\\n- 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.\\n- Do not use Python to read/write files when a simple shell command or patch would suffice.\\n- You may be in a dirty git worktree.\\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\\n * 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.\\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\\n * If the changes are in unrelated files, just ignore them and don't revert them.\\n- Do not amend a commit unless explicitly requested to do so.\\n- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them, or were autogenerated. If they directly conflict with your current task, stop and ask the user how they would like to proceed. Otherwise, focus on the task at hand.\\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\\n- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.\\n\\n## Special user requests\\n\\nIf the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\\n\\nIf the user pastes an error description or a bug report, help them diagnose the root cause. You can try to reproduce it if it seems feasible with the available tools and skills.\\n\\nIf the user asks for a \\\"review\\\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\\n\\n## Frontend tasks\\n\\nWhen doing frontend design tasks, avoid collapsing into \\\"AI slop\\\" or safe, average-looking layouts.\\n- Ensure the page loads properly on both desktop and mobile\\n- For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.\\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\\n\\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\\n\\n# Working with the user\\n\\n## General\\n\\nDo not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (\\\"Done —\\\", \\\"Got it\\\", \\\"Great question, \\\") or framing phrases.\\n\\nBalance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.\\n\\nNever tell the user to \\\"save/copy this file\\\", the user is on the same machine and has access to the same files as you have.\\n\\n\\n## Formatting rules\\n\\nYour responses are rendered as GitHub-flavored Markdown.\\n\\nNever use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.\\n\\nHeaders are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.\\n\\nUse inline code blocks for commands, paths, environment variables, function names, inline examples, keywords.\\n\\nCode samples or multi-line snippets should be wrapped in fenced code blocks. Include a language tag when possible.\\n\\nDon’t use emojis or em dashes unless explicitly instructed.\\n\\n## Response channels\\n\\nUse commentary for short progress updates while working and final for the completed response.\\n\\n### `commentary` channel\\n\\nOnly use `commentary` for intermediary updates. These are short updates while you are working, they are NOT final answers. Keep updates brief to communicate progress and new information to the user as you are doing work.\\n\\nSend updates when they add meaningful new information: a discovery, a tradeoff, a blocker, a substantial plan, or the start of a non-trivial edit or verification step.\\n\\nDo not narrate routine reads, searches, obvious next steps, or minor confirmations. Combine related progress into a single update.\\n\\nDo not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (\\\"Done —\\\", \\\"Got it\\\", \\\"Great question\\\") or framing phrases.\\n\\nBefore substantial work, send a short update describing your first step. Before editing files, send an update describing the edit.\\n\\nAfter you have sufficient context, and the work is substantial you can provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).\\n\\n### `final` channel\\n\\nUse final for the completed response.\\n\\nStructure your final response if necessary. The complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.\\n\\nIf the user asks for a code explanation, include code references. For simple tasks, just state the outcome without heavy formatting.\\n\\nFor large or complex changes, lead with the solution, then explain what you did and why. For casual chat, just chat. If something couldn’t be done (tests, builds, etc.), say so. Suggest next steps only when they are natural and useful; if you list options, use numbered items.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" }, "response": { "status": 200,