From 78f8cc94187914a842e01d41d042e4970dc9b1d0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 14:25:29 -0600 Subject: [PATCH 01/12] wip --- packages/opencode/src/cli/cmd/debug/agent.ts | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 4 +- .../src/server/routes/experimental.ts | 4 +- packages/opencode/src/session/prompt.ts | 5 +- .../src/tool/{patch.ts => apply_patch.ts} | 2 +- packages/opencode/src/tool/apply_patch.txt | 1 + packages/opencode/src/tool/batch.ts | 2 +- packages/opencode/src/tool/patch.txt | 1 - packages/opencode/src/tool/registry.ts | 17 +- .../opencode/test/tool/apply_patch.test.ts | 261 ++++++++++++++++++ packages/opencode/test/tool/patch.test.ts | 261 ------------------ 11 files changed, 289 insertions(+), 273 deletions(-) rename packages/opencode/src/tool/{patch.ts => apply_patch.ts} (99%) create mode 100644 packages/opencode/src/tool/apply_patch.txt delete mode 100644 packages/opencode/src/tool/patch.txt create mode 100644 packages/opencode/test/tool/apply_patch.test.ts delete mode 100644 packages/opencode/test/tool/patch.test.ts diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ef6b0c4fc92b..d1236ff40bc5 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>) { 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 1d64a2ff1569..082eee14cf6b 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.ts" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" @@ -1835,7 +1835,7 @@ function Edit(props: ToolProps) { ) } -function Patch(props: ToolProps) { +function Patch(props: ToolProps) { const { theme } = useTheme() return ( diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c6b1d42e8e52..0fb2a5e9d2e0 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 8327698fd5fc..0d3d25feb8de 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/tool/patch.ts b/packages/opencode/src/tool/apply_patch.ts similarity index 99% rename from packages/opencode/src/tool/patch.ts rename to packages/opencode/src/tool/apply_patch.ts index 08a58bfea9cd..dcbf66352e77 100644 --- a/packages/opencode/src/tool/patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,7 +14,7 @@ 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", { +export const ApplyPatchTool = Tool.define("apply_patch", { description: "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", parameters: PatchParams, diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt new file mode 100644 index 000000000000..1af0606109f3 --- /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 ba1b94a3e607..8bffbd54a280 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.txt b/packages/opencode/src/tool/patch.txt deleted file mode 100644 index 88a50f6347ab..000000000000 --- 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 35e378f080ba..59ce8ecbfd5f 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,26 @@ 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 } + + const usePatch = model.modelID.includes("gpt") && !model.modelID.includes("oss") + if (t.id === "apply_patch") return usePatch + if (t.id === "edit") 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 000000000000..dcfac436a8da --- /dev/null +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { ApplyPatchTool } from "../../src/tool/apply_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/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts deleted file mode 100644 index 3d3ec574e60a..000000000000 --- 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}') - }, - }) - }) -}) From 40eddce4355c3229a40b24cd3088543923bd6b73 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 15:13:57 -0600 Subject: [PATCH 02/12] wip --- .../opencode/src/session/prompt/codex.txt | 1 + packages/opencode/src/tool/apply_patch.ts | 3 +- packages/opencode/src/tool/registry.ts | 2 + .../opencode/test/tool/apply_patch.test.ts | 600 +++++++++++------- 4 files changed, 357 insertions(+), 249 deletions(-) diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index d26e2e01aa7e..daad82377581 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 index dcbf66352e77..5043e014722a 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -15,8 +15,7 @@ const PatchParams = z.object({ }) export const ApplyPatchTool = Tool.define("apply_patch", { - description: - "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", + 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) { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 59ce8ecbfd5f..ad86828bac9b 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -137,7 +137,9 @@ export namespace ToolRegistry { 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 !== "gpt-5" << TODO: gpt-5 needs special instructions if (t.id === "apply_patch") return usePatch if (t.id === "edit") return !usePatch diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index dcfac436a8da..d65cb1479fc3 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,261 +1,367 @@ 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 { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission/next" -import * as fs from "fs/promises" -const ctx = { +const baseCtx = { 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}') -// }, -// }) -// }) -// }) +type AskInput = { + permission: string + patterns: string[] + always: string[] + metadata: { diff: string } +} + +type ToolCtx = typeof baseCtx & { + ask: (input: AskInput) => Promise +} + +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("Failed to parse patch") + }) + + test("rejects empty patch", async () => { + const { ctx } = makeCtx() + const emptyPatch = "*** Begin Patch\n*** End Patch" + await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in 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") + + 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("files changed") + expect(result.output).toContain("Patch applied successfully") + expect(result.metadata.diff).toContain("diff") + 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") + + 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") + + 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") + + 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") + + 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") + + 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("File not found or is directory") + }, + }) + }) + + 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("Failed to parse patch") + }, + }) + }) + + 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") + + const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("Failed to apply update") + 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") + + 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") + + 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") + + 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") + }, + }) + }) +}) From 2ed18ea1fe117dca548794b49cd75680213d1ee2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 20:48:09 -0600 Subject: [PATCH 03/12] wip --- packages/opencode/src/tool/apply_patch.ts | 34 ++++++++++++++----- .../opencode/test/tool/apply_patch.test.ts | 30 +++++++++++----- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 5043e014722a..9df0c099f5dd 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -28,11 +28,15 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const parseResult = Patch.parsePatch(params.patchText) hunks = parseResult.hunks } catch (error) { - throw new Error(`Failed to parse patch: ${error}`) + throw new Error(`apply_patch verification failed: ${error}`) } if (hunks.length === 0) { - throw new Error("No file changes found in patch") + 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 @@ -54,7 +58,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", { case "add": if (hunk.type === "add") { const oldContent = "" - const newContent = hunk.contents + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) fileChanges.push({ @@ -72,7 +77,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // 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}`) + throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) } // Read file and update time tracking (like edit tool does) @@ -85,7 +90,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content } catch (error) { - throw new Error(`Failed to apply update to ${filePath}: ${error}`) + throw new Error(`apply_patch verification failed: ${error}`) } const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) @@ -107,7 +112,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", { case "delete": // Check if file exists for deletion await FileTime.assert(ctx.sessionID, filePath) - const contentToDelete = await fs.readFile(filePath, "utf-8") + const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { + throw new Error(`apply_patch verification failed: ${error}`) + }) const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "") fileChanges.push({ @@ -186,15 +193,24 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } // Generate output summary - const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath)) - const summary = `${fileChanges.length} files changed` + 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)}` + }) + const summary = `Success. Updated the following files:\n${summaryLines.join("\n")}` return { title: summary, metadata: { diff: totalDiff, }, - output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`, + output: summary, } }, }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index d65cb1479fc3..631d28d9e0ea 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -3,6 +3,7 @@ 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 = { @@ -50,13 +51,13 @@ describe("tool.apply_patch freeform", () => { test("rejects invalid patch format", async () => { const { ctx } = makeCtx() - await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch") + 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("No file changes found in patch") + await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") }) test("applies add/update/delete in one patch", async () => { @@ -70,15 +71,17 @@ describe("tool.apply_patch freeform", () => { 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("files changed") - expect(result.output).toContain("Patch applied successfully") - expect(result.metadata.diff).toContain("diff") + 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") @@ -98,6 +101,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -118,6 +122,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -137,6 +142,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -160,6 +166,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -186,6 +193,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -225,7 +233,9 @@ describe("tool.apply_patch freeform", () => { 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("File not found or is directory") + await expect(execute({ patchText }, ctx)).rejects.toThrow( + "apply_patch verification failed: Failed to read file to update", + ) }, }) }) @@ -270,7 +280,7 @@ describe("tool.apply_patch freeform", () => { fn: async () => { const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" - await expect(execute({ patchText }, ctx)).rejects.toThrow("Failed to parse patch") + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") }, }) }) @@ -284,10 +294,11 @@ describe("tool.apply_patch freeform", () => { 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("Failed to apply update") + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n") }, }) @@ -320,6 +331,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -338,6 +350,7 @@ describe("tool.apply_patch freeform", () => { 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" @@ -356,6 +369,7 @@ describe("tool.apply_patch freeform", () => { 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" From ab78a46396ec7263f90bebd10edf658322e4832e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 21:15:27 -0600 Subject: [PATCH 04/12] wip --- packages/opencode/src/patch/index.ts | 81 +++++++++-- packages/opencode/src/tool/apply_patch.ts | 47 +++--- .../opencode/test/tool/apply_patch.test.ts | 134 ++++++++++++++++++ 3 files changed, 222 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 91d52065f6fd..888a4d94b89d 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 < 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/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 9df0c099f5dd..96c27f298e81 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -55,23 +55,22 @@ export const ApplyPatchTool = Tool.define("apply_patch", { await assertExternalDirectory(ctx, filePath) switch (hunk.type) { - case "add": - if (hunk.type === "add") { - const oldContent = "" - const newContent = - hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: "add", - }) - - totalDiff += diff + "\n" - } + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + 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 @@ -145,11 +144,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", { 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 }) - } + // 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 @@ -161,14 +157,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", { 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 + // 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") - // Remove original await fs.unlink(change.filePath) changedFiles.push(change.movePath) } diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 631d28d9e0ea..d8f05a9d911e 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -378,4 +378,138 @@ describe("tool.apply_patch freeform", () => { }, }) }) + + 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 = `< { + 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`) + }, + }) + }) }) From f1ec28176f9bcbf5ca70235cc7cbdcaafb326f16 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 22:03:47 -0600 Subject: [PATCH 05/12] wip - ui --- .../src/cli/cmd/tui/routes/session/index.tsx | 78 ++++++++++++++++--- packages/opencode/src/tool/apply_patch.ts | 55 +++++++++++-- 2 files changed, 115 insertions(+), 18 deletions(-) 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 082eee14cf6b..cc966b58f855 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1385,8 +1385,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - + + @@ -1835,20 +1835,74 @@ function Edit(props: ToolProps) { ) } -function Patch(props: ToolProps) { - const { theme } = useTheme() +function ApplyPatch(props: ToolProps) { + 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 ( + + + + ) + } + + 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 "← Edit " + file.relativePath + } + return ( - - - - {props.output?.trim()} - - + 0}> + + {(file) => ( + + + -{file.deletions} line{file.deletions !== 1 ? "s" : ""} + + } + > + + + + )} + - - Patch + + apply_patch diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 96c27f298e81..1eebda697ab3 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -7,8 +7,9 @@ import { Bus } from "../bus" import { FileWatcher } from "../file/watcher" import { Instance } from "../project/instance" import { Patch } from "../patch" -import { createTwoFilesPatch } from "diff" +import { createTwoFilesPatch, diffLines } from "diff" import { assertExternalDirectory } from "./external-directory" +import { trimDiff } from "./edit" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -46,6 +47,9 @@ export const ApplyPatchTool = Tool.define("apply_patch", { newContent: string type: "add" | "update" | "delete" | "move" movePath?: string + diff: string + additions: number + deletions: number }> = [] let totalDiff = "" @@ -59,20 +63,30 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) + 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": + case "update": { // Check if file exists for update const stats = await fs.stat(filePath).catch(() => null) if (!stats || stats.isDirectory()) { @@ -92,7 +106,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", { throw new Error(`apply_patch verification failed: ${error}`) } - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) + 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) @@ -103,28 +124,38 @@ export const ApplyPatchTool = Tool.define("apply_patch", { newContent, type: hunk.move_path ? "move" : "update", movePath, + diff, + additions, + deletions, }) totalDiff += diff + "\n" break + } - case "delete": + case "delete": { // Check if file exists for deletion await FileTime.assert(ctx.sessionID, filePath) const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { throw new Error(`apply_patch verification failed: ${error}`) }) - const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "") + 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 + } } } @@ -196,10 +227,22 @@ export const ApplyPatchTool = Tool.define("apply_patch", { }) const summary = `Success. Updated the following files:\n${summaryLines.join("\n")}` + // 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, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + })) + return { title: summary, metadata: { diff: totalDiff, + files, }, output: summary, } From 22b5d7e57027d40c33bf318e70b7431524d5898e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 22:19:02 -0600 Subject: [PATCH 06/12] rm assertion for deletes --- packages/opencode/src/tool/apply_patch.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 1eebda697ab3..4809c5be0e74 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -134,8 +134,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } case "delete": { - // Check if file exists for deletion - await FileTime.assert(ctx.sessionID, filePath) const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { throw new Error(`apply_patch verification failed: ${error}`) }) From 4173ee0e0bb90609b796feb873f001caa2ad35eb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 22:47:26 -0600 Subject: [PATCH 07/12] add lsp diagnostics to apply patch --- packages/opencode/src/tool/apply_patch.ts | 35 +++++++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 4809c5be0e74..d070eaefa978 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -10,6 +10,8 @@ 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"), @@ -212,6 +214,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", { 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") { @@ -223,7 +233,23 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const target = change.movePath ?? change.filePath return `M ${path.relative(Instance.worktree, target)}` }) - const summary = `Success. Updated the following files:\n${summaryLines.join("\n")}` + 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\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + } + } // Build per-file metadata for UI rendering const files = fileChanges.map((change) => ({ @@ -231,18 +257,21 @@ export const ApplyPatchTool = Tool.define("apply_patch", { 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: summary, + title: output, metadata: { diff: totalDiff, files, + diagnostics, }, - output: summary, + output, } }, }) From cfd6a7ae96cd13fba9e6bbfe139953473903b4aa Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 22:47:35 -0600 Subject: [PATCH 08/12] add apply patch to desktop app --- packages/ui/src/components/message-part.css | 72 ++++++++++++++++ packages/ui/src/components/message-part.tsx | 94 +++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 4a249ec4f425..184565e9cb32 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 165f46f6c500..2c0931015f57 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 ( + + 0}> +
+ + {(file) => ( +
+
+ + + + Deleted + + + + + Created + + + + + Moved + + + + + Edit + + + + {file.relativePath} + + + + + -{file.deletions} + +
+ +
+ +
+
+
+ )} +
+
+
+
+ ) + }, +}) + ToolRegistry.register({ name: "todowrite", render(props) { From 8a6b8e5339788f270d22927d7b3fdce1dd66f74b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 23:25:07 -0600 Subject: [PATCH 09/12] tweak wording to say Patched for ui rendered tool parts --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/ui/src/components/message-part.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 cc966b58f855..014b90e387e6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1877,7 +1877,7 @@ function ApplyPatch(props: ToolProps) { 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 "← Edit " + file.relativePath + return "← Patched " + file.relativePath } return ( diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 2c0931015f57..47403786b227 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1090,7 +1090,7 @@ ToolRegistry.register({
- Edit + Patched
From c2cc486c7d64e2b40c0ba408f776d9c2e834a3d7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 23:46:23 -0600 Subject: [PATCH 10/12] exclude write tool too --- packages/opencode/src/tool/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ad86828bac9b..cb81d8cd0bfe 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -141,7 +141,7 @@ export namespace ToolRegistry { const usePatch = model.modelID.includes("gpt") && !model.modelID.includes("oss") // && model.modelID !== "gpt-5" << TODO: gpt-5 needs special instructions if (t.id === "apply_patch") return usePatch - if (t.id === "edit") return !usePatch + if (t.id === "edit" || t.id === "write") return !usePatch return true }) From 06d69ab60900c016088f12efbf321e18e88a379e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 18 Jan 2026 00:17:13 -0600 Subject: [PATCH 11/12] cleanup --- packages/opencode/src/tool/registry.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index cb81d8cd0bfe..469126fba1e8 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -138,8 +138,7 @@ export namespace ToolRegistry { } // use apply tool in same format as codex - const usePatch = model.modelID.includes("gpt") && !model.modelID.includes("oss") - // && model.modelID !== "gpt-5" << TODO: gpt-5 needs special instructions + const usePatch = model.modelID.includes("gpt-5") && !model.modelID.includes("oss") if (t.id === "apply_patch") return usePatch if (t.id === "edit" || t.id === "write") return !usePatch From ac3d0cb5a35762b8ce22f0da5451dbb5034d4a4a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 18 Jan 2026 00:24:11 -0600 Subject: [PATCH 12/12] review cleanup --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/tool/registry.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 014b90e387e6..fd72a23217b0 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 { ApplyPatchTool } from "@/tool/apply_patch.ts" +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" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 469126fba1e8..faa5f72bcce1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -138,7 +138,8 @@ export namespace ToolRegistry { } // use apply tool in same format as codex - const usePatch = model.modelID.includes("gpt-5") && !model.modelID.includes("oss") + 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