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.
This commit is contained in:
YeonGyu-Kim 2026-02-19 17:09:46 +09:00
parent e14a4cfc77
commit 3adade46e3
7 changed files with 443 additions and 167 deletions

View File

@ -1,5 +1,5 @@
import { log } from "../../shared" import { log } from "../../shared"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation" import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "../../tools/hashline-edit/diff-utils"
interface HashlineEditDiffEnhancerConfig { interface HashlineEditDiffEnhancerConfig {
hashline_edit?: { enabled: boolean } hashline_edit?: { enabled: boolean }
@ -27,9 +27,8 @@ function cleanupStaleEntries(): void {
} }
} }
function isEditOrWriteTool(toolName: string): boolean { function isWriteTool(toolName: string): boolean {
const lower = toolName.toLowerCase() return toolName.toLowerCase() === "write"
return lower === "edit" || lower === "write"
} }
function extractFilePath(args: Record<string, unknown>): string | undefined { function extractFilePath(args: Record<string, unknown>): string | undefined {
@ -37,113 +36,6 @@ function extractFilePath(args: Record<string, unknown>): string | undefined {
return typeof path === "string" ? path : 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<string, number>()
for (const line of oldLines) {
oldSet.set(line, (oldSet.get(line) ?? 0) + 1)
}
const newSet = new Map<string, number>()
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<string> { async function captureOldContent(filePath: string): Promise<string> {
try { try {
const file = Bun.file(filePath) const file = Bun.file(filePath)
@ -161,7 +53,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
return { return {
"tool.execute.before": async (input: BeforeInput, output: BeforeOutput) => { "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) const filePath = extractFilePath(output.args)
if (!filePath) return if (!filePath) return
@ -176,7 +68,7 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
}, },
"tool.execute.after": async (input: AfterInput, output: AfterOutput) => { "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 key = makeKey(input.sessionID, input.callID)
const captured = pendingCaptures.get(key) const captured = pendingCaptures.get(key)
@ -194,14 +86,16 @@ export function createHashlineEditDiffEnhancerHook(config: HashlineEditDiffEnhan
} }
const { additions, deletions } = countLineDiffs(oldContent, newContent) const { additions, deletions } = countLineDiffs(oldContent, newContent)
const oldHashlined = toHashlineContent(oldContent)
const newHashlined = toHashlineContent(newContent)
const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath) const unifiedDiff = generateUnifiedDiff(oldContent, newContent, filePath)
output.metadata.filediff = { output.metadata.filediff = {
file: filePath, file: filePath,
path: filePath, path: filePath,
before: toHashlineContent(oldContent), before: oldHashlined,
after: toHashlineContent(newContent), after: newHashlined,
additions, additions,
deletions, deletions,
} }

View File

@ -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", () => { describe("hashline-edit-diff-enhancer", () => {
let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook> let hook: ReturnType<typeof createHashlineEditDiffEnhancerHook>
@ -25,9 +34,9 @@ describe("hashline-edit-diff-enhancer", () => {
}) })
describe("tool.execute.before", () => { 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 filePath = import.meta.dir + "/index.test.ts"
const input = makeInput("edit") const input = makeInput("write")
const output = makeBeforeOutput({ path: filePath, edits: [] }) const output = makeBeforeOutput({ path: filePath, edits: [] })
await hook["tool.execute.before"](input, output) 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 // 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 input = makeInput("read")
const output = makeBeforeOutput({ path: "/some/file.ts" }) const output = makeBeforeOutput({ path: "/some/file.ts" })
@ -46,20 +55,20 @@ describe("hashline-edit-diff-enhancer", () => {
}) })
describe("tool.execute.after", () => { 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 // given - a temp file that we can modify between before/after
const tmpDir = (await import("os")).tmpdir() const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts` const tmpFile = `${tmpDir}/hashline-diff-test-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n" const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent) 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: [] }) const beforeOutput = makeBeforeOutput({ path: tmpFile, edits: [] })
// when - before hook captures old content // when - before hook captures old content
await hook["tool.execute.before"](input, beforeOutput) 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" const newContent = "line 1\nmodified line 2\nline 3\nnew line 4\n"
await Bun.write(tmpFile, newContent) 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) 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 input = makeInput("read", "call-other")
const afterOutput = makeAfterOutput() const afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata } const originalMetadata = { ...afterOutput.metadata }
@ -104,7 +113,7 @@ describe("hashline-edit-diff-enhancer", () => {
test("does nothing when no before capture exists", async () => { test("does nothing when no before capture exists", async () => {
// given - no before hook was called for this callID // 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 afterOutput = makeAfterOutput()
const originalMetadata = { ...afterOutput.metadata } const originalMetadata = { ...afterOutput.metadata }
@ -119,7 +128,7 @@ describe("hashline-edit-diff-enhancer", () => {
const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts` const tmpFile = `${tmpDir}/hashline-diff-cleanup-${Date.now()}.ts`
await Bun.write(tmpFile, "original") 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 hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified") await Bun.write(tmpFile, "modified")
@ -141,17 +150,17 @@ describe("hashline-edit-diff-enhancer", () => {
const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts` const tmpFile = `${tmpDir}/hashline-diff-create-${Date.now()}.ts`
// given - file doesn't exist during before hook // 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 })) 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") await Bun.write(tmpFile, "new content\n")
const afterOutput = makeAfterOutput() const afterOutput = makeAfterOutput()
await hook["tool.execute.after"](input, afterOutput) await hook["tool.execute.after"](input, afterOutput)
// then - filediff shows creation (before is empty) // 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).toBeDefined()
expect(filediff.before).toBe("") expect(filediff.before).toBe("")
expect(filediff.after).toMatch(/^1:[a-f0-9]{2}\|new content/) 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` const tmpFile = `${tmpDir}/hashline-diff-disabled-${Date.now()}.ts`
await Bun.write(tmpFile, "content") 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 disabledHook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
await Bun.write(tmpFile, "modified") await Bun.write(tmpFile, "modified")
@ -230,7 +239,8 @@ describe("hashline-edit-diff-enhancer", () => {
await hook["tool.execute.after"](input, afterOutput) await hook["tool.execute.after"](input, afterOutput)
//#then //#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(() => {}) 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" const oldContent = "const x = 1\nconst y = 2\n"
await Bun.write(tmpFile, oldContent) 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 })) await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified and after hook runs //#when - file is modified and after hook runs
@ -271,14 +281,14 @@ describe("hashline-edit-diff-enhancer", () => {
}) })
describe("TUI diff support (metadata.diff)", () => { 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 //#given - a temp file
const tmpDir = (await import("os")).tmpdir() const tmpDir = (await import("os")).tmpdir()
const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts` const tmpFile = `${tmpDir}/hashline-tui-diff-${Date.now()}.ts`
const oldContent = "line 1\nline 2\nline 3\n" const oldContent = "line 1\nline 2\nline 3\n"
await Bun.write(tmpFile, oldContent) 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 })) await hook["tool.execute.before"](input, makeBeforeOutput({ path: tmpFile }))
//#when - file is modified //#when - file is modified

View File

@ -52,6 +52,14 @@ function transformOutput(output: string): string {
return result.join("\n") 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( export function createHashlineReadEnhancerHook(
_ctx: PluginInput, _ctx: PluginInput,
config: HashlineReadEnhancerConfig config: HashlineReadEnhancerConfig
@ -70,7 +78,7 @@ export function createHashlineReadEnhancerHook(
if (!shouldProcess(config)) { if (!shouldProcess(config)) {
return return
} }
output.output = transformOutput(output.output) output.output = input.tool.toLowerCase() === "write" ? transformWriteOutput(output.output) : transformOutput(output.output)
}, },
} }
} }

View File

@ -113,6 +113,26 @@ describe("createHashlineReadEnhancerHook", () => {
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\|const y = 2$/) 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 () => { it("should skip non-read tools", async () => {
//#given //#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true)) const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))

View File

@ -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<string, number>()
for (const line of oldLines) {
oldSet.set(line, (oldSet.get(line) ?? 0) + 1)
}
const newSet = new Map<string, number>()
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 }
}

View File

@ -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 { createHashlineEditTool } from "./tools"
import * as fs from "node:fs" import * as fs from "node:fs"
import * as path from "node:path" import * as path from "node:path"
import * as os from "node:os" import * as os from "node:os"
import { computeLineHash } from "./hash-computation" import { computeLineHash } from "./hash-computation"
type MetadataPayload = {
title?: string
metadata?: Record<string, unknown>
}
function createMockContext(overrides?: Partial<Pick<ToolContext, "metadata">>): 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", () => { describe("createHashlineEditTool", () => {
let tempDir: string let tempDir: string
let tool: ReturnType<typeof createHashlineEditTool> let tool: ReturnType<typeof createHashlineEditTool>
@ -16,6 +42,7 @@ describe("createHashlineEditTool", () => {
afterEach(() => { afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true }) fs.rmSync(tempDir, { recursive: true, force: true })
clearPendingStore()
}) })
describe("tool definition", () => { describe("tool definition", () => {
@ -30,11 +57,11 @@ describe("createHashlineEditTool", () => {
expect(tool.description).toContain("replace") expect(tool.description).toContain("replace")
}) })
it("has path parameter", () => { it("has filePath parameter", () => {
//#given tool is created //#given tool is created
//#when checking parameters //#when checking parameters
//#then path parameter exists as required string //#then filePath exists
expect(tool.args.path).toBeDefined() expect(tool.args.filePath).toBeDefined()
}) })
it("has edits parameter as array", () => { it("has edits parameter as array", () => {
@ -53,10 +80,10 @@ describe("createHashlineEditTool", () => {
//#when executing tool //#when executing tool
const result = await tool.execute( const result = await tool.execute(
{ {
path: nonExistentPath, filePath: nonExistentPath,
edits: [{ type: "set_line", line: "1:00", text: "new content" }], edits: [{ type: "set_line", line: "1:00", text: "new content" }],
}, },
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } createMockContext()
) )
//#then error is returned //#then error is returned
@ -73,10 +100,10 @@ describe("createHashlineEditTool", () => {
//#when executing set_line edit //#when executing set_line edit
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified line2" }], 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 //#then file is modified and diff is returned
@ -94,10 +121,10 @@ describe("createHashlineEditTool", () => {
//#when executing insert_after edit //#when executing insert_after edit
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [{ type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }], 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 //#then line is inserted after specified line
@ -115,7 +142,7 @@ describe("createHashlineEditTool", () => {
//#when executing replace_lines edit //#when executing replace_lines edit
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [ edits: [
{ {
type: "replace_lines", type: "replace_lines",
@ -125,7 +152,7 @@ describe("createHashlineEditTool", () => {
}, },
], ],
}, },
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } createMockContext()
) )
//#then lines are replaced //#then lines are replaced
@ -141,10 +168,10 @@ describe("createHashlineEditTool", () => {
//#when executing replace edit //#when executing replace edit
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [{ type: "replace", old_text: "world", new_text: "universe" }], edits: [{ type: "replace", old_text: "world", new_text: "universe" }],
}, },
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } createMockContext()
) )
//#then text is replaced //#then text is replaced
@ -162,13 +189,13 @@ describe("createHashlineEditTool", () => {
//#when executing multiple edits //#when executing multiple edits
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [ edits: [
{ type: "set_line", line: `1:${line1Hash}`, text: "new1" }, { type: "set_line", line: `1:${line1Hash}`, text: "new1" },
{ type: "set_line", line: `3:${line3Hash}`, text: "new3" }, { type: "set_line", line: `3:${line3Hash}`, text: "new3" },
], ],
}, },
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } createMockContext()
) )
//#then both edits are applied //#then both edits are applied
@ -184,10 +211,10 @@ describe("createHashlineEditTool", () => {
//#when executing with wrong hash (valid format but wrong value) //#when executing with wrong hash (valid format but wrong value)
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [{ type: "set_line", line: "1:ff", text: "new" }], 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 //#then hash mismatch error is returned
@ -204,10 +231,10 @@ describe("createHashlineEditTool", () => {
//#when executing with escaped newline //#when executing with escaped newline
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }], edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }],
}, },
{ sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } createMockContext()
) )
//#then newline is unescaped //#then newline is unescaped
@ -224,16 +251,164 @@ describe("createHashlineEditTool", () => {
//#when executing edit //#when executing edit
const result = await tool.execute( const result = await tool.execute(
{ {
path: filePath, filePath,
edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new content" }], 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 //#then result contains success indicator and diff
expect(result).toContain("Successfully") expect(result).toContain("Successfully")
expect(result).toContain("old content") expect(result).toContain("old content")
expect(result).toContain("new 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()
}) })
}) })
}) })

View File

@ -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 type { HashlineEdit } from "./types"
import { applyHashlineEdits } from "./edit-operations" import { applyHashlineEdits } from "./edit-operations"
import { computeLineHash } from "./hash-computation" import { computeLineHash } from "./hash-computation"
import { toHashlineContent, generateUnifiedDiff, countLineDiffs } from "./diff-utils"
interface HashlineEditArgs { interface HashlineEditArgs {
path: string filePath: string
edits: HashlineEdit[] 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 { function generateDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n") const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n") const newLines = newContent.split("\n")
@ -38,6 +53,17 @@ export function createHashlineEditTool(): ToolDefinition {
return tool({ return tool({
description: `Edit files using LINE:HASH format for precise, safe modifications. 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: LINE:HASH FORMAT:
Each line reference must be in "LINE:HASH" format where: Each line reference must be in "LINE:HASH" format where:
- LINE: 1-based line number - LINE: 1-based line number
@ -46,6 +72,7 @@ Each line reference must be in "LINE:HASH" format where:
GETTING HASHES: GETTING HASHES:
Use the read tool - it returns lines in "LINE:HASH|content" format. 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: FOUR OPERATION TYPES:
@ -53,7 +80,7 @@ FOUR OPERATION TYPES:
{ "type": "set_line", "line": "5:a3", "text": "const y = 2" } { "type": "set_line", "line": "5:a3", "text": "const y = 2" }
2. replace_lines: Replace a range of lines 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 3. insert_after: Insert lines after a specific line
{ "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" } { "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" }
@ -64,13 +91,18 @@ FOUR OPERATION TYPES:
HASH MISMATCH HANDLING: 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. 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: BOTTOM-UP APPLICATION:
Edits are applied from bottom to top (highest line numbers first) to preserve line number references. Edits are applied from bottom to top (highest line numbers first) to preserve line number references.
ESCAPING: ESCAPING:
Use \\n in text to represent literal newlines.`, Use \\n in text to represent literal newlines.`,
args: { 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 edits: tool.schema
.array( .array(
tool.schema.union([ tool.schema.union([
@ -99,13 +131,10 @@ Use \\n in text to represent literal newlines.`,
) )
.describe("Array of edit operations to apply"), .describe("Array of edit operations to apply"),
}, },
execute: async (args: HashlineEditArgs) => { execute: async (args: HashlineEditArgs, context: ToolContext) => {
try { try {
const { path: filePath, edits } = args const filePath = args.filePath
const { edits } = args
if (!filePath) {
return "Error: path parameter is required"
}
if (!edits || !Array.isArray(edits) || edits.length === 0) { if (!edits || !Array.isArray(edits) || edits.length === 0) {
return "Error: edits parameter must be a non-empty array" 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) await Bun.write(filePath, newContent)
const diff = generateDiff(oldContent, newContent, filePath) 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) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
if (message.includes("hash")) { 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}` return `Error: ${message}`
} }