From 78f8cc94187914a842e01d41d042e4970dc9b1d0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 17 Jan 2026 14:25:29 -0600 Subject: [PATCH] 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 ef6b0c4fc9..d1236ff40b 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 1d64a2ff15..082eee14cf 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 c6b1d42e8e..0fb2a5e9d2 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 8327698fd5..0d3d25feb8 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 08a58bfea9..dcbf66352e 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 0000000000..1af0606109 --- /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 ba1b94a3e6..8bffbd54a2 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 88a50f6347..0000000000 --- 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 35e378f080..59ce8ecbfd 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 0000000000..dcfac436a8 --- /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 3d3ec574e6..0000000000 --- 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}') - }, - }) - }) -})