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:
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user