From 3adade46e38ef989e0b6a9d1121561a60d8dbc2f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 19 Feb 2026 17:09:46 +0900 Subject: [PATCH] fix(hashline-edit): stabilize TUI diff metadata and output flow Align edit/write hashline handling with TUI expectations by preserving metadata through tool execution, keeping unified diff raw to avoid duplicated line numbers, and tightening read/write/edit outputs plus tests for reliable agent operation. --- src/hooks/hashline-edit-diff-enhancer/hook.ts | 124 +--------- .../hashline-edit-diff-enhancer/index.test.ts | 44 ++-- src/hooks/hashline-read-enhancer/hook.ts | 10 +- .../hashline-read-enhancer/index.test.ts | 20 ++ src/tools/hashline-edit/diff-utils.ts | 104 +++++++++ src/tools/hashline-edit/tools.test.ts | 219 ++++++++++++++++-- src/tools/hashline-edit/tools.ts | 89 ++++++- 7 files changed, 443 insertions(+), 167 deletions(-) create mode 100644 src/tools/hashline-edit/diff-utils.ts diff --git a/src/hooks/hashline-edit-diff-enhancer/hook.ts b/src/hooks/hashline-edit-diff-enhancer/hook.ts index f2363878f..8ef7c56aa 100644 --- a/src/hooks/hashline-edit-diff-enhancer/hook.ts +++ b/src/hooks/hashline-edit-diff-enhancer/hook.ts @@ -1,5 +1,5 @@ import { log } from "../../shared" -import { computeLineHash } from "../../tools/hashline-edit/hash-computation" +import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils" interface HashlineEditDiffEnhancerConfig { hashline_edit?: { enabled: boolean } @@ -27,9 +27,8 @@ function cleanupStaleEntries(): void { } } -function isEditOrWriteTool(toolName: string): boolean { - const lower = toolName.toLowerCase() - return lower === "edit" || lower === "write" +function isWriteTool(toolName: string): boolean { + return toolName.toLowerCase() === "write" } function extractFilePath(args: Record): string | undefined { @@ -37,113 +36,6 @@ function extractFilePath(args: Record): string | undefined { return typeof path === "string" ? path : undefined } -function toHashlineContent(content: string): string { - if (!content) return content - const lines = content.split("\n") - const lastLine = lines[lines.length - 1] - const hasTrailingNewline = lastLine === "" - const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines - const hashlined = contentLines.map((line, i) => { - const lineNum = i + 1 - const hash = computeLineHash(lineNum, line) - return `${lineNum}:${hash}|${line}` - }) - return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n") -} - -function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string { - const oldLines = oldContent.split("\n") - const newLines = newContent.split("\n") - const maxLines = Math.max(oldLines.length, newLines.length) - - let diff = `--- ${filePath}\n+++ ${filePath}\n` - let inHunk = false - let oldStart = 1 - let newStart = 1 - let oldCount = 0 - let newCount = 0 - let hunkLines: string[] = [] - - for (let i = 0; i < maxLines; i++) { - const oldLine = oldLines[i] ?? "" - const newLine = newLines[i] ?? "" - - if (oldLine !== newLine) { - if (!inHunk) { - // Start new hunk - oldStart = i + 1 - newStart = i + 1 - oldCount = 0 - newCount = 0 - hunkLines = [] - inHunk = true - } - - if (oldLines[i] !== undefined) { - hunkLines.push(`-${oldLine}`) - oldCount++ - } - if (newLines[i] !== undefined) { - hunkLines.push(`+${newLine}`) - newCount++ - } - } else if (inHunk) { - // Context line within hunk - hunkLines.push(` ${oldLine}`) - oldCount++ - newCount++ - - // End hunk if we've seen enough context - if (hunkLines.length > 6) { - diff += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` - diff += hunkLines.join("\n") + "\n" - inHunk = false - } - } - } - - // Close remaining hunk - if (inHunk && hunkLines.length > 0) { - diff += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` - diff += hunkLines.join("\n") + "\n" - } - - return diff || `--- ${filePath}\n+++ ${filePath}\n` -} - -function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } { - const oldLines = oldContent.split("\n") - const newLines = newContent.split("\n") - - const oldSet = new Map() - for (const line of oldLines) { - oldSet.set(line, (oldSet.get(line) ?? 0) + 1) - } - - const newSet = new Map() - for (const line of newLines) { - newSet.set(line, (newSet.get(line) ?? 0) + 1) - } - - let deletions = 0 - for (const [line, count] of oldSet) { - const newCount = newSet.get(line) ?? 0 - if (count > newCount) { - deletions += count - newCount - } - } - - let additions = 0 - for (const [line, count] of newSet) { - const oldCount = oldSet.get(line) ?? 0 - if (count > oldCount) { - additions += count - oldCount - } - } - - return { additions, deletions } -} - async function captureOldContent(filePath: string): Promise { try { const file = Bun.file(filePath) @@ -161,7 +53,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan return { "tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => { - if (!enabled || !isEditOrWriteTool(input.tool)) return + if (!enabled || !isWriteTool(input.tool)) return const filePath = extractFilePath(output.args) if (!filePath) return @@ -176,7 +68,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan }, "tool.execute.after": async (input: AfterInput, output: AfterOutput) => { - if (!enabled || !isEditOrWriteTool(input.tool)) return + if (!enabled || !isWriteTool(input.tool)) return const key = makeKey(input.sessionID, input.callID) const captured = pendingCaptures.get(key) @@ -194,14 +86,16 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan } const { additions, deletions } = countLineDiffs(oldContent, newContent) + const oldHashlined = toHashlineContent(oldContent) + const newHashlined = toHashlineContent(newContent) const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath) output.metadata.filediff = { file: filePath, path: filePath, - before: toHashlineContent(oldContent), - after: toHashlineContent(newContent), + before: oldHashlined, + after: newHashlined, additions, deletions, } diff --git a/src/hooks/hashline-edit-diff-enhancer/index.test.ts b/src/hooks/hashline-edit-diff-enhancer/index.test.ts index 1910b3815..435ad9c31 100644 --- a/src/hooks/hashline-edit-diff-enhancer/index.test.ts +++ b/src/hooks/hashline-edit-diff-enhancer/index.test.ts @@ -17,6 +17,15 @@ function makeAfterOutput(overrides?: Partial<{ title: string; output: string; me } } +type FileDiffMetadata = { + file: string + path: string + before: string + after: string + additions: number + deletions: number +} + describe("hashline-edit-diff-enhancer", () => { let hook: ReturnType @@ -25,9 +34,9 @@ describe("hashline-edit-diff-enhancer", () => { }) describe("tool.execute.before", () => { - test("captures old file content for edit tool", async () => { + test("captures old file content for write tool", async () => { const filePath = import.meta.dir + "/index.test.ts" - const input = makeInput("edit") + const input = makeInput("write") const output = makeBeforeOutput({ path: filePath, edits: [] }) await hook["tool.execute.before"](input, output) @@ -36,7 +45,7 @@ describe("hashline-edit-diff-enhancer", () => { // we verify in the after hook test that it produces filediff }) - test("ignores non-edit tools", async () => { + test("ignores non-write tools", async () => { const input = makeInput("read") const output = makeBeforeOutput({ path: "/some/file.ts" }) @@ -46,20 +55,20 @@ describe("hashline-edit-diff-enhancer", () => { }) describe("tool.execute.after", () => { - test("injects filediff metadata after edit tool execution", async () => { + test("injects filediff metadata after write tool execution", async () => { // given - a temp file that we can modify between before/after const tmpDir = (await import("os")).tmpdir() const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts` const oldContent = "line 1\nline 2\nline 3\n" await Bun.write(tmpFile, oldContent) - const input = makeInput("edit", "call-diff-1") + const input = makeInput("write", "call-diff-1") const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] }) // when - before hook captures old content await hook["tool.execute.before"](input, beforeOutput) - // when - file is modified (simulating hashline edit execution) + // when - file is modified (simulating write execution) const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n" await Bun.write(tmpFile, newContent) @@ -91,7 +100,7 @@ describe("hashline-edit-diff-enhancer", () => { await Bun.file(tmpFile).exists() && (await import("fs/promises")).unlink(tmpFile) }) - test("does nothing for non-edit tools", async () => { + test("does nothing for non-write tools", async () => { const input = makeInput("read", "call-other") const afterOutput = makeAfterOutput() const originalMetadata = { ...afterOutput.metadata } @@ -104,7 +113,7 @@ describe("hashline-edit-diff-enhancer", () => { test("does nothing when no before capture exists", async () => { // given - no before hook was called for this callID - const input = makeInput("edit", "call-no-before") + const input = makeInput("write", "call-no-before") const afterOutput = makeAfterOutput() const originalMetadata = { ...afterOutput.metadata } @@ -119,7 +128,7 @@ describe("hashline-edit-diff-enhancer", () => { const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts` await Bun.write(tmpFile, "original") - const input = makeInput("edit", "call-cleanup") + const input = makeInput("write", "call-cleanup") await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) await Bun.write(tmpFile, "modified") @@ -141,17 +150,17 @@ describe("hashline-edit-diff-enhancer", () => { const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts` // given - file doesn't exist during before hook - const input = makeInput("edit", "call-create") + const input = makeInput("write", "call-create") await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) - // when - file created during edit + // when - file created during write await Bun.write(tmpFile, "new content\n") const afterOutput = makeAfterOutput() await hook["tool.execute.after"](input, afterOutput) // then - filediff shows creation (before is empty) - const filediff = afterOutput.metadata.filediff as any + const filediff = afterOutput.metadata.filediff as FileDiffMetadata expect(filediff).toBeDefined() expect(filediff.before).toBe("") expect(filediff.after).toMatch(/^1:[a-f0-9]{2}\|new content/) @@ -169,7 +178,7 @@ describe("hashline-edit-diff-enhancer", () => { const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts` await Bun.write(tmpFile, "content") - const input = makeInput("edit", "call-disabled") + const input = makeInput("write", "call-disabled") await disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) await Bun.write(tmpFile, "modified") @@ -230,7 +239,8 @@ describe("hashline-edit-diff-enhancer", () => { await hook["tool.execute.after"](input, afterOutput) //#then - expect((afterOutput.metadata.filediff as any)).toBeDefined() + const filediff = afterOutput.metadata.filediff as FileDiffMetadata | undefined + expect(filediff).toBeDefined() await (await import("fs/promises")).unlink(tmpFile).catch(() => {}) }) @@ -244,7 +254,7 @@ describe("hashline-edit-diff-enhancer", () => { const oldContent = "const x = 1\nconst y = 2\n" await Bun.write(tmpFile, oldContent) - const input = makeInput("edit", "call-hashline-format") + const input = makeInput("write", "call-hashline-format") await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) //#when - file is modified and after hook runs @@ -271,14 +281,14 @@ describe("hashline-edit-diff-enhancer", () => { }) describe("TUI diff support (metadata.diff)", () => { - test("injects unified diff string in metadata.diff for TUI", async () => { + test("injects unified diff string in metadata.diff for write tool TUI", async () => { //#given - a temp file const tmpDir = (await import("os")).tmpdir() const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts` const oldContent = "line 1\nline 2\nline 3\n" await Bun.write(tmpFile, oldContent) - const input = makeInput("edit", "call-tui-diff") + const input = makeInput("write", "call-tui-diff") await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile })) //#when - file is modified diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts index 285fe737b..d41b0447e 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -52,6 +52,14 @@ function transformOutput(output: string): string { return result.join("\n") } +function transformWriteOutput(output: string): string { + if (!output) { + return output + } + const lines = output.split("\n") + return lines.map((line) => (READ_LINE_PATTERN.test(line) ? transformLine(line) : line)).join("\n") +} + export function createHashlineReadEnhancerHook( _ctx: PluginInput, config: HashlineReadEnhancerConfig @@ -70,7 +78,7 @@ export function createHashlineReadEnhancerHook( if (!shouldProcess(config)) { return } - output.output = transformOutput(output.output) + output.output = input.tool.toLowerCase() === "write" ? transformWriteOutput(output.output) : transformOutput(output.output) }, } } diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts index 5d6571ab1..1cd713bc5 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -113,6 +113,26 @@ describe("createHashlineReadEnhancerHook", () => { expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\|const y = 2$/) }) + it("should transform numbered write lines even when header lines come first", async () => { + //#given + const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true)) + const input = { tool: "write", sessionID, callID: "call-1" } + const output = { + title: "Write", + output: ["# Wrote /tmp/demo-edit.txt", "1: This is line one", "2: This is line two"].join("\n"), + metadata: {}, + } + + //#when + await hook["tool.execute.after"](input, output) + + //#then + const lines = output.output.split("\n") + expect(lines[0]).toBe("# Wrote /tmp/demo-edit.txt") + expect(lines[1]).toMatch(/^1:[a-f0-9]{2}\|This is line one$/) + expect(lines[2]).toMatch(/^2:[a-f0-9]{2}\|This is line two$/) + }) + it("should skip non-read tools", async () => { //#given const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true)) diff --git a/src/tools/hashline-edit/diff-utils.ts b/src/tools/hashline-edit/diff-utils.ts new file mode 100644 index 000000000..163d841fd --- /dev/null +++ b/src/tools/hashline-edit/diff-utils.ts @@ -0,0 +1,104 @@ +import { computeLineHash } from "./hash-computation" + +export function toHashlineContent(content: string): string { + if (!content) return content + const lines = content.split("\n") + const lastLine = lines[lines.length - 1] + const hasTrailingNewline = lastLine === "" + const contentLines = hasTrailingNewline ? lines.slice(0, -1) : lines + const hashlined = contentLines.map((line, i) => { + const lineNum = i + 1 + const hash = computeLineHash(lineNum, line) + return `${lineNum}:${hash}|${line}` + }) + return hasTrailingNewline ? hashlined.join("\n") + "\n" : hashlined.join("\n") +} + +export function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + const maxLines = Math.max(oldLines.length, newLines.length) + + let diff = `--- ${filePath}\n+++ ${filePath}\n` + let inHunk = false + let oldStart = 1 + let newStart = 1 + let oldCount = 0 + let newCount = 0 + let hunkLines: string[] = [] + + for (let i = 0; i < maxLines; i++) { + const oldLine = oldLines[i] ?? "" + const newLine = newLines[i] ?? "" + + if (oldLine !== newLine) { + if (!inHunk) { + oldStart = i + 1 + newStart = i + 1 + oldCount = 0 + newCount = 0 + hunkLines = [] + inHunk = true + } + + if (oldLines[i] !== undefined) { + hunkLines.push(`-${oldLine}`) + oldCount++ + } + if (newLines[i] !== undefined) { + hunkLines.push(`+${newLine}`) + newCount++ + } + } else if (inHunk) { + hunkLines.push(` ${oldLine}`) + oldCount++ + newCount++ + + if (hunkLines.length > 6) { + diff += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` + diff += hunkLines.join("\n") + "\n" + inHunk = false + } + } + } + + if (inHunk && hunkLines.length > 0) { + diff += `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n` + diff += hunkLines.join("\n") + "\n" + } + + return diff || `--- ${filePath}\n+++ ${filePath}\n` +} + +export function countLineDiffs(oldContent: string, newContent: string): { additions: number; deletions: number } { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + const oldSet = new Map() + for (const line of oldLines) { + oldSet.set(line, (oldSet.get(line) ?? 0) + 1) + } + + const newSet = new Map() + for (const line of newLines) { + newSet.set(line, (newSet.get(line) ?? 0) + 1) + } + + let deletions = 0 + for (const [line, count] of oldSet) { + const newCount = newSet.get(line) ?? 0 + if (count > newCount) { + deletions += count - newCount + } + } + + let additions = 0 + for (const [line, count] of newSet) { + const oldCount = oldSet.get(line) ?? 0 + if (count > oldCount) { + additions += count - oldCount + } + } + + return { additions, deletions } +} diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts index 518a0df6b..44dfe4097 100644 --- a/src/tools/hashline-edit/tools.test.ts +++ b/src/tools/hashline-edit/tools.test.ts @@ -1,10 +1,36 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test" +import type { ToolContext } from "@opencode-ai/plugin/tool" +import { consumeToolMetadata, clearPendingStore } from "../../features/tool-metadata-store" import { createHashlineEditTool } from "./tools" import * as fs from "node:fs" import * as path from "node:path" import * as os from "node:os" import { computeLineHash } from "./hash-computation" +type MetadataPayload = { + title?: string + metadata?: Record +} + +function createMockContext(overrides?: Partial>): ToolContext { + return { + sessionID: "test", + messageID: "test", + agent: "test", + directory: "/tmp", + worktree: "/tmp", + abort: new AbortController().signal, + metadata: overrides?.metadata ?? mock(() => {}), + ask: async () => {}, + } +} + +type ToolContextWithCallID = ToolContext & { + callID?: string + callId?: string + call_id?: string +} + describe("createHashlineEditTool", () => { let tempDir: string let tool: ReturnType @@ -16,6 +42,7 @@ describe("createHashlineEditTool", () => { afterEach(() => { fs.rmSync(tempDir, { recursive: true, force: true }) + clearPendingStore() }) describe("tool definition", () => { @@ -30,11 +57,11 @@ describe("createHashlineEditTool", () => { expect(tool.description).toContain("replace") }) - it("has path parameter", () => { + it("has filePath parameter", () => { //#given tool is created //#when checking parameters - //#then path parameter exists as required string - expect(tool.args.path).toBeDefined() + //#then filePath exists + expect(tool.args.filePath).toBeDefined() }) it("has edits parameter as array", () => { @@ -53,10 +80,10 @@ describe("createHashlineEditTool", () => { //#when executing tool const result = await tool.execute( { - path: nonExistentPath, + filePath: nonExistentPath, edits: [{ type: "set_line", line: "1:00", text: "new content" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then error is returned @@ -73,10 +100,10 @@ describe("createHashlineEditTool", () => { //#when executing set_line edit const result = await tool.execute( { - path: filePath, + filePath, edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified line2" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then file is modified and diff is returned @@ -94,10 +121,10 @@ describe("createHashlineEditTool", () => { //#when executing insert_after edit const result = await tool.execute( { - path: filePath, + filePath, edits: [{ type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then line is inserted after specified line @@ -115,7 +142,7 @@ describe("createHashlineEditTool", () => { //#when executing replace_lines edit const result = await tool.execute( { - path: filePath, + filePath, edits: [ { type: "replace_lines", @@ -125,7 +152,7 @@ describe("createHashlineEditTool", () => { }, ], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then lines are replaced @@ -141,10 +168,10 @@ describe("createHashlineEditTool", () => { //#when executing replace edit const result = await tool.execute( { - path: filePath, + filePath, edits: [{ type: "replace", old_text: "world", new_text: "universe" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then text is replaced @@ -162,13 +189,13 @@ describe("createHashlineEditTool", () => { //#when executing multiple edits const result = await tool.execute( { - path: filePath, + filePath, edits: [ { type: "set_line", line: `1:${line1Hash}`, text: "new1" }, { type: "set_line", line: `3:${line3Hash}`, text: "new3" }, ], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then both edits are applied @@ -184,10 +211,10 @@ describe("createHashlineEditTool", () => { //#when executing with wrong hash (valid format but wrong value) const result = await tool.execute( { - path: filePath, + filePath, edits: [{ type: "set_line", line: "1:ff", text: "new" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then hash mismatch error is returned @@ -204,10 +231,10 @@ describe("createHashlineEditTool", () => { //#when executing with escaped newline const result = await tool.execute( { - path: filePath, + filePath, edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then newline is unescaped @@ -224,16 +251,164 @@ describe("createHashlineEditTool", () => { //#when executing edit const result = await tool.execute( { - path: filePath, + filePath, edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new content" }], }, - { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + createMockContext() ) //#then result contains success indicator and diff expect(result).toContain("Successfully") expect(result).toContain("old content") expect(result).toContain("new content") + expect(result).toContain("Updated file (LINE:HASH|content)") + expect(result).toMatch(/1:[a-f0-9]{2}\|new content/) + }) + }) + + describe("context.metadata for TUI diff", () => { + it("calls context.metadata with diff and filediff on successful edit", async () => { + //#given file with content and mock context + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2\nline3") + const line2Hash = computeLineHash(2, "line2") + const metadataMock = mock((_: MetadataPayload) => {}) + const ctx = createMockContext({ metadata: metadataMock }) + + //#when executing a successful edit + await tool.execute( + { + filePath, + edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified" }], + }, + ctx + ) + + //#then context.metadata is called with diff string and filediff object + expect(metadataMock).toHaveBeenCalledTimes(1) + const call = metadataMock.mock.calls[0]?.[0] + expect(call).toBeDefined() + if (!call || !call.metadata) { + throw new Error("metadata payload missing") + } + expect(call.title).toBe(filePath) + expect(call.metadata.filePath).toBe(filePath) + expect(call.metadata.path).toBe(filePath) + expect(call.metadata.file).toBe(filePath) + expect(call.metadata.diff).toContain("---") + expect(call.metadata.diff).toContain("+++") + expect(call.metadata.diff).toContain("-line2") + expect(call.metadata.diff).toContain("+modified") + expect(call.metadata.filediff.file).toBe(filePath) + expect(call.metadata.filediff.path).toBe(filePath) + expect(call.metadata.filediff.filePath).toBe(filePath) + expect(typeof call.metadata.filediff.before).toBe("string") + expect(typeof call.metadata.filediff.after).toBe("string") + expect(typeof call.metadata.filediff.additions).toBe("number") + expect(typeof call.metadata.filediff.deletions).toBe("number") + }) + + it("includes hashline content in filediff before/after", async () => { + //#given file with known content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "hello\nworld") + const line1Hash = computeLineHash(1, "hello") + const metadataMock = mock((_: MetadataPayload) => {}) + const ctx = createMockContext({ metadata: metadataMock }) + + //#when executing edit + await tool.execute( + { + filePath, + edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "hi" }], + }, + ctx + ) + + //#then filediff.before contains hashline format of original content + const call = metadataMock.mock.calls[0]?.[0] + expect(call).toBeDefined() + if (!call || !call.metadata) { + throw new Error("metadata payload missing") + } + expect(call.metadata.filediff.before).toContain("1:") + expect(call.metadata.filediff.before).toContain("|hello") + expect(call.metadata.filediff.after).toContain("1:") + expect(call.metadata.filediff.after).toContain("|hi") + }) + + it("reports correct additions and deletions count", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "aaa\nbbb\nccc") + const metadataMock = mock((_: MetadataPayload) => {}) + const ctx = createMockContext({ metadata: metadataMock }) + + //#when replacing text that changes one line + await tool.execute( + { + filePath, + edits: [{ type: "replace", old_text: "bbb", new_text: "xxx" }], + }, + ctx + ) + + //#then additions and deletions are both 1 + const call = metadataMock.mock.calls[0]?.[0] + expect(call).toBeDefined() + if (!call || !call.metadata) { + throw new Error("metadata payload missing") + } + expect(call.metadata.filediff.additions).toBe(1) + expect(call.metadata.filediff.deletions).toBe(1) + }) + + it("does not call context.metadata on error", async () => { + //#given non-existent file + const nonExistentPath = path.join(tempDir, "nope.txt") + const metadataMock = mock(() => {}) + const ctx = createMockContext({ metadata: metadataMock }) + + //#when executing tool on missing file + await tool.execute( + { + filePath: nonExistentPath, + edits: [{ type: "set_line", line: "1:00", text: "new" }], + }, + ctx + ) + + //#then context.metadata is never called + expect(metadataMock).not.toHaveBeenCalled() + }) + + it("stores metadata for tool.execute.after restoration when callID exists", async () => { + //#given file and context with callID + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2") + const line2Hash = computeLineHash(2, "line2") + + const metadataMock = mock((_: MetadataPayload) => {}) + const ctx: ToolContextWithCallID = { + ...createMockContext({ metadata: metadataMock }), + callID: "call-edit-meta-1", + } + + //#when executing edit + await tool.execute( + { + filePath, + edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified" }], + }, + ctx, + ) + + //#then pending metadata store has restorable metadata + const restored = consumeToolMetadata(ctx.sessionID, "call-edit-meta-1") + expect(restored).toBeDefined() + expect(restored?.title).toBe(filePath) + expect(typeof restored?.metadata?.diff).toBe("string") + expect(restored?.metadata?.filediff).toBeDefined() }) }) }) diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index 8c970582e..e2b852f93 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -1,13 +1,28 @@ -import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { storeToolMetadata } from "../../features/tool-metadata-store" import type { HashlineEdit } from "./types" import { applyHashlineEdits } from "./edit-operations" import { computeLineHash } from "./hash-computation" +import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils" interface HashlineEditArgs { - path: string + filePath: string edits: HashlineEdit[] } +type ToolContextWithCallID = ToolContext & { + callID?: string + callId?: string + call_id?: string +} + +function resolveToolCallID(ctx: ToolContextWithCallID): string | undefined { + if (typeof ctx.callID === "string" && ctx.callID.trim() !== "") return ctx.callID + if (typeof ctx.callId === "string" && ctx.callId.trim() !== "") return ctx.callId + if (typeof ctx.call_id === "string" && ctx.call_id.trim() !== "") return ctx.call_id + return undefined +} + function generateDiff(oldContent: string, newContent: string, filePath: string): string { const oldLines = oldContent.split("\n") const newLines = newContent.split("\n") @@ -38,6 +53,17 @@ export function createHashlineEditTool(): ToolDefinition { return tool({ description: `Edit files using LINE:HASH format for precise, safe modifications. +WORKFLOW: +1. Read the file and copy exact LINE:HASH anchors. +2. Submit one edit call with all related operations for that file. +3. If more edits are needed after success, use the latest anchors from read/edit output. +4. Use anchors as "LINE:HASH" only (never include trailing "|content"). + +VALIDATION: +- Payload shape: { "filePath": string, "edits": [...] } +- Each edit must be one of: set_line, replace_lines, insert_after, replace +- text/new_text must contain plain replacement text only (no LINE:HASH prefixes, no diff + markers) + LINE:HASH FORMAT: Each line reference must be in "LINE:HASH" format where: - LINE: 1-based line number @@ -46,6 +72,7 @@ Each line reference must be in "LINE:HASH" format where: GETTING HASHES: Use the read tool - it returns lines in "LINE:HASH|content" format. +Successful edit output also includes updated file content in "LINE:HASH|content" format. FOUR OPERATION TYPES: @@ -53,7 +80,7 @@ FOUR OPERATION TYPES: { "type": "set_line", "line": "5:a3", "text": "const y = 2" } 2. replace_lines: Replace a range of lines - { "type": "replace_lines", "start_line": "5:a3", "end_line": "7:b2", "text": "new\ncontent" } + { "type": "replace_lines", "start_line": "5:a3", "end_line": "7:b2", "text": "new\\ncontent" } 3. insert_after: Insert lines after a specific line { "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" } @@ -64,13 +91,18 @@ FOUR OPERATION TYPES: HASH MISMATCH HANDLING: If the hash doesn't match the current content, the edit fails with a hash mismatch error. This prevents editing stale content. +SEQUENTIAL EDITS (ANTI-FLAKE): +- Always copy anchors exactly from the latest read/edit output. +- Never infer or guess hashes. +- For related edits, prefer batching them in one call. + BOTTOM-UP APPLICATION: Edits are applied from bottom to top (highest line numbers first) to preserve line number references. ESCAPING: Use \\n in text to represent literal newlines.`, args: { - path: tool.schema.string().describe("Absolute path to the file to edit"), + filePath: tool.schema.string().describe("Absolute path to the file to edit"), edits: tool.schema .array( tool.schema.union([ @@ -99,13 +131,10 @@ Use \\n in text to represent literal newlines.`, ) .describe("Array of edit operations to apply"), }, - execute: async (args: HashlineEditArgs) => { + execute: async (args: HashlineEditArgs, context: ToolContext) => { try { - const { path: filePath, edits } = args - - if (!filePath) { - return "Error: path parameter is required" - } + const filePath = args.filePath + const { edits } = args if (!edits || !Array.isArray(edits) || edits.length === 0) { return "Error: edits parameter must be a non-empty array" @@ -123,12 +152,48 @@ Use \\n in text to represent literal newlines.`, await Bun.write(filePath, newContent) const diff = generateDiff(oldContent, newContent, filePath) + const oldHashlined = toHashlineContent(oldContent) + const newHashlined = toHashlineContent(newContent) - return `Successfully applied ${edits.length} edit(s) to ${filePath}\n\n${diff}` + const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath) + const { additions, deletions } = countLineDiffs(oldContent, newContent) + + const meta = { + title: filePath, + metadata: { + filePath, + path: filePath, + file: filePath, + diff: unifiedDiff, + filediff: { + file: filePath, + path: filePath, + filePath, + before: oldHashlined, + after: newHashlined, + additions, + deletions, + }, + }, + } + + context.metadata(meta) + + const callID = resolveToolCallID(context) + if (callID) { + storeToolMetadata(context.sessionID, callID, meta) + } + + return `Successfully applied ${edits.length} edit(s) to ${filePath} + +${diff} + +Updated file (LINE:HASH|content): +${newHashlined}` } catch (error) { const message = error instanceof Error ? error.message : String(error) if (message.includes("hash")) { - return `Error: Hash mismatch - ${message}` + return `Error: Hash mismatch - ${message}\nTip: reuse LINE:HASH entries from the latest read/edit output, or batch related edits in one call.` } return `Error: ${message}` }