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:
parent
e14a4cfc77
commit
3adade46e3
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
104
src/tools/hashline-edit/diff-utils.ts
Normal file
104
src/tools/hashline-edit/diff-utils.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user