feat(hashline): port hashline edit tool from oh-my-pi

This PR ports the hashline edit tool from oh-my-pi to oh-my-opencode as an experimental feature.

## Features
- New experimental.hashline_edit config flag
- hashline_edit tool with 4 operations: set_line, replace_lines, insert_after, replace
- Hash-based line anchors for safe concurrent editing
- Edit tool disabler for non-OpenAI providers
- Read output enhancer with LINE:HASH prefixes
- Provider state tracking module

## Technical Details
- xxHash32-based 2-char hex hashes
- Bottom-up edit application to prevent index shifting
- OpenAI provider exemption (uses native apply_patch)
- 90 tests covering all operations and edge cases
- All files under 200 LOC limit

## Files Added/Modified
- src/tools/hashline-edit/ (7 files, ~400 LOC)
- src/hooks/hashline-edit-disabler/ (4 files, ~200 LOC)
- src/hooks/hashline-read-enhancer/ (3 files, ~400 LOC)
- src/features/hashline-provider-state.ts (13 LOC)
- src/config/schema/experimental.ts (hashline_edit flag)
- src/config/schema/hooks.ts (2 new hook names)
- src/plugin/tool-registry.ts (conditional registration)
- src/plugin/chat-params.ts (provider state tracking)
- src/tools/index.ts (export)
- src/hooks/index.ts (exports)
This commit is contained in:
YeonGyu-Kim
2026-02-16 16:32:33 +09:00
parent 149de9da66
commit 51dde4d43f
26 changed files with 1509 additions and 27 deletions
@@ -1,6 +1,7 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../../config"
import * as originalDeduplicationRecovery from "./deduplication-recovery"
const attemptDeduplicationRecoveryMock = mock(async () => {})
@@ -8,6 +9,10 @@ mock.module("./deduplication-recovery", () => ({
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
}))
afterAll(() => {
mock.module("./deduplication-recovery", () => originalDeduplicationRecovery)
})
function createImmediateTimeouts(): () => void {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
@@ -1,4 +1,4 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, afterAll } from "bun:test"
import { truncateUntilTargetTokens } from "./storage"
import * as storage from "./storage"
@@ -11,6 +11,10 @@ mock.module("./storage", () => {
}
})
afterAll(() => {
mock.module("./storage", () => storage)
})
describe("truncateUntilTargetTokens", () => {
const sessionID = "test-session"
@@ -1,5 +1,7 @@
import { describe, expect, it, mock } from "bun:test"
import { describe, expect, it, afterAll, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { createOpencodeClient } from "@opencode-ai/sdk"
import type { Todo } from "@opencode-ai/sdk"
import { createCompactionTodoPreserverHook } from "./index"
const updateMock = mock(async () => {})
@@ -10,27 +12,37 @@ mock.module("opencode/session/todo", () => ({
},
}))
type TodoSnapshot = {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
}
function createMockContext(todoResponses: TodoSnapshot[][]): PluginInput {
let callIndex = 0
return {
client: {
session: {
todo: async () => {
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
callIndex += 1
return { data: current }
},
},
afterAll(() => {
mock.module("opencode/session/todo", () => ({
Todo: {
update: async () => {},
},
}))
})
function createMockContext(todoResponses: Array<Todo>[]): PluginInput {
let callIndex = 0
const client = createOpencodeClient({ directory: "/tmp/test" })
type SessionTodoOptions = Parameters<typeof client.session.todo>[0]
type SessionTodoResult = ReturnType<typeof client.session.todo>
const request = new Request("http://localhost")
const response = new Response()
client.session.todo = mock((_: SessionTodoOptions): SessionTodoResult => {
const current = todoResponses[Math.min(callIndex, todoResponses.length - 1)] ?? []
callIndex += 1
return Promise.resolve({ data: current, error: undefined, request, response })
})
return {
client,
project: { id: "test-project", worktree: "/tmp/test", time: { created: Date.now() } },
directory: "/tmp/test",
} as PluginInput
worktree: "/tmp/test",
serverUrl: new URL("http://localhost"),
$: Bun.$,
}
}
describe("compaction-todo-preserver", () => {
@@ -38,7 +50,7 @@ describe("compaction-todo-preserver", () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-missing"
const todos = [
const todos: Todo[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "in_progress", priority: "medium" },
]
@@ -58,7 +70,7 @@ describe("compaction-todo-preserver", () => {
//#given
updateMock.mockClear()
const sessionID = "session-compaction-present"
const todos = [
const todos: Todo[] = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
]
const ctx = createMockContext([todos, todos])
@@ -0,0 +1,3 @@
export const HOOK_NAME = "hashline-edit-disabler"
export const EDIT_DISABLED_MESSAGE = `The 'edit' tool is disabled. Use 'hashline_edit' tool instead. Read the file first to get LINE:HASH anchors, then use hashline_edit with set_line, replace_lines, or insert_after operations.`
+37
View File
@@ -0,0 +1,37 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { getProvider } from "../../features/hashline-provider-state"
import { EDIT_DISABLED_MESSAGE } from "./constants"
export interface HashlineEditDisablerConfig {
experimental?: {
hashline_edit?: boolean
}
}
export function createHashlineEditDisablerHook(
config: HashlineEditDisablerConfig,
): Hooks {
const isHashlineEnabled = config.experimental?.hashline_edit ?? false
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string },
) => {
if (!isHashlineEnabled) {
return
}
const toolName = input.tool.toLowerCase()
if (toolName !== "edit") {
return
}
const providerID = getProvider(input.sessionID)
if (providerID === "openai") {
return
}
throw new Error(EDIT_DISABLED_MESSAGE)
},
}
}
@@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { createHashlineEditDisablerHook } from "./index"
import { setProvider, clearProvider } from "../../features/hashline-provider-state"
describe("hashline-edit-disabler hook", () => {
const sessionID = "test-session-123"
beforeEach(() => {
clearProvider(sessionID)
})
afterEach(() => {
clearProvider(sessionID)
})
it("blocks edit tool when hashline enabled + non-OpenAI provider", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineEditDisablerHook({
experimental: { hashline_edit: true },
})
const input = { tool: "edit", sessionID }
const output = { args: {} }
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
await expect(executeBeforeHandler(input, output)).rejects.toThrow(
/hashline_edit/,
)
})
it("passes through edit tool when hashline disabled", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineEditDisablerHook({
experimental: { hashline_edit: false },
})
const input = { tool: "edit", sessionID }
const output = { args: {} }
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
const result = await executeBeforeHandler(input, output)
expect(result).toBeUndefined()
})
it("passes through edit tool when OpenAI provider (even if hashline enabled)", async () => {
//#given
setProvider(sessionID, "openai")
const hook = createHashlineEditDisablerHook({
experimental: { hashline_edit: true },
})
const input = { tool: "edit", sessionID }
const output = { args: {} }
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
const result = await executeBeforeHandler(input, output)
expect(result).toBeUndefined()
})
it("passes through non-edit tools", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineEditDisablerHook({
experimental: { hashline_edit: true },
})
const input = { tool: "write", sessionID }
const output = { args: {} }
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
const result = await executeBeforeHandler(input, output)
expect(result).toBeUndefined()
})
it("blocks case-insensitive edit tool names", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineEditDisablerHook({
experimental: { hashline_edit: true },
})
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
for (const toolName of ["Edit", "EDIT", "edit", "EdIt"]) {
const input = { tool: toolName, sessionID }
const output = { args: {} }
await expect(executeBeforeHandler(input, output)).rejects.toThrow(
/hashline_edit/,
)
}
})
it("passes through when hashline config is undefined", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineEditDisablerHook({
experimental: {},
})
const input = { tool: "edit", sessionID }
const output = { args: {} }
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
const result = await executeBeforeHandler(input, output)
expect(result).toBeUndefined()
})
it("error message includes hashline_edit tool guidance", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineEditDisablerHook({
experimental: { hashline_edit: true },
})
const input = { tool: "edit", sessionID }
const output = { args: {} }
//#when
const executeBeforeHandler = hook["tool.execute.before"]
if (!executeBeforeHandler) {
throw new Error("tool.execute.before handler not found")
}
//#then
try {
await executeBeforeHandler(input, output)
throw new Error("Expected error to be thrown")
} catch (error) {
if (error instanceof Error) {
expect(error.message).toContain("hashline_edit")
expect(error.message).toContain("set_line")
expect(error.message).toContain("replace_lines")
expect(error.message).toContain("insert_after")
}
}
})
})
@@ -0,0 +1,2 @@
export { createHashlineEditDisablerHook } from "./hook"
export { HOOK_NAME, EDIT_DISABLED_MESSAGE } from "./constants"
+74
View File
@@ -0,0 +1,74 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getProvider } from "../../features/hashline-provider-state"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
interface HashlineReadEnhancerConfig {
hashline_edit?: { enabled: boolean }
}
const READ_LINE_PATTERN = /^(\d+): (.*)$/
function isReadTool(toolName: string): boolean {
return toolName.toLowerCase() === "read"
}
function shouldProcess(sessionID: string, config: HashlineReadEnhancerConfig): boolean {
if (!config.hashline_edit?.enabled) {
return false
}
const providerID = getProvider(sessionID)
if (providerID === "openai") {
return false
}
return true
}
function isTextFile(output: string): boolean {
const firstLine = output.split("\n")[0] ?? ""
return READ_LINE_PATTERN.test(firstLine)
}
function transformLine(line: string): string {
const match = READ_LINE_PATTERN.exec(line)
if (!match) {
return line
}
const lineNumber = parseInt(match[1], 10)
const content = match[2]
const hash = computeLineHash(lineNumber, content)
return `${lineNumber}:${hash}|${content}`
}
function transformOutput(output: string): string {
if (!output) {
return output
}
if (!isTextFile(output)) {
return output
}
const lines = output.split("\n")
return lines.map(transformLine).join("\n")
}
export function createHashlineReadEnhancerHook(
_ctx: PluginInput,
config: HashlineReadEnhancerConfig
) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (!isReadTool(input.tool)) {
return
}
if (typeof output.output !== "string") {
return
}
if (!shouldProcess(input.sessionID, config)) {
return
}
output.output = transformOutput(output.output)
},
}
}
@@ -0,0 +1,299 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { createHashlineReadEnhancerHook } from "./hook"
import type { PluginInput } from "@opencode-ai/plugin"
import { setProvider, clearProvider } from "../../features/hashline-provider-state"
//#given - Test setup helpers
function createMockContext(): PluginInput {
return {
client: {} as unknown as PluginInput["client"],
directory: "/test",
}
}
interface TestConfig {
hashline_edit?: { enabled: boolean }
}
function createMockConfig(enabled: boolean): TestConfig {
return {
hashline_edit: { enabled },
}
}
describe("createHashlineReadEnhancerHook", () => {
let mockCtx: PluginInput
const sessionID = "test-session-123"
beforeEach(() => {
mockCtx = createMockContext()
clearProvider(sessionID)
})
afterEach(() => {
clearProvider(sessionID)
})
describe("tool name matching", () => {
it("should process 'read' tool (lowercase)", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("1:")
expect(output.output).toContain("|")
})
it("should process 'Read' tool (mixed case)", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "Read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("|")
})
it("should process 'READ' tool (uppercase)", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "READ", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("|")
})
it("should skip non-read tools", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "edit", sessionID, callID: "call-1" }
const originalOutput = "1: hello\n2: world"
const output = { title: "Edit", output: originalOutput, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe(originalOutput)
})
})
describe("config flag check", () => {
it("should skip when hashline_edit is disabled", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(false))
const input = { tool: "read", sessionID, callID: "call-1" }
const originalOutput = "1: hello\n2: world"
const output = { title: "Read", output: originalOutput, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe(originalOutput)
})
it("should skip when hashline_edit config is missing", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, {})
const input = { tool: "read", sessionID, callID: "call-1" }
const originalOutput = "1: hello\n2: world"
const output = { title: "Read", output: originalOutput, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe(originalOutput)
})
})
describe("provider check", () => {
it("should skip when provider is OpenAI", async () => {
//#given
setProvider(sessionID, "openai")
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const originalOutput = "1: hello\n2: world"
const output = { title: "Read", output: originalOutput, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe(originalOutput)
})
it("should process when provider is Claude", async () => {
//#given
setProvider(sessionID, "anthropic")
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("|")
})
it("should process when provider is unknown (undefined)", async () => {
//#given
// Provider not set, getProvider returns undefined
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: hello\n2: world", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("|")
})
})
describe("output transformation", () => {
it("should transform 'N: content' format to 'N:HASH|content'", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: function hello() {\n2: console.log('world')\n3: }", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[0]).toMatch(/^1:[a-f0-9]{2}\|function hello\(\) \{$/)
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\| console\.log\('world'\)$/)
expect(lines[2]).toMatch(/^3:[a-f0-9]{2}\|\}$/)
})
it("should handle empty output", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe("")
})
it("should handle single line", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: const x = 1", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toMatch(/^1:[a-f0-9]{2}\|const x = 1$/)
})
})
describe("binary file detection", () => {
it("should skip binary files (no line number prefix)", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const originalOutput = "PNG\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"
const output = { title: "Read", output: originalOutput, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe(originalOutput)
})
it("should skip if first line doesn't match pattern", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const originalOutput = "some binary data\nmore data"
const output = { title: "Read", output: originalOutput, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBe(originalOutput)
})
it("should process if first line matches 'N: ' pattern", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: valid line\n2: another line", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("|")
})
})
describe("edge cases", () => {
it("should handle non-string output gracefully", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: null as unknown as string, metadata: {} }
//#when - should not throw
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toBeNull()
})
it("should handle lines with no content after colon", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: "1: hello\n2: \n3: world", metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[0]).toMatch(/^1:[a-f0-9]{2}\|hello$/)
expect(lines[1]).toMatch(/^2:[a-f0-9]{2}\|$/)
expect(lines[2]).toMatch(/^3:[a-f0-9]{2}\|world$/)
})
it("should handle very long lines", async () => {
//#given
const longContent = "a".repeat(1000)
const hook = createHashlineReadEnhancerHook(mockCtx, createMockConfig(true))
const input = { tool: "read", sessionID, callID: "call-1" }
const output = { title: "Read", output: `1: ${longContent}`, metadata: {} }
//#when
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toMatch(/^1:[a-f0-9]{2}\|a+$/)
})
})
})
@@ -0,0 +1 @@
export { createHashlineReadEnhancerHook } from "./hook"
+2
View File
@@ -43,3 +43,5 @@ export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineEditDisablerHook } from "./hashline-edit-disabler";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";