diff --git a/packages/opencode/src/tool/hashline.ts b/packages/opencode/src/tool/hashline.ts index e729cf0f9ab..26f16711366 100644 --- a/packages/opencode/src/tool/hashline.ts +++ b/packages/opencode/src/tool/hashline.ts @@ -20,6 +20,9 @@ export type HashlineEdit = anchor: Anchor text: string } + | { + op: "delete_file" + } export function normalizeLine(line: string): string { let result = line.replace(/\r$/, "") @@ -77,6 +80,9 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri const relocatedMap: Map = new Map() for (const edit of edits) { + // Skip delete_file operations - they don't have anchors to validate + if (edit.op === "delete_file") continue + const anchors = [ ...(edit.op === "set_line" ? [edit.anchor] @@ -125,6 +131,7 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri const sortedEdits = [...edits].sort((a, b) => { const getOriginalLine = (e: HashlineEdit): number => { + if (e.op === "delete_file") return Infinity if (e.op === "replace_lines") return e.start_anchor.line return e.anchor.line } @@ -204,4 +211,4 @@ export function applyHashlineEdits(content: string, edits: HashlineEdit[]): stri } return result -} \ No newline at end of file +} diff --git a/packages/opencode/src/tool/hashline_edit.ts b/packages/opencode/src/tool/hashline_edit.ts index 839ee5db62d..5283072c608 100644 --- a/packages/opencode/src/tool/hashline_edit.ts +++ b/packages/opencode/src/tool/hashline_edit.ts @@ -7,33 +7,53 @@ import { Instance } from "../project/instance" import { assertExternalDirectory } from "./external-directory" import { applyHashlineEdits, type HashlineEdit, parseAnchor } from "./hashline" +const editSchema = 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"), + }), + z.object({ + op: z.literal("delete_file"), + }), +]) + +type EditSchema = z.infer + +function parseEdit(edit: EditSchema): HashlineEdit { + 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, + } + } + if (edit.op === "insert_after") { + return { op: edit.op, anchor: parseAnchor(edit.anchor), text: edit.text } + } + throw new Error(`Unknown edit operation: ${edit.op}`) +} + export const HashlineEditTool = Tool.define("hashline_edit", { description: DESCRIPTION, parameters: z.object({ filePath: 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"), + edits: z.array(editSchema).min(1).describe("List of edits to apply atomically"), }), async execute(params, ctx) { const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath) @@ -49,20 +69,40 @@ export const HashlineEditTool = Tool.define("hashline_edit", { }, }) - 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, + const isDeleteFile = params.edits.length === 1 && params.edits[0].op === "delete_file" + + if (isDeleteFile) { + await FileTime.withLock(filepath, async () => { + try { + await FileTime.assertHashlineRead(ctx.sessionID, filepath) + } catch (error) { + // If file doesn't exist, that's an ENOENT error from stat() + // Wrap it in a clean message + if (error instanceof Error && error.message.includes("no such file")) { + throw new Error(`File not found: ${filepath}`) + } + throw error } + const file = Bun.file(filepath) + const exists = await file.exists().catch(() => false) + if (!exists) throw new Error(`File not found: ${filepath}`) + await file.delete().catch((error) => { + throw new Error(`File not found: ${filepath}`) + }) + // Clear FileTime state for this file + const { hashlineRead } = FileTime.state() + hashlineRead[ctx.sessionID] = hashlineRead[ctx.sessionID] || {} + delete hashlineRead[ctx.sessionID][filepath] + }) + + return { + title: path.relative(Instance.worktree, filepath), + output: `File deleted successfully: ${params.filePath}`, + metadata: {}, } - return { op: edit.op, anchor: parseAnchor(edit.anchor), text: edit.text } - }) + } + + const parsedEdits = params.edits.map(parseEdit) await FileTime.withLock(filepath, async () => { const file = Bun.file(filepath) diff --git a/packages/opencode/test/tool/hashline_edit.test.ts b/packages/opencode/test/tool/hashline_edit.test.ts index 2c0958f49df..d76a8d34307 100644 --- a/packages/opencode/test/tool/hashline_edit.test.ts +++ b/packages/opencode/test/tool/hashline_edit.test.ts @@ -413,4 +413,152 @@ describe("tool.hashline_edit", () => { }, }) }) + + test("delete_file successfully deletes a file after hashline_read", 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() + const filepath = path.join(tmp.path, "test.txt") + FileTime.hashlineRead(ctx.sessionID, filepath) + const result = await tool.execute( + { + filePath: filepath, + edits: [{ op: "delete_file" }], + }, + ctx + ) + expect(result.output).toContain("File deleted successfully") + const exists = await Bun.file(filepath).exists() + expect(exists).toBe(false) + }, + }) + }) + + test("delete_file is rejected without prior hashline_read", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "content") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + const filepath = path.join(tmp.path, "test.txt") + const result = await tool.execute( + { + filePath: filepath, + edits: [{ op: "delete_file" }], + }, + ctx + ).catch((e) => e) + expect(result.message).toContain("You must use hashline_read before hashline_edit") + const exists = await Bun.file(filepath).exists() + expect(exists).toBe(true) + }, + }) + }) + + test("delete_file returns clear error if file already gone", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "content") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + const filepath = path.join(tmp.path, "test.txt") + FileTime.hashlineRead(ctx.sessionID, filepath) + // Delete the file externally + await Bun.file(filepath).delete() + const result = await tool.execute( + { + filePath: filepath, + edits: [{ op: "delete_file" }], + }, + ctx + ).catch((e) => e) + expect(result.message).toContain("File not found") + }, + }) + }) + + test("after delete_file, subsequent hashline_edit fails until new hashline_read", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "test.txt"), "content") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await HashlineEditTool.init() + const filepath = path.join(tmp.path, "test.txt") + FileTime.hashlineRead(ctx.sessionID, filepath) + await tool.execute( + { + filePath: filepath, + edits: [{ op: "delete_file" }], + }, + ctx + ) + // Try to edit the same path (file is now gone) + const result = await tool.execute( + { + filePath: filepath, + edits: [{ op: "delete_file" }], + }, + ctx + ).catch((e) => e) + expect(result.message).toContain("You must use hashline_read before hashline_edit") + }, + }) + }) + + test("after delete_file + new hashline_read, operations work on re-created file", 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() + const filepath = path.join(tmp.path, "test.txt") + FileTime.hashlineRead(ctx.sessionID, filepath) + // Delete the file + await tool.execute( + { + filePath: filepath, + edits: [{ op: "delete_file" }], + }, + ctx + ) + // Re-create the file + await Bun.write(filepath, "new line1\nnew line2") + // Read it again with hashline_read + FileTime.hashlineRead(ctx.sessionID, filepath) + // Now editing should work + const result = await tool.execute( + { + filePath: filepath, + edits: [{ op: "set_line", anchor: "1赙", new_text: "modified" }], + }, + ctx + ) + expect(result.output).toContain("Edit applied successfully") + const content = await Bun.file(filepath).text() + expect(content).toContain("modified") + }, + }) + }) })