Parcourir la source

feat: apply_patch tool for openai models (#9127)

Aiden Cline il y a 2 mois
Parent
commit
b7ad6bd839

+ 2 - 2
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>>) {

+ 67 - 13
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>

+ 69 - 12
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")

+ 2 - 2
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,

+ 4 - 1
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,

+ 1 - 0
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:

+ 277 - 0
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,
+    }
+  },
+})

+ 1 - 0
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.

+ 1 - 1
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]) => {

+ 0 - 201
packages/opencode/src/tool/patch.ts

@@ -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")}`,
-    }
-  },
-})

+ 0 - 1
packages/opencode/src/tool/patch.txt

@@ -1 +0,0 @@
-do not use

+ 17 - 2
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) => {

+ 515 - 0
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`)
+      },
+    })
+  })
+})

+ 0 - 261
packages/opencode/test/tool/patch.test.ts

@@ -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}')
-      },
-    })
-  })
-})

+ 72 - 0
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;
+  }
+}

+ 94 - 0
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) {