diff --git a/packages/opencode/src/tool/hashline_edit.ts b/packages/opencode/src/tool/hashline_edit.ts new file mode 100644 index 000000000000..30dcc664ba29 --- /dev/null +++ b/packages/opencode/src/tool/hashline_edit.ts @@ -0,0 +1,86 @@ +import z from "zod" +import * as path from "path" +import { Tool } from "./tool" +import { FileTime } from "../file/time" +import DESCRIPTION from "./hashline_edit.txt" +import { Instance } from "../project/instance" +import { assertExternalDirectory } from "./external-directory" +import { applyHashlineEdits, type HashlineEdit, parseAnchor } from "./hashline" + +export const HashlineEditTool = Tool.define("hashline_edit", { + description: DESCRIPTION, + parameters: z.object({ + file: z.string().describe("Absolute path to the file to edit"), + edits: z + .array( + z.discriminatedUnion("op", [ + z.object({ + op: z.literal("set_line"), + anchor: z.string().describe('Line anchor e.g. "14丐"'), + new_text: z.string().describe("Replacement text, or empty string to delete the line"), + }), + z.object({ + op: z.literal("replace_lines"), + start_anchor: z.string().describe('Start anchor e.g. "10乙"'), + end_anchor: z.string().describe('End anchor e.g. "14丐"'), + new_text: z.string().describe("Replacement lines, or empty string to delete the range"), + }), + z.object({ + op: z.literal("insert_after"), + anchor: z.string().describe('Line anchor e.g. "14丐"'), + text: z.string().describe("Text to insert after the anchor line"), + }), + ]) + ) + .min(1) + .describe("List of edits to apply atomically"), + }), + async execute(params, ctx) { + const filepath = path.isAbsolute(params.file) ? params.file : path.join(Instance.directory, params.file) + + await assertExternalDirectory(ctx, filepath) + + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filepath)], + always: ["*"], + metadata: { + filepath, + }, + }) + + const parsedEdits: HashlineEdit[] = params.edits.map((edit) => { + if (edit.op === "set_line") { + return { op: edit.op, anchor: parseAnchor(edit.anchor), new_text: edit.new_text } + } + if (edit.op === "replace_lines") { + return { + op: edit.op, + start_anchor: parseAnchor(edit.start_anchor), + end_anchor: parseAnchor(edit.end_anchor), + new_text: edit.new_text, + } + } + return { op: edit.op, anchor: parseAnchor(edit.anchor), text: edit.text } + }) + + await FileTime.withLock(filepath, async () => { + const file = Bun.file(filepath) + const stats = await file.stat().catch(() => {}) + if (!stats) throw new Error(`File not found: ${filepath}`) + if (stats.isDirectory()) throw new Error(`Path is a directory: ${filepath}`) + await FileTime.assert(ctx.sessionID, filepath) + + const contentOld = await file.text() + const contentNew = applyHashlineEdits(contentOld, parsedEdits) + await Bun.write(filepath, contentNew) + FileTime.read(ctx.sessionID, filepath) + }) + + return { + title: path.relative(Instance.worktree, filepath), + output: `Edit applied successfully to ${params.file}`, + metadata: {}, + } + }, +}) diff --git a/packages/opencode/src/tool/hashline_edit.txt b/packages/opencode/src/tool/hashline_edit.txt new file mode 100644 index 000000000000..88303a1942f7 --- /dev/null +++ b/packages/opencode/src/tool/hashline_edit.txt @@ -0,0 +1,12 @@ +Apply precise line edits to a file using content-addressable anchors from hashline_read. + +Each edit references a line by its anchor (line number + CJK hash character). The tool validates +all anchors before applying any edits — if any anchor doesn't match, no changes are made. + +Supports three operations: +- set_line: Replace or delete a single line +- replace_lines: Replace or delete a contiguous range of lines +- insert_after: Insert new lines after an anchor line + +If lines have moved since last read, anchors are automatically relocated by hash. +Always use hashline_read to get current anchors before editing. \ No newline at end of file diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 3460cb0de0b0..d622436b54fc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -30,6 +30,7 @@ import { CheckTaskTool } from "./check_task" import { ListTasksTool } from "./list_tasks" import { CancelTaskTool } from "./cancel_task" import { HashlineReadTool } from "./hashline_read" +import { HashlineEditTool } from "./hashline_edit" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -131,7 +132,7 @@ export namespace ToolRegistry { SkillTool, ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), - ...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool] : []), +...(Flag.OPENCODE_EXPERIMENTAL_HASHLINE ? [HashlineReadTool, HashlineEditTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), ...custom, @@ -152,20 +153,21 @@ export namespace ToolRegistry { 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 model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA - } +.filter((t) => { + // Enable websearch/codesearch for zen users OR via enable flag + if (t.id === "codesearch" || t.id === "websearch") { + return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA + } - // use apply tool in same format as codex - const usePatch = - model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") - if (t.id === "apply_patch") return usePatch - if (t.id === "edit" || t.id === "write") return !usePatch + // use apply tool in same format as codex + const usePatch = + model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") + if (t.id === "apply_patch") return usePatch +if (t.id === "edit") return !usePatch && !Flag.OPENCODE_EXPERIMENTAL_HASHLINE + if (t.id === "write") return !usePatch - return true - }) + return true + }) .map(async (t) => { using _ = log.time(t.id) const tool = await t.init({ agent }) diff --git a/packages/opencode/test/tool/hashline_edit.test.ts b/packages/opencode/test/tool/hashline_edit.test.ts new file mode 100644 index 000000000000..3114c35a6e00 --- /dev/null +++ b/packages/opencode/test/tool/hashline_edit.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { HashlineEditTool } from "../../src/tool/hashline_edit" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { FileTime } from "../../src/file/time" +import { Flag } from "../../src/flag/flag" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.hashline_edit", () => { + test("set_line replaces a line correctly", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [{ op: "set_line", anchor: "2咲", new_text: "new line 2" }], + }, + ctx + ) + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nnew line 2\nline3") + expect(result.output).toContain("Edit applied successfully") + }, + }) + }) + + test("set_line with new_text empty deletes the line", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [{ op: "set_line", anchor: "2咲", new_text: "" }], + }, + ctx + ) + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nline3") + expect(result.output).toContain("Edit applied successfully") + }, + }) + }) + + test("replace_lines replaces a range correctly", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3\nline4\nline5") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [ + { + op: "replace_lines", + start_anchor: "2咲", + end_anchor: "4扟", + new_text: "replaced lines", + }, + ], + }, + ctx + ) + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nreplaced lines\nline5") + expect(result.output).toContain("Edit applied successfully") + }, + }) + }) + + test("replace_lines with new_text empty deletes the range", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3\nline4") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [ + { + op: "replace_lines", + start_anchor: "2咲", + end_anchor: "3徃", + new_text: "", + }, + ], + }, + ctx + ) + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nline4") + expect(result.output).toContain("Edit applied successfully") + }, + }) + }) + + test("insert_after inserts lines at the correct position", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [ + { + op: "insert_after", + anchor: "2咲", + text: "inserted line", + }, + ], + }, + ctx + ) + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nline2\ninserted line\nline3") + expect(result.output).toContain("Edit applied successfully") + }, + }) + }) + + test("returns human-readable error message when anchor mismatches", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [{ op: "set_line", anchor: "2戌", new_text: "new line" }], + }, + ctx + ).catch((e) => e) + expect(result.message).toContain("have changed since last read") + expect(result.message).toContain("→") + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nline2\nline3") + }, + }) + }) + + test("returns error message for no-op edit", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [{ op: "set_line", anchor: "2咲", new_text: "line2" }], + }, + ctx + ).catch((e) => e) + expect(result.message).toContain("No-op edit detected") + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nline2\nline3") + }, + }) + }) + + test("applies multiple edits atomically", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3\nline4\nline5") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [ + { op: "set_line", anchor: "2咲", new_text: "updated line 2" }, + { op: "set_line", anchor: "3徃", new_text: "updated line 3" }, + ], + }, + ctx + ) + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nupdated line 2\nupdated line 3\nline4\nline5") + expect(result.output).toContain("Edit applied successfully") + }, + }) + }) + + test("atomic failure: file unchanged when any anchor is invalid", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3\nline4") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + FileTime.read(ctx.sessionID, path.join(tmp.path, "test.txt")) + const result = await tool.execute( + { + file: path.join(tmp.path, "test.txt"), + edits: [ + { op: "set_line", anchor: "2咲", new_text: "this should apply" }, + { op: "set_line", anchor: "3戌", new_text: "this should fail" }, + ], + }, + ctx + ).catch((e) => e) + expect(result.message).toContain("have changed since last read") + const content = await Bun.file(path.join(tmp.path, "test.txt")).text() + expect(content).toBe("line1\nline2\nline3\nline4") + }, + }) + }) + + test("hashline_edit not in registry when flag disabled", async () => { + process.env.OPENCODE_EXPERIMENTAL_HASHLINE = "false" + try { + // Test that the module handles the flag correctly + const { HashlineEditTool } = await import("../../src/tool/hashline_edit") + // Tool exists but won't be in registry when flag is disabled + expect(HashlineEditTool).toBeDefined() + } finally { + delete process.env.OPENCODE_EXPERIMENTAL_HASHLINE + } + }) +}) \ No newline at end of file