From 455b5d3165aae1035e39cb3a609442aa42e0cee9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:46:11 -0500 Subject: [PATCH] fix(core): match dev patch behavior (#37709) --- packages/core/src/patch.ts | 34 +- .../core/src/plugin/system-prompt/codex.txt | 2 +- .../core/src/plugin/system-prompt/gpt.txt | 4 +- packages/core/src/tool/patch.ts | 274 ++++++-- packages/core/src/tool/patch.txt | 33 + .../openai-chat-streams-text.json | 2 +- packages/core/test/patch.test.ts | 101 ++- packages/core/test/tool-patch.test.ts | 626 ++++++++++++++++-- 8 files changed, 887 insertions(+), 189 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 a4370d44aa..404986cbc8 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") 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,27 +107,21 @@ 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[] = [] const newLines: string[] = [] let endOfFile = false index++ - while (index < lines.length && !lines[index]!.startsWith("@@")) { + 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)) } 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 949390068d..be926be5f8 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 8870e5421e..9839d5a6c6 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 6925b87793..990abcab0e 100644 --- a/packages/core/src/tool/patch.ts +++ b/packages/core/src/tool/patch.ts @@ -5,12 +5,13 @@ import { ToolFailure } from "@opencode-ai/ai" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" -import { FileMutation } from "../file-mutation" +import path from "path" import { FSUtil } from "../fs-util" -import { LocationMutation } from "../location-mutation" +import { Location } from "../location" import { Patch } from "../patch" import { PermissionV2 } from "../permission" import { Tool } from "./tool" +import DESCRIPTION from "./patch.txt" export const name = "patch" @@ -34,7 +35,7 @@ export type Output = typeof Output.Type export const toModelOutput = (output: Output) => [ - "Applied patch sequentially:", + "Success. Updated the following files:", ...output.applied.map( (item) => `${item.type === "add" ? "A" : item.type === "delete" ? "D" : "M"} ${item.resource}`, ), @@ -42,24 +43,32 @@ export const toModelOutput = (output: Output) => type Prepared = | (Extract & { - readonly target: LocationMutation.Target + readonly target: Target readonly before: string readonly after: string }) | (Extract & { - readonly target: LocationMutation.Target - readonly source: Uint8Array + readonly target: Target readonly content: string readonly before: string readonly after: string + readonly moveTarget?: Target }) +interface Target { + readonly canonical: string + readonly resource: string + readonly externalDirectory?: { + readonly directory: string + readonly resource: string + } +} + export const Plugin = { id: "opencode.tool.patch", effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { - const mutation = yield* LocationMutation.Service - const files = yield* FileMutation.Service const fs = yield* FSUtil.Service + const location = yield* Location.Service const permission = yield* PermissionV2.Service yield* ctx.tool @@ -68,8 +77,7 @@ export const Plugin = { name, Tool.withPermission( Tool.make({ - description: - "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", + description: DESCRIPTION, input: Input, output: Output, toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], @@ -88,100 +96,163 @@ export const Plugin = { messageID: context.messageID, callID: context.callID, } - if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) + if (!input.patchText) return yield* new ToolFailure({ message: "patchText is required" }) const hunks = yield* Effect.try({ try: () => Patch.parse(input.patchText), catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }), }) - if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) - const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) - if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" }) - - const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] - for (const hunk of hunks) - targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) }) - const externalDirectories = new Map() - for (const { target } of targets) { - const external = target.externalDirectory - if (external) externalDirectories.set(external.resource, external) + if (hunks.length === 0) { + const normalized = input.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() + if (normalized === "*** Begin Patch\n*** End Patch") { + return yield* new ToolFailure({ message: "patch rejected: empty patch" }) + } + return yield* new ToolFailure({ message: "patch verification failed: no hunks found" }) } - for (const external of externalDirectories.values()) { - yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), - sessionID: context.sessionID, - agent: context.agent, - source, - }) - } - yield* permission.assert({ - action: "edit", - resources: [...new Set(targets.map(({ target }) => target.resource))], - save: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - const prepared: Prepared[] = [] - for (const { hunk, target } of targets) { + const targets: Target[] = [] + for (const hunk of hunks) { yield* Effect.gen(function* () { + const target = resolveTarget(location, hunk.path) + targets.push(target) + if (target.externalDirectory) { + yield* permission.assert({ + action: "external_directory", + resources: [target.externalDirectory.resource], + save: [target.externalDirectory.resource], + metadata: { + filepath: target.canonical, + parentDir: target.externalDirectory.directory, + }, + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } if (hunk.type === "add") { prepared.push({ ...hunk, target, before: "", - after: - hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + after: (hunk.contents.endsWith("\n") || hunk.contents === "" + ? hunk.contents + : `${hunk.contents}\n` + ).replace(/^\uFEFF/, ""), }) return } - if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) - const source = yield* fs.readFile(target.canonical) - const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source) - const before = original.replace(/^\uFEFF/, "") if (hunk.type === "delete") { - prepared.push({ ...hunk, target, before, after: "" }) + const content = yield* fs.readFile(target.canonical).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `patch verification failed: ${error instanceof Error ? error.message : String(error)}`, + }), + ), + ) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content) + prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" }) return } - const update = Patch.derive(hunk.path, hunk.chunks, original) + const stats = yield* fs + .stat(target.canonical) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stats || stats.type === "Directory") { + return yield* new ToolFailure({ + message: `patch verification failed: Failed to read file to update: ${target.canonical}`, + }) + } + const content = yield* fs.readFile(target.canonical) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content) + const before = original.replace(/^\uFEFF/, "") + const update = yield* Effect.try({ + try: () => Patch.derive(hunk.path, hunk.chunks, original), + catch: (error) => + new ToolFailure({ message: `patch verification failed: ${String(error)}` }), + }) + const moveTarget = hunk.movePath ? resolveTarget(location, hunk.movePath) : undefined + if (moveTarget?.externalDirectory) { + yield* permission.assert({ + action: "external_directory", + resources: [moveTarget.externalDirectory.resource], + save: [moveTarget.externalDirectory.resource], + metadata: { + filepath: moveTarget.canonical, + parentDir: moveTarget.externalDirectory.directory, + }, + sessionID: context.sessionID, + agent: context.agent, + source, + }) + } prepared.push({ ...hunk, target, - source, content: Patch.joinBom(update.content, update.bom), before, after: update.content, + moveTarget, }) - }).pipe(Effect.mapError((error) => fail(hunk.path, error))) + }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail(hunk.path, error)))) } const patchFiles = prepared.map(patchFile) + yield* permission.assert({ + action: "edit", + resources: [...new Set(targets.map((target) => target.resource))], + save: ["*"], + metadata: { + filepath: targets.map((target) => target.resource).join(", "), + diff: patchFiles.map((file) => `${file.patch}\n`).join(""), + files: patchFiles, + }, + sessionID: context.sessionID, + agent: context.agent, + source, + }) + yield* Effect.forEach( prepared, (change) => Effect.gen(function* () { if (change.type === "add") { - const result = yield* files.create({ - target: change.target, - content: - change.contents.endsWith("\n") || change.contents === "" - ? change.contents - : `${change.contents}\n`, + yield* fs.writeWithDirs( + change.target.canonical, + change.contents.endsWith("\n") || change.contents === "" + ? change.contents + : `${change.contents}\n`, + ) + applied.push({ + type: change.type, + resource: change.target.resource, + target: change.target.canonical, }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) return } if (change.type === "delete") { - const result = yield* files.remove({ target: change.target }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) + yield* fs.remove(change.target.canonical) + applied.push({ + type: change.type, + resource: change.target.resource, + target: change.target.canonical, + }) return } - const result = yield* files.writeIfUnchanged({ - target: change.target, - expected: change.source, - content: change.content, + if (change.moveTarget) { + yield* fs.writeWithDirs(change.moveTarget.canonical, change.content) + yield* fs.remove(change.target.canonical) + applied.push({ + type: change.type, + resource: change.moveTarget.resource, + target: change.moveTarget.canonical, + }) + return + } + yield* fs.writeWithDirs(change.target.canonical, change.content) + applied.push({ + type: change.type, + resource: change.target.resource, + target: change.target.canonical, }) - applied.push({ type: change.type, resource: result.resource, target: result.target }) }).pipe(Effect.mapError((error) => fail(change.path, error))), { discard: true }, ) @@ -199,7 +270,7 @@ export const Plugin = { yield* ctx.session.hook("context", (event) => Effect.sync(() => { const usePatch = - event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") + event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4") if (usePatch) { delete event.tools.edit delete event.tools.write @@ -212,17 +283,74 @@ export const Plugin = { } function patchFile(change: Prepared): typeof FileDiff.Info.Type { - const counts = diffLines(change.before, change.after).reduce( - (result, item) => ({ - additions: result.additions + (item.added ? (item.count ?? 0) : 0), - deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), - }), - { additions: 0, deletions: 0 }, + const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource + const patch = trimDiff( + createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after), ) + const counts = + change.type === "delete" + ? { additions: 0, deletions: change.before.split("\n").length } + : diffLines(change.before, change.after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) return { - file: change.target.resource, - patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + file: target, + patch, status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", ...counts, } } + +function trimDiff(diff: string) { + const lines = diff.split("\n") + const content = lines.filter( + (line) => + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++"), + ) + if (content.length === 0) return diff + const indent = content.reduce((result, line) => { + const value = line.slice(1) + if (value.trim().length === 0) return result + return Math.min(result, value.match(/^(\s*)/)?.[1].length ?? result) + }, Infinity) + if (indent === Infinity || indent === 0) return diff + return lines + .map((line) => { + if ( + (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) && + !line.startsWith("---") && + !line.startsWith("+++") + ) { + return line[0] + line.slice(1 + indent) + } + return line + }) + .join("\n") +} + +function resolveTarget(location: Location.Interface, value: string): Target { + const canonical = + process.platform === "win32" + ? FSUtil.normalizePath(path.resolve(location.directory, value)) + : path.resolve(location.directory, value) + const projectRoot = path.parse(location.project.directory).root + const external = + !FSUtil.contains(location.directory, canonical) && + (location.project.directory === projectRoot || !FSUtil.contains(location.project.directory, canonical)) + const directory = path.dirname(canonical) + const resource = + process.platform === "win32" + ? FSUtil.normalizePathPattern(path.join(directory, "*")) + : path.join(directory, "*").replaceAll("\\", "/") + return { + canonical, + resource: path.relative(location.project.directory, canonical).replaceAll("\\", "/") || ".", + externalDirectory: external ? { directory, resource } : undefined, + } +} diff --git a/packages/core/src/tool/patch.txt b/packages/core/src/tool/patch.txt new file mode 100644 index 0000000000..72c0489c0d --- /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/fixtures/recordings/session-runner/openai-chat-streams-text.json b/packages/core/test/fixtures/recordings/session-runner/openai-chat-streams-text.json index ddafa3a3e7..06e048fb14 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, diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index 10560bf928..7dd332e388 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -19,18 +19,88 @@ 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" }, ]) }) + 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( @@ -41,28 +111,15 @@ 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("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" }, ]) }) - - 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", - ) - }) }) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 458f8c3758..829b9f6840 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -1,13 +1,11 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Deferred, Effect, Exit, Fiber, Layer } 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" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -23,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, FileMutation.node, FSUtil.node, PermissionV2.node], + deps: [ToolRegistry.toolsNode, FSUtil.node, Location.node, PermissionV2.node], }) const sessionID = SessionV2.ID.make("ses_patch_tool_test") @@ -32,9 +30,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 +67,6 @@ const reset = () => { failRemoveTarget = undefined readsBeforeEditApproval = 0 editApproved = false - blockRemoveTarget = undefined - removeStarted = undefined - releaseRemove = undefined afterEditApproval = () => Effect.void } @@ -90,21 +82,25 @@ 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) }, }) }), ).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) @@ -114,8 +110,6 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte LayerNode.group([ ToolRegistry.node, ToolRegistry.toolsNode, - LocationMutation.node, - FileMutation.node, patchToolNode, ]), [ @@ -143,6 +137,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", () => @@ -167,8 +170,9 @@ 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", }) + if (process.platform === "win32") expect(settled.result.value).not.toContain("\\") expect(settled.output?.structured).toMatchObject({ applied: [ { type: "add", resource: "nested/new.txt" }, @@ -194,15 +198,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", ) @@ -217,7 +231,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 +248,17 @@ describe("PatchTool", () => { "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", ), ), - ).toEqual({ type: "error", value: "patch moves are not supported yet" }) - expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) - expect(assertions).toEqual([]) + ).toEqual({ + type: "text", + value: "Success. Updated the following files:\nA created.txt\nM moved.txt", + }) + expect(yield* exists(source)).toBe(false) + expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "moved.txt"), "utf8"))).toBe( + "after\n", + ) + expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "created.txt"), "utf8"))).toBe( + "created\n", + ) }), ), ), @@ -246,7 +268,387 @@ describe("PatchTool", () => { ), ) - it.live("approves an external directory and the batch before reading external update content", () => + it.live("moves a file over an existing destination", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const source = path.join(tmp.path, "old.txt") + const destination = path.join(tmp.path, "nested", "moved.txt") + return Effect.promise(() => + Promise.all([ + fs.writeFile(source, "before\n"), + fs.mkdir(path.dirname(destination), { recursive: true }).then(() => fs.writeFile(destination, "existing\n")), + ]), + ).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + Effect.gen(function* () { + expect( + yield* executeTool( + registry, + call( + "*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/moved.txt\n@@\n-before\n+after\n*** End Patch", + ), + ), + ).toMatchObject({ type: "text" }) + expect(yield* exists(source)).toBe(false) + expect(yield* Effect.promise(() => fs.readFile(destination, "utf8"))).toBe("after\n") + }), + ), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + + it.live("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", + ), + ) + 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("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"), + }, + ], + }) + }), + ), + ) + + 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"), + ) + 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("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]) => { @@ -263,7 +665,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") }), ), @@ -277,7 +679,106 @@ describe("PatchTool", () => { ), ) - it.live("approves a relative external target before reading update content", () => + 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()])), ([active, outside]) => { @@ -295,7 +796,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") }), ), @@ -309,7 +810,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]) => { @@ -330,10 +831,17 @@ describe("PatchTool", () => { ), ), ).toMatchObject({ type: "text" }) - expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) - expect(assertions[0]?.resources).toEqual([ - path.join(yield* Effect.promise(() => fs.realpath(outside.path)), "*").replaceAll("\\", "/"), + expect(assertions.map((input) => input.action)).toEqual([ + "external_directory", + "external_directory", + "edit", ]) + expect(assertions[0]?.resources).toEqual([ + 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) }), ), ), @@ -360,7 +868,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) }), ) @@ -369,7 +880,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 +895,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 +906,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 +920,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") }), ) }, @@ -449,35 +960,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]()), - ), - ) })