summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-01-17 22:35:09 -0800
committerGitHub <[email protected]>2026-01-18 00:35:09 -0600
commitb7ad6bd83922e2259a467fe59f27806af8060629 (patch)
tree0e1412ec1941f5a7c72731b53f4503b1619e811f /packages
parent10433cb45b6ed932368fb147032d671eaed0d273 (diff)
downloadopencode-b7ad6bd83922e2259a467fe59f27806af8060629.tar.gz
opencode-b7ad6bd83922e2259a467fe59f27806af8060629.zip
feat: apply_patch tool for openai models (#9127)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/debug/agent.ts4
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx80
-rw-r--r--packages/opencode/src/patch/index.ts81
-rw-r--r--packages/opencode/src/server/routes/experimental.ts4
-rw-r--r--packages/opencode/src/session/prompt.ts5
-rw-r--r--packages/opencode/src/session/prompt/codex.txt1
-rw-r--r--packages/opencode/src/tool/apply_patch.ts277
-rw-r--r--packages/opencode/src/tool/apply_patch.txt1
-rw-r--r--packages/opencode/src/tool/batch.ts2
-rw-r--r--packages/opencode/src/tool/patch.ts201
-rw-r--r--packages/opencode/src/tool/patch.txt1
-rw-r--r--packages/opencode/src/tool/registry.ts19
-rw-r--r--packages/opencode/test/tool/apply_patch.test.ts515
-rw-r--r--packages/opencode/test/tool/patch.test.ts261
-rw-r--r--packages/ui/src/components/message-part.css72
-rw-r--r--packages/ui/src/components/message-part.tsx94
16 files changed, 1122 insertions, 496 deletions
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
index ef6b0c4fc..d1236ff40 100644
--- a/packages/opencode/src/cli/cmd/debug/agent.ts
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -70,8 +70,8 @@ export const AgentCommand = cmd({
})
async function getAvailableTools(agent: Agent.Info) {
- const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
- return ToolRegistry.tools(providerID, agent)
+ const model = agent.model ?? (await Provider.defaultModel())
+ return ToolRegistry.tools(model, agent)
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 1842a955d..1294ab849 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
import type { ListTool } from "@/tool/ls"
import type { EditTool } from "@/tool/edit"
-import type { PatchTool } from "@/tool/patch"
+import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
@@ -1445,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
<Match when={props.part.tool === "task"}>
<Task {...toolprops} />
</Match>
- <Match when={props.part.tool === "patch"}>
- <Patch {...toolprops} />
+ <Match when={props.part.tool === "apply_patch"}>
+ <ApplyPatch {...toolprops} />
</Match>
<Match when={props.part.tool === "todowrite"}>
<TodoWrite {...toolprops} />
@@ -1895,20 +1895,74 @@ function Edit(props: ToolProps<typeof EditTool>) {
)
}
-function Patch(props: ToolProps<typeof PatchTool>) {
- const { theme } = useTheme()
+function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
+ const ctx = use()
+ const { theme, syntax } = useTheme()
+
+ const files = createMemo(() => props.metadata.files ?? [])
+
+ const view = createMemo(() => {
+ const diffStyle = ctx.sync.data.config.tui?.diff_style
+ if (diffStyle === "stacked") return "unified"
+ return ctx.width > 120 ? "split" : "unified"
+ })
+
+ function Diff(p: { diff: string; filePath: string }) {
+ return (
+ <box paddingLeft={1}>
+ <diff
+ diff={p.diff}
+ view={view()}
+ filetype={filetype(p.filePath)}
+ syntaxStyle={syntax()}
+ showLineNumbers={true}
+ width="100%"
+ wrapMode={ctx.diffWrapMode()}
+ fg={theme.text}
+ addedBg={theme.diffAddedBg}
+ removedBg={theme.diffRemovedBg}
+ contextBg={theme.diffContextBg}
+ addedSignColor={theme.diffHighlightAdded}
+ removedSignColor={theme.diffHighlightRemoved}
+ lineNumberFg={theme.diffLineNumber}
+ lineNumberBg={theme.diffContextBg}
+ addedLineNumberBg={theme.diffAddedLineNumberBg}
+ removedLineNumberBg={theme.diffRemovedLineNumberBg}
+ />
+ </box>
+ )
+ }
+
+ function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) {
+ if (file.type === "delete") return "# Deleted " + file.relativePath
+ if (file.type === "add") return "# Created " + file.relativePath
+ if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath
+ return "← Patched " + file.relativePath
+ }
+
return (
<Switch>
- <Match when={props.output !== undefined}>
- <BlockTool title="# Patch" part={props.part}>
- <box>
- <text fg={theme.text}>{props.output?.trim()}</text>
- </box>
- </BlockTool>
+ <Match when={files().length > 0}>
+ <For each={files()}>
+ {(file) => (
+ <BlockTool title={title(file)} part={props.part}>
+ <Show
+ when={file.type !== "delete"}
+ fallback={
+ <text fg={theme.diffRemoved}>
+ -{file.deletions} line{file.deletions !== 1 ? "s" : ""}
+ </text>
+ }
+ >
+ <Diff diff={file.diff} filePath={file.filePath} />
+ </Show>
+ </BlockTool>
+ )}
+ </For>
</Match>
<Match when={true}>
- <InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
- Patch
+ <InlineTool icon="%" pending="Preparing apply_patch..." complete={false} part={props.part}>
+ apply_patch
</InlineTool>
</Match>
</Switch>
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index 91d52065f..888a4d94b 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -177,8 +177,18 @@ export namespace Patch {
return { content, nextIdx: i }
}
+ function stripHeredoc(input: string): string {
+ // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
+ const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
+ if (heredocMatch) {
+ return heredocMatch[2]
+ }
+ return input
+ }
+
export function parsePatch(patchText: string): { hunks: Hunk[] } {
- const lines = patchText.split("\n")
+ const cleaned = stripHeredoc(patchText.trim())
+ const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
@@ -363,7 +373,7 @@ export namespace Patch {
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
- let found = seekSequence(originalLines, pattern, lineIndex)
+ let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
@@ -371,7 +381,7 @@ export namespace Patch {
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
- found = seekSequence(originalLines, pattern, lineIndex)
+ found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
@@ -407,28 +417,75 @@ export namespace Patch {
return result
}
- function seekSequence(lines: string[], pattern: string[], startIndex: number): number {
- if (pattern.length === 0) return -1
+ // Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
+ function normalizeUnicode(str: string): string {
+ return str
+ .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
+ .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
+ .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
+ .replace(/\u2026/g, "...") // ellipsis
+ .replace(/\u00A0/g, " ") // non-breaking space
+ }
+
+ type Comparator = (a: string, b: string) => boolean
+
+ function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
+ // If EOF anchor, try matching from end of file first
+ if (eof) {
+ const fromEnd = lines.length - pattern.length
+ if (fromEnd >= startIndex) {
+ let matches = true
+ for (let j = 0; j < pattern.length; j++) {
+ if (!compare(lines[fromEnd + j], pattern[j])) {
+ matches = false
+ break
+ }
+ }
+ if (matches) return fromEnd
+ }
+ }
- // Simple substring search implementation
+ // Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
-
for (let j = 0; j < pattern.length; j++) {
- if (lines[i + j] !== pattern[j]) {
+ if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
-
- if (matches) {
- return i
- }
+ if (matches) return i
}
return -1
}
+ function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
+ if (pattern.length === 0) return -1
+
+ // Pass 1: exact match
+ const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
+ if (exact !== -1) return exact
+
+ // Pass 2: rstrip (trim trailing whitespace)
+ const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
+ if (rstrip !== -1) return rstrip
+
+ // Pass 3: trim (both ends)
+ const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
+ if (trim !== -1) return trim
+
+ // Pass 4: normalized (Unicode punctuation to ASCII)
+ const normalized = tryMatch(
+ lines,
+ pattern,
+ startIndex,
+ (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
+ eof,
+ )
+ return normalized
+ }
+
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts
index c6b1d42e8..0fb2a5e9d 100644
--- a/packages/opencode/src/server/routes/experimental.ts
+++ b/packages/opencode/src/server/routes/experimental.ts
@@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() =>
}),
),
async (c) => {
- const { provider } = c.req.valid("query")
- const tools = await ToolRegistry.tools(provider)
+ const { provider, model } = c.req.valid("query")
+ const tools = await ToolRegistry.tools({ providerID: provider, modelID: model })
return c.json(
tools.map((t) => ({
id: t.id,
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 8327698fd..0d3d25feb 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -685,7 +685,10 @@ export namespace SessionPrompt {
},
})
- for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) {
+ for (const item of await ToolRegistry.tools(
+ { modelID: input.model.api.id, providerID: input.model.providerID },
+ input.agent,
+ )) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt
index d26e2e01a..daad82377 100644
--- a/packages/opencode/src/session/prompt/codex.txt
+++ b/packages/opencode/src/session/prompt/codex.txt
@@ -5,6 +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).
## Tool usage
- Prefer specialized tools over shell for file operations:
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
new file mode 100644
index 000000000..d070eaefa
--- /dev/null
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -0,0 +1,277 @@
+import z from "zod"
+import * as path from "path"
+import * as fs from "fs/promises"
+import { Tool } from "./tool"
+import { FileTime } from "../file/time"
+import { Bus } from "../bus"
+import { FileWatcher } from "../file/watcher"
+import { Instance } from "../project/instance"
+import { Patch } from "../patch"
+import { createTwoFilesPatch, diffLines } from "diff"
+import { assertExternalDirectory } from "./external-directory"
+import { trimDiff } from "./edit"
+import { LSP } from "../lsp"
+import { Filesystem } from "../util/filesystem"
+
+const PatchParams = z.object({
+ patchText: z.string().describe("The full patch text that describes all changes to be made"),
+})
+
+export const ApplyPatchTool = Tool.define("apply_patch", {
+ description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.",
+ parameters: PatchParams,
+ async execute(params, ctx) {
+ if (!params.patchText) {
+ throw new Error("patchText is required")
+ }
+
+ // Parse the patch to get hunks
+ let hunks: Patch.Hunk[]
+ try {
+ const parseResult = Patch.parsePatch(params.patchText)
+ hunks = parseResult.hunks
+ } catch (error) {
+ throw new Error(`apply_patch verification failed: ${error}`)
+ }
+
+ if (hunks.length === 0) {
+ const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
+ if (normalized === "*** Begin Patch\n*** End Patch") {
+ throw new Error("patch rejected: empty patch")
+ }
+ throw new Error("apply_patch verification failed: no hunks found")
+ }
+
+ // Validate file paths and check permissions
+ const fileChanges: Array<{
+ filePath: string
+ oldContent: string
+ newContent: string
+ type: "add" | "update" | "delete" | "move"
+ movePath?: string
+ diff: string
+ additions: number
+ deletions: number
+ }> = []
+
+ let totalDiff = ""
+
+ for (const hunk of hunks) {
+ const filePath = path.resolve(Instance.directory, hunk.path)
+ await assertExternalDirectory(ctx, filePath)
+
+ switch (hunk.type) {
+ case "add": {
+ const oldContent = ""
+ const newContent =
+ hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
+ const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
+
+ let additions = 0
+ let deletions = 0
+ for (const change of diffLines(oldContent, newContent)) {
+ if (change.added) additions += change.count || 0
+ if (change.removed) deletions += change.count || 0
+ }
+
+ fileChanges.push({
+ filePath,
+ oldContent,
+ newContent,
+ type: "add",
+ diff,
+ additions,
+ deletions,
+ })
+
+ totalDiff += diff + "\n"
+ break
+ }
+
+ case "update": {
+ // Check if file exists for update
+ const stats = await fs.stat(filePath).catch(() => null)
+ if (!stats || stats.isDirectory()) {
+ throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
+ }
+
+ // Read file and update time tracking (like edit tool does)
+ await FileTime.assert(ctx.sessionID, filePath)
+ const oldContent = await fs.readFile(filePath, "utf-8")
+ let newContent = oldContent
+
+ // Apply the update chunks to get new content
+ try {
+ const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
+ newContent = fileUpdate.content
+ } catch (error) {
+ throw new Error(`apply_patch verification failed: ${error}`)
+ }
+
+ const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
+
+ let additions = 0
+ let deletions = 0
+ for (const change of diffLines(oldContent, newContent)) {
+ if (change.added) additions += change.count || 0
+ if (change.removed) deletions += change.count || 0
+ }
+
+ const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
+ await assertExternalDirectory(ctx, movePath)
+
+ fileChanges.push({
+ filePath,
+ oldContent,
+ newContent,
+ type: hunk.move_path ? "move" : "update",
+ movePath,
+ diff,
+ additions,
+ deletions,
+ })
+
+ totalDiff += diff + "\n"
+ break
+ }
+
+ case "delete": {
+ const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
+ throw new Error(`apply_patch verification failed: ${error}`)
+ })
+ const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
+
+ const deletions = contentToDelete.split("\n").length
+
+ fileChanges.push({
+ filePath,
+ oldContent: contentToDelete,
+ newContent: "",
+ type: "delete",
+ diff: deleteDiff,
+ additions: 0,
+ deletions,
+ })
+
+ totalDiff += deleteDiff + "\n"
+ break
+ }
+ }
+ }
+
+ // Check permissions if needed
+ await ctx.ask({
+ permission: "edit",
+ patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
+ always: ["*"],
+ metadata: {
+ diff: totalDiff,
+ },
+ })
+
+ // Apply the changes
+ const changedFiles: string[] = []
+
+ for (const change of fileChanges) {
+ switch (change.type) {
+ case "add":
+ // Create parent directories (recursive: true is safe on existing/root dirs)
+ await fs.mkdir(path.dirname(change.filePath), { recursive: true })
+ await fs.writeFile(change.filePath, change.newContent, "utf-8")
+ changedFiles.push(change.filePath)
+ break
+
+ case "update":
+ await fs.writeFile(change.filePath, change.newContent, "utf-8")
+ changedFiles.push(change.filePath)
+ break
+
+ case "move":
+ if (change.movePath) {
+ // Create parent directories (recursive: true is safe on existing/root dirs)
+ await fs.mkdir(path.dirname(change.movePath), { recursive: true })
+ await fs.writeFile(change.movePath, change.newContent, "utf-8")
+ await fs.unlink(change.filePath)
+ changedFiles.push(change.movePath)
+ }
+ break
+
+ case "delete":
+ await fs.unlink(change.filePath)
+ changedFiles.push(change.filePath)
+ break
+ }
+
+ // Update file time tracking
+ FileTime.read(ctx.sessionID, change.filePath)
+ if (change.movePath) {
+ FileTime.read(ctx.sessionID, change.movePath)
+ }
+ }
+
+ // Publish file change events
+ for (const filePath of changedFiles) {
+ await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
+ }
+
+ // Notify LSP of file changes and collect diagnostics
+ for (const change of fileChanges) {
+ if (change.type === "delete") continue
+ const target = change.movePath ?? change.filePath
+ await LSP.touchFile(target, true)
+ }
+ const diagnostics = await LSP.diagnostics()
+
+ // Generate output summary
+ const summaryLines = fileChanges.map((change) => {
+ if (change.type === "add") {
+ return `A ${path.relative(Instance.worktree, change.filePath)}`
+ }
+ if (change.type === "delete") {
+ return `D ${path.relative(Instance.worktree, change.filePath)}`
+ }
+ const target = change.movePath ?? change.filePath
+ return `M ${path.relative(Instance.worktree, target)}`
+ })
+ let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
+
+ // Report LSP errors for changed files
+ const MAX_DIAGNOSTICS_PER_FILE = 20
+ for (const change of fileChanges) {
+ if (change.type === "delete") continue
+ const target = change.movePath ?? change.filePath
+ const normalized = Filesystem.normalizePath(target)
+ const issues = diagnostics[normalized] ?? []
+ const errors = issues.filter((item) => item.severity === 1)
+ if (errors.length > 0) {
+ const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
+ const suffix =
+ errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
+ output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
+ }
+ }
+
+ // Build per-file metadata for UI rendering
+ const files = fileChanges.map((change) => ({
+ filePath: change.filePath,
+ relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath),
+ type: change.type,
+ diff: change.diff,
+ before: change.oldContent,
+ after: change.newContent,
+ additions: change.additions,
+ deletions: change.deletions,
+ movePath: change.movePath,
+ }))
+
+ return {
+ title: output,
+ metadata: {
+ diff: totalDiff,
+ files,
+ diagnostics,
+ },
+ output,
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt
new file mode 100644
index 000000000..1af060610
--- /dev/null
+++ b/packages/opencode/src/tool/apply_patch.txt
@@ -0,0 +1 @@
+Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.
diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts
index ba1b94a3e..8bffbd54a 100644
--- a/packages/opencode/src/tool/batch.ts
+++ b/packages/opencode/src/tool/batch.ts
@@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => {
const discardedCalls = params.tool_calls.slice(10)
const { ToolRegistry } = await import("./registry")
- const availableTools = await ToolRegistry.tools("")
+ const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {
diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts
deleted file mode 100644
index 08a58bfea..000000000
--- a/packages/opencode/src/tool/patch.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import z from "zod"
-import * as path from "path"
-import * as fs from "fs/promises"
-import { Tool } from "./tool"
-import { FileTime } from "../file/time"
-import { Bus } from "../bus"
-import { FileWatcher } from "../file/watcher"
-import { Instance } from "../project/instance"
-import { Patch } from "../patch"
-import { createTwoFilesPatch } from "diff"
-import { assertExternalDirectory } from "./external-directory"
-
-const PatchParams = z.object({
- patchText: z.string().describe("The full patch text that describes all changes to be made"),
-})
-
-export const PatchTool = Tool.define("patch", {
- description:
- "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.",
- parameters: PatchParams,
- async execute(params, ctx) {
- if (!params.patchText) {
- throw new Error("patchText is required")
- }
-
- // Parse the patch to get hunks
- let hunks: Patch.Hunk[]
- try {
- const parseResult = Patch.parsePatch(params.patchText)
- hunks = parseResult.hunks
- } catch (error) {
- throw new Error(`Failed to parse patch: ${error}`)
- }
-
- if (hunks.length === 0) {
- throw new Error("No file changes found in patch")
- }
-
- // Validate file paths and check permissions
- const fileChanges: Array<{
- filePath: string
- oldContent: string
- newContent: string
- type: "add" | "update" | "delete" | "move"
- movePath?: string
- }> = []
-
- let totalDiff = ""
-
- for (const hunk of hunks) {
- const filePath = path.resolve(Instance.directory, hunk.path)
- await assertExternalDirectory(ctx, filePath)
-
- switch (hunk.type) {
- case "add":
- if (hunk.type === "add") {
- const oldContent = ""
- const newContent = hunk.contents
- const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
-
- fileChanges.push({
- filePath,
- oldContent,
- newContent,
- type: "add",
- })
-
- totalDiff += diff + "\n"
- }
- break
-
- case "update":
- // Check if file exists for update
- const stats = await fs.stat(filePath).catch(() => null)
- if (!stats || stats.isDirectory()) {
- throw new Error(`File not found or is directory: ${filePath}`)
- }
-
- // Read file and update time tracking (like edit tool does)
- await FileTime.assert(ctx.sessionID, filePath)
- const oldContent = await fs.readFile(filePath, "utf-8")
- let newContent = oldContent
-
- // Apply the update chunks to get new content
- try {
- const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
- newContent = fileUpdate.content
- } catch (error) {
- throw new Error(`Failed to apply update to ${filePath}: ${error}`)
- }
-
- const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent)
-
- const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
- await assertExternalDirectory(ctx, movePath)
-
- fileChanges.push({
- filePath,
- oldContent,
- newContent,
- type: hunk.move_path ? "move" : "update",
- movePath,
- })
-
- totalDiff += diff + "\n"
- break
-
- case "delete":
- // Check if file exists for deletion
- await FileTime.assert(ctx.sessionID, filePath)
- const contentToDelete = await fs.readFile(filePath, "utf-8")
- const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "")
-
- fileChanges.push({
- filePath,
- oldContent: contentToDelete,
- newContent: "",
- type: "delete",
- })
-
- totalDiff += deleteDiff + "\n"
- break
- }
- }
-
- // Check permissions if needed
- await ctx.ask({
- permission: "edit",
- patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)),
- always: ["*"],
- metadata: {
- diff: totalDiff,
- },
- })
-
- // Apply the changes
- const changedFiles: string[] = []
-
- for (const change of fileChanges) {
- switch (change.type) {
- case "add":
- // Create parent directories
- const addDir = path.dirname(change.filePath)
- if (addDir !== "." && addDir !== "/") {
- await fs.mkdir(addDir, { recursive: true })
- }
- await fs.writeFile(change.filePath, change.newContent, "utf-8")
- changedFiles.push(change.filePath)
- break
-
- case "update":
- await fs.writeFile(change.filePath, change.newContent, "utf-8")
- changedFiles.push(change.filePath)
- break
-
- case "move":
- if (change.movePath) {
- // Create parent directories for destination
- const moveDir = path.dirname(change.movePath)
- if (moveDir !== "." && moveDir !== "/") {
- await fs.mkdir(moveDir, { recursive: true })
- }
- // Write to new location
- await fs.writeFile(change.movePath, change.newContent, "utf-8")
- // Remove original
- await fs.unlink(change.filePath)
- changedFiles.push(change.movePath)
- }
- break
-
- case "delete":
- await fs.unlink(change.filePath)
- changedFiles.push(change.filePath)
- break
- }
-
- // Update file time tracking
- FileTime.read(ctx.sessionID, change.filePath)
- if (change.movePath) {
- FileTime.read(ctx.sessionID, change.movePath)
- }
- }
-
- // Publish file change events
- for (const filePath of changedFiles) {
- await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" })
- }
-
- // Generate output summary
- const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath))
- const summary = `${fileChanges.length} files changed`
-
- return {
- title: summary,
- metadata: {
- diff: totalDiff,
- },
- output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`,
- }
- },
-})
diff --git a/packages/opencode/src/tool/patch.txt b/packages/opencode/src/tool/patch.txt
deleted file mode 100644
index 88a50f634..000000000
--- a/packages/opencode/src/tool/patch.txt
+++ /dev/null
@@ -1 +0,0 @@
-do not use
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 35e378f08..faa5f72bc 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -26,6 +26,7 @@ import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
+import { ApplyPatchTool } from "./apply_patch"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -108,6 +109,7 @@ export namespace ToolRegistry {
WebSearchTool,
CodeSearchTool,
SkillTool,
+ ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
@@ -119,15 +121,28 @@ export namespace ToolRegistry {
return all().then((x) => x.map((t) => t.id))
}
- export async function tools(providerID: string, agent?: Agent.Info) {
+ export async function tools(
+ model: {
+ providerID: string
+ modelID: string
+ },
+ agent?: Agent.Info,
+ ) {
const tools = await all()
const result = await Promise.all(
tools
.filter((t) => {
// Enable websearch/codesearch for zen users OR via enable flag
if (t.id === "codesearch" || t.id === "websearch") {
- return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
+ return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
+
+ // use apply tool in same format as codex
+ const usePatch =
+ model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
+ if (t.id === "apply_patch") return usePatch
+ if (t.id === "edit" || t.id === "write") return !usePatch
+
return true
})
.map(async (t) => {
diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts
new file mode 100644
index 000000000..d8f05a9d9
--- /dev/null
+++ b/packages/opencode/test/tool/apply_patch.test.ts
@@ -0,0 +1,515 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import * as fs from "fs/promises"
+import { ApplyPatchTool } from "../../src/tool/apply_patch"
+import { Instance } from "../../src/project/instance"
+import { FileTime } from "../../src/file/time"
+import { tmpdir } from "../fixture/fixture"
+
+const baseCtx = {
+ sessionID: "test",
+ messageID: "",
+ callID: "",
+ agent: "build",
+ abort: AbortSignal.any([]),
+ metadata: () => {},
+}
+
+type AskInput = {
+ permission: string
+ patterns: string[]
+ always: string[]
+ metadata: { diff: string }
+}
+
+type ToolCtx = typeof baseCtx & {
+ ask: (input: AskInput) => Promise<void>
+}
+
+const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
+ const tool = await ApplyPatchTool.init()
+ return tool.execute(params, ctx)
+}
+
+const makeCtx = () => {
+ const calls: AskInput[] = []
+ const ctx: ToolCtx = {
+ ...baseCtx,
+ ask: async (input) => {
+ calls.push(input)
+ },
+ }
+
+ return { ctx, calls }
+}
+
+describe("tool.apply_patch freeform", () => {
+ test("requires patchText", async () => {
+ const { ctx } = makeCtx()
+ await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
+ })
+
+ test("rejects invalid patch format", async () => {
+ const { ctx } = makeCtx()
+ await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed")
+ })
+
+ test("rejects empty patch", async () => {
+ const { ctx } = makeCtx()
+ const emptyPatch = "*** Begin Patch\n*** End Patch"
+ await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch")
+ })
+
+ test("applies add/update/delete in one patch", async () => {
+ await using fixture = await tmpdir()
+ const { ctx, calls } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const modifyPath = path.join(fixture.path, "modify.txt")
+ const deletePath = path.join(fixture.path, "delete.txt")
+ await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
+ await fs.writeFile(deletePath, "obsolete\n", "utf-8")
+ FileTime.read(ctx.sessionID, modifyPath)
+ FileTime.read(ctx.sessionID, deletePath)
+
+ const patchText =
+ "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
+
+ const result = await execute({ patchText }, ctx)
+
+ expect(result.title).toContain("Success. Updated the following files")
+ expect(result.output).toContain("Success. Updated the following files")
+ expect(result.metadata.diff).toContain("Index:")
+ expect(calls.length).toBe(1)
+
+ const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
+ expect(added).toBe("created\n")
+ expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n")
+ await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow()
+ },
+ })
+ })
+
+ test("applies multiple hunks to one file", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "multi.txt")
+ await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText =
+ "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+
+ expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n")
+ },
+ })
+ })
+
+ test("inserts lines with insert-only hunk", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "insert_only.txt")
+ await fs.writeFile(target, "alpha\nomega\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+
+ expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n")
+ },
+ })
+ })
+
+ test("appends trailing newline on update", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "no_newline.txt")
+ await fs.writeFile(target, "no newline at end", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText =
+ "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+
+ const contents = await fs.readFile(target, "utf-8")
+ expect(contents.endsWith("\n")).toBe(true)
+ expect(contents).toBe("first line\nsecond line\n")
+ },
+ })
+ })
+
+ test("moves file to a new directory", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const original = path.join(fixture.path, "old", "name.txt")
+ await fs.mkdir(path.dirname(original), { recursive: true })
+ await fs.writeFile(original, "old content\n", "utf-8")
+ FileTime.read(ctx.sessionID, original)
+
+ const patchText =
+ "*** 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"
+
+ await execute({ patchText }, ctx)
+
+ const moved = path.join(fixture.path, "renamed", "dir", "name.txt")
+ await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
+ expect(await fs.readFile(moved, "utf-8")).toBe("new content\n")
+ },
+ })
+ })
+
+ test("moves file overwriting existing destination", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const original = path.join(fixture.path, "old", "name.txt")
+ const destination = path.join(fixture.path, "renamed", "dir", "name.txt")
+ await fs.mkdir(path.dirname(original), { recursive: true })
+ await fs.mkdir(path.dirname(destination), { recursive: true })
+ await fs.writeFile(original, "from\n", "utf-8")
+ await fs.writeFile(destination, "existing\n", "utf-8")
+ FileTime.read(ctx.sessionID, original)
+
+ const patchText =
+ "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+
+ await expect(fs.readFile(original, "utf-8")).rejects.toThrow()
+ expect(await fs.readFile(destination, "utf-8")).toBe("new\n")
+ },
+ })
+ })
+
+ test("adds file overwriting existing file", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "duplicate.txt")
+ await fs.writeFile(target, "old content\n", "utf-8")
+
+ const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+ expect(await fs.readFile(target, "utf-8")).toBe("new content\n")
+ },
+ })
+ })
+
+ test("rejects update when target file is missing", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow(
+ "apply_patch verification failed: Failed to read file to update",
+ )
+ },
+ })
+ })
+
+ test("rejects delete when file is missing", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow()
+ },
+ })
+ })
+
+ test("rejects delete when target is a directory", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const dirPath = path.join(fixture.path, "dir")
+ await fs.mkdir(dirPath)
+
+ const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow()
+ },
+ })
+ })
+
+ test("rejects invalid hunk header", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
+ },
+ })
+ })
+
+ test("rejects update with missing context", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "modify.txt")
+ await fs.writeFile(target, "line1\nline2\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed")
+ expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n")
+ },
+ })
+ })
+
+ test("verification failure leaves no side effects", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const patchText =
+ "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow()
+
+ const createdPath = path.join(fixture.path, "created.txt")
+ await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow()
+ },
+ })
+ })
+
+ test("supports end of file anchor", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "tail.txt")
+ await fs.writeFile(target, "alpha\nlast\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+ expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n")
+ },
+ })
+ })
+
+ test("rejects missing second chunk context", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "two_chunks.txt")
+ await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
+
+ await expect(execute({ patchText }, ctx)).rejects.toThrow()
+ expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n")
+ },
+ })
+ })
+
+ test("disambiguates change context with @@ header", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "multi_ctx.txt")
+ await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+ expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n")
+ },
+ })
+ })
+
+ test("EOF anchor matches from end of file first", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "eof_anchor.txt")
+ // File has duplicate "marker" lines - one in middle, one at end
+ await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ // With EOF anchor, should match the LAST "marker" line, not the first
+ const patchText =
+ "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+ // First marker unchanged, second marker changed
+ expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n")
+ },
+ })
+ })
+
+ test("parses heredoc-wrapped patch", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const patchText = `cat <<'EOF'
+*** Begin Patch
+*** Add File: heredoc_test.txt
++heredoc content
+*** End Patch
+EOF`
+
+ await execute({ patchText }, ctx)
+ const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8")
+ expect(content).toBe("heredoc content\n")
+ },
+ })
+ })
+
+ test("parses heredoc-wrapped patch without cat", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const patchText = `<<EOF
+*** Begin Patch
+*** Add File: heredoc_no_cat.txt
++no cat prefix
+*** End Patch
+EOF`
+
+ await execute({ patchText }, ctx)
+ const content = await fs.readFile(path.join(fixture.path, "heredoc_no_cat.txt"), "utf-8")
+ expect(content).toBe("no cat prefix\n")
+ },
+ })
+ })
+
+ test("matches with trailing whitespace differences", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "trailing_ws.txt")
+ // File has trailing spaces on some lines
+ await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ // Patch doesn't have trailing spaces - should still match via rstrip pass
+ const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+ expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n")
+ },
+ })
+ })
+
+ test("matches with leading whitespace differences", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "leading_ws.txt")
+ // File has leading spaces
+ await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ // Patch without leading spaces - should match via trim pass
+ const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+ expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n")
+ },
+ })
+ })
+
+ test("matches with Unicode punctuation differences", async () => {
+ await using fixture = await tmpdir()
+ const { ctx } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const target = path.join(fixture.path, "unicode.txt")
+ // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014)
+ const leftQuote = "\u201C"
+ const rightQuote = "\u201D"
+ const emDash = "\u2014"
+ await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
+ FileTime.read(ctx.sessionID, target)
+
+ // Patch uses ASCII equivalents - should match via normalized pass
+ // The replacement uses ASCII quotes from the patch (not preserving Unicode)
+ const patchText =
+ '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch'
+
+ await execute({ patchText }, ctx)
+ // Result has ASCII quotes because that's what the patch specifies
+ expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`)
+ },
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts
deleted file mode 100644
index 3d3ec574e..000000000
--- a/packages/opencode/test/tool/patch.test.ts
+++ /dev/null
@@ -1,261 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import path from "path"
-import { PatchTool } from "../../src/tool/patch"
-import { Instance } from "../../src/project/instance"
-import { tmpdir } from "../fixture/fixture"
-import { PermissionNext } from "../../src/permission/next"
-import * as fs from "fs/promises"
-
-const ctx = {
- sessionID: "test",
- messageID: "",
- callID: "",
- agent: "build",
- abort: AbortSignal.any([]),
- metadata: () => {},
- ask: async () => {},
-}
-
-const patchTool = await PatchTool.init()
-
-describe("tool.patch", () => {
- test("should validate required parameters", async () => {
- await Instance.provide({
- directory: "/tmp",
- fn: async () => {
- expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required")
- },
- })
- })
-
- test("should validate patch format", async () => {
- await Instance.provide({
- directory: "/tmp",
- fn: async () => {
- expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch")
- },
- })
- })
-
- test("should handle empty patch", async () => {
- await Instance.provide({
- directory: "/tmp",
- fn: async () => {
- const emptyPatch = `*** Begin Patch
-*** End Patch`
-
- expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch")
- },
- })
- })
-
- test.skip("should ask permission for files outside working directory", async () => {
- await Instance.provide({
- directory: "/tmp",
- fn: async () => {
- const maliciousPatch = `*** Begin Patch
-*** Add File: /etc/passwd
-+malicious content
-*** End Patch`
- patchTool.execute({ patchText: maliciousPatch }, ctx)
- // TODO: this sucks
- await new Promise((resolve) => setTimeout(resolve, 1000))
- const pending = await PermissionNext.list()
- expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined()
- },
- })
- })
-
- test("should handle simple add file operation", async () => {
- await using fixture = await tmpdir()
-
- await Instance.provide({
- directory: fixture.path,
- fn: async () => {
- const patchText = `*** Begin Patch
-*** Add File: test-file.txt
-+Hello World
-+This is a test file
-*** End Patch`
-
- const result = await patchTool.execute({ patchText }, ctx)
-
- expect(result.title).toContain("files changed")
- expect(result.metadata.diff).toBeDefined()
- expect(result.output).toContain("Patch applied successfully")
-
- // Verify file was created
- const filePath = path.join(fixture.path, "test-file.txt")
- const content = await fs.readFile(filePath, "utf-8")
- expect(content).toBe("Hello World\nThis is a test file")
- },
- })
- })
-
- test("should handle file with context update", async () => {
- await using fixture = await tmpdir()
-
- await Instance.provide({
- directory: fixture.path,
- fn: async () => {
- const patchText = `*** Begin Patch
-*** Add File: config.js
-+const API_KEY = "test-key"
-+const DEBUG = false
-+const VERSION = "1.0"
-*** End Patch`
-
- const result = await patchTool.execute({ patchText }, ctx)
-
- expect(result.title).toContain("files changed")
- expect(result.metadata.diff).toBeDefined()
- expect(result.output).toContain("Patch applied successfully")
-
- // Verify file was created with correct content
- const filePath = path.join(fixture.path, "config.js")
- const content = await fs.readFile(filePath, "utf-8")
- expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"')
- },
- })
- })
-
- test("should handle multiple file operations", async () => {
- await using fixture = await tmpdir()
-
- await Instance.provide({
- directory: fixture.path,
- fn: async () => {
- const patchText = `*** Begin Patch
-*** Add File: file1.txt
-+Content of file 1
-*** Add File: file2.txt
-+Content of file 2
-*** Add File: file3.txt
-+Content of file 3
-*** End Patch`
-
- const result = await patchTool.execute({ patchText }, ctx)
-
- expect(result.title).toContain("3 files changed")
- expect(result.metadata.diff).toBeDefined()
- expect(result.output).toContain("Patch applied successfully")
-
- // Verify all files were created
- for (let i = 1; i <= 3; i++) {
- const filePath = path.join(fixture.path, `file${i}.txt`)
- const content = await fs.readFile(filePath, "utf-8")
- expect(content).toBe(`Content of file ${i}`)
- }
- },
- })
- })
-
- test("should create parent directories when adding nested files", async () => {
- await using fixture = await tmpdir()
-
- await Instance.provide({
- directory: fixture.path,
- fn: async () => {
- const patchText = `*** Begin Patch
-*** Add File: deep/nested/file.txt
-+Deep nested content
-*** End Patch`
-
- const result = await patchTool.execute({ patchText }, ctx)
-
- expect(result.title).toContain("files changed")
- expect(result.output).toContain("Patch applied successfully")
-
- // Verify nested file was created
- const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt")
- const exists = await fs
- .access(nestedPath)
- .then(() => true)
- .catch(() => false)
- expect(exists).toBe(true)
-
- const content = await fs.readFile(nestedPath, "utf-8")
- expect(content).toBe("Deep nested content")
- },
- })
- })
-
- test("should generate proper unified diff in metadata", async () => {
- await using fixture = await tmpdir()
-
- await Instance.provide({
- directory: fixture.path,
- fn: async () => {
- // First create a file with simple content
- const patchText1 = `*** Begin Patch
-*** Add File: test.txt
-+line 1
-+line 2
-+line 3
-*** End Patch`
-
- await patchTool.execute({ patchText: patchText1 }, ctx)
-
- // Now create an update patch
- const patchText2 = `*** Begin Patch
-*** Update File: test.txt
-@@
- line 1
--line 2
-+line 2 updated
- line 3
-*** End Patch`
-
- const result = await patchTool.execute({ patchText: patchText2 }, ctx)
-
- expect(result.metadata.diff).toBeDefined()
- expect(result.metadata.diff).toContain("@@")
- expect(result.metadata.diff).toContain("-line 2")
- expect(result.metadata.diff).toContain("+line 2 updated")
- },
- })
- })
-
- test("should handle complex patch with multiple operations", async () => {
- await using fixture = await tmpdir()
-
- await Instance.provide({
- directory: fixture.path,
- fn: async () => {
- const patchText = `*** Begin Patch
-*** Add File: new.txt
-+This is a new file
-+with multiple lines
-*** Add File: existing.txt
-+old content
-+new line
-+more content
-*** Add File: config.json
-+{
-+ "version": "1.0",
-+ "debug": true
-+}
-*** End Patch`
-
- const result = await patchTool.execute({ patchText }, ctx)
-
- expect(result.title).toContain("3 files changed")
- expect(result.metadata.diff).toBeDefined()
- expect(result.output).toContain("Patch applied successfully")
-
- // Verify all files were created
- const newPath = path.join(fixture.path, "new.txt")
- const newContent = await fs.readFile(newPath, "utf-8")
- expect(newContent).toBe("This is a new file\nwith multiple lines")
-
- const existingPath = path.join(fixture.path, "existing.txt")
- const existingContent = await fs.readFile(existingPath, "utf-8")
- expect(existingContent).toBe("old content\nnew line\nmore content")
-
- const configPath = path.join(fixture.path, "config.json")
- const configContent = await fs.readFile(configPath, "utf-8")
- expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}')
- },
- })
- })
-})
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 4a249ec4f..184565e9c 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -689,3 +689,75 @@
}
}
}
+
+[data-component="apply-patch-files"] {
+ display: flex;
+ flex-direction: column;
+}
+
+[data-component="apply-patch-file"] {
+ display: flex;
+ flex-direction: column;
+ border-top: 1px solid var(--border-weaker-base);
+
+ &:first-child {
+ border-top: 1px solid var(--border-weaker-base);
+ }
+
+ [data-slot="apply-patch-file-header"] {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background-color: var(--surface-inset-base);
+ }
+
+ [data-slot="apply-patch-file-action"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large);
+ color: var(--text-base);
+ flex-shrink: 0;
+
+ &[data-type="delete"] {
+ color: var(--text-critical-base);
+ }
+
+ &[data-type="add"] {
+ color: var(--text-success-base);
+ }
+
+ &[data-type="move"] {
+ color: var(--text-warning-base);
+ }
+ }
+
+ [data-slot="apply-patch-file-path"] {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-small);
+ color: var(--text-weak);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex-grow: 1;
+ }
+
+ [data-slot="apply-patch-deletion-count"] {
+ font-family: var(--font-family-mono);
+ font-size: var(--font-size-small);
+ color: var(--text-critical-base);
+ flex-shrink: 0;
+ }
+}
+
+[data-component="apply-patch-file-diff"] {
+ max-height: 420px;
+ overflow-y: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 165f46f6c..47403786b 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -233,6 +233,12 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
title: "Write",
subtitle: input.filePath ? getFilename(input.filePath) : undefined,
}
+ case "apply_patch":
+ return {
+ icon: "code-lines",
+ title: "Patch",
+ subtitle: input.files?.length ? `${input.files.length} file${input.files.length > 1 ? "s" : ""}` : undefined,
+ }
case "todowrite":
return {
icon: "checklist",
@@ -1027,6 +1033,94 @@ ToolRegistry.register({
},
})
+interface ApplyPatchFile {
+ filePath: string
+ relativePath: string
+ type: "add" | "update" | "delete" | "move"
+ diff: string
+ before: string
+ after: string
+ additions: number
+ deletions: number
+ movePath?: string
+}
+
+ToolRegistry.register({
+ name: "apply_patch",
+ render(props) {
+ const diffComponent = useDiffComponent()
+ const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
+
+ const subtitle = createMemo(() => {
+ const count = files().length
+ if (count === 0) return ""
+ return `${count} file${count > 1 ? "s" : ""}`
+ })
+
+ return (
+ <BasicTool
+ {...props}
+ icon="code-lines"
+ trigger={{
+ title: "Patch",
+ subtitle: subtitle(),
+ }}
+ >
+ <Show when={files().length > 0}>
+ <div data-component="apply-patch-files">
+ <For each={files()}>
+ {(file) => (
+ <div data-component="apply-patch-file">
+ <div data-slot="apply-patch-file-header">
+ <Switch>
+ <Match when={file.type === "delete"}>
+ <span data-slot="apply-patch-file-action" data-type="delete">
+ Deleted
+ </span>
+ </Match>
+ <Match when={file.type === "add"}>
+ <span data-slot="apply-patch-file-action" data-type="add">
+ Created
+ </span>
+ </Match>
+ <Match when={file.type === "move"}>
+ <span data-slot="apply-patch-file-action" data-type="move">
+ Moved
+ </span>
+ </Match>
+ <Match when={file.type === "update"}>
+ <span data-slot="apply-patch-file-action" data-type="update">
+ Patched
+ </span>
+ </Match>
+ </Switch>
+ <span data-slot="apply-patch-file-path">{file.relativePath}</span>
+ <Show when={file.type !== "delete"}>
+ <DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
+ </Show>
+ <Show when={file.type === "delete"}>
+ <span data-slot="apply-patch-deletion-count">-{file.deletions}</span>
+ </Show>
+ </div>
+ <Show when={file.type !== "delete"}>
+ <div data-component="apply-patch-file-diff">
+ <Dynamic
+ component={diffComponent}
+ before={{ name: file.filePath, contents: file.before }}
+ after={{ name: file.filePath, contents: file.after }}
+ />
+ </div>
+ </Show>
+ </div>
+ )}
+ </For>
+ </div>
+ </Show>
+ </BasicTool>
+ )
+ },
+})
+
ToolRegistry.register({
name: "todowrite",
render(props) {