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 52029a0d42
commit 6869a2c0b5
7 changed files with 443 additions and 167 deletions
+104
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 }
}
+197 -22
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 * 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<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", () => {
let tempDir: string
let tool: ReturnType<typeof createHashlineEditTool>
@@ -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()
})
})
})
+77 -12
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 { 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}`
}