diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 052956403..b3ab6ce21 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -98,7 +98,9 @@ "stop-continuation-guard", "tasks-todowrite-disabler", "write-existing-file-guard", - "anthropic-effort" + "anthropic-effort", + "hashline-edit-disabler", + "hashline-read-enhancer" ] } }, diff --git a/src/cli/run/integration.test.ts b/src/cli/run/integration.test.ts index 1cbfa0847..6aa4fc8d0 100644 --- a/src/cli/run/integration.test.ts +++ b/src/cli/run/integration.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect, mock, spyOn, beforeEach, afterEach } from "bun:test" +import { describe, it, expect, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test" import type { RunResult } from "./types" import { createJsonOutputManager } from "./json-output" import { resolveSession } from "./session-resolver" import { executeOnCompleteHook } from "./on-complete-hook" import type { OpencodeClient } from "./types" +import * as originalSdk from "@opencode-ai/sdk" +import * as originalPortUtils from "../../shared/port-utils" const mockServerClose = mock(() => {}) const mockCreateOpencode = mock(() => @@ -27,6 +29,11 @@ mock.module("../../shared/port-utils", () => ({ DEFAULT_SERVER_PORT: 4096, })) +afterAll(() => { + mock.module("@opencode-ai/sdk", () => originalSdk) + mock.module("../../shared/port-utils", () => originalPortUtils) +}) + const { createServerConnection } = await import("./server-connection") interface MockWriteStream { diff --git a/src/cli/run/server-connection.test.ts b/src/cli/run/server-connection.test.ts index 100154a0e..9dc94587e 100644 --- a/src/cli/run/server-connection.test.ts +++ b/src/cli/run/server-connection.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" +import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test" + +import * as originalSdk from "@opencode-ai/sdk" +import * as originalPortUtils from "../../shared/port-utils" const originalConsole = globalThis.console @@ -25,6 +28,11 @@ mock.module("../../shared/port-utils", () => ({ DEFAULT_SERVER_PORT: 4096, })) +afterAll(() => { + mock.module("@opencode-ai/sdk", () => originalSdk) + mock.module("../../shared/port-utils", () => originalPortUtils) +}) + const { createServerConnection } = await import("./server-connection") describe("createServerConnection", () => { diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index add671887..a4461e2c3 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -45,6 +45,8 @@ export const HookNameSchema = z.enum([ "tasks-todowrite-disabler", "write-existing-file-guard", "anthropic-effort", + "hashline-edit-disabler", + "hashline-read-enhancer", ]) export type HookName = z.infer diff --git a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts index 65db7298e..d7541139c 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/recovery-deduplication.test.ts @@ -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 diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts index ffe1fabc5..407fc64bf 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage.test.ts @@ -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" diff --git a/src/hooks/compaction-todo-preserver/index.test.ts b/src/hooks/compaction-todo-preserver/index.test.ts index 04cc577a8..0bc784e2c 100644 --- a/src/hooks/compaction-todo-preserver/index.test.ts +++ b/src/hooks/compaction-todo-preserver/index.test.ts @@ -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[]): PluginInput { + let callIndex = 0 + + const client = createOpencodeClient({ directory: "/tmp/test" }) + type SessionTodoOptions = Parameters[0] + type SessionTodoResult = ReturnType + + 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]) diff --git a/src/hooks/hashline-edit-disabler/constants.ts b/src/hooks/hashline-edit-disabler/constants.ts new file mode 100644 index 000000000..a60019650 --- /dev/null +++ b/src/hooks/hashline-edit-disabler/constants.ts @@ -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.` diff --git a/src/hooks/hashline-edit-disabler/hook.ts b/src/hooks/hashline-edit-disabler/hook.ts new file mode 100644 index 000000000..aa49e26a4 --- /dev/null +++ b/src/hooks/hashline-edit-disabler/hook.ts @@ -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) + }, + } +} diff --git a/src/hooks/hashline-edit-disabler/index.test.ts b/src/hooks/hashline-edit-disabler/index.test.ts new file mode 100644 index 000000000..2112497d1 --- /dev/null +++ b/src/hooks/hashline-edit-disabler/index.test.ts @@ -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") + } + } + }) +}) diff --git a/src/hooks/hashline-edit-disabler/index.ts b/src/hooks/hashline-edit-disabler/index.ts new file mode 100644 index 000000000..7bc6f96ea --- /dev/null +++ b/src/hooks/hashline-edit-disabler/index.ts @@ -0,0 +1,2 @@ +export { createHashlineEditDisablerHook } from "./hook" +export { HOOK_NAME, EDIT_DISABLED_MESSAGE } from "./constants" diff --git a/src/hooks/hashline-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts new file mode 100644 index 000000000..5aefbf31a --- /dev/null +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -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) + }, + } +} diff --git a/src/hooks/hashline-read-enhancer/index.test.ts b/src/hooks/hashline-read-enhancer/index.test.ts new file mode 100644 index 000000000..243e00970 --- /dev/null +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -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+$/) + }) + }) +}) diff --git a/src/hooks/hashline-read-enhancer/index.ts b/src/hooks/hashline-read-enhancer/index.ts new file mode 100644 index 000000000..d0aaa82db --- /dev/null +++ b/src/hooks/hashline-read-enhancer/index.ts @@ -0,0 +1 @@ +export { createHashlineReadEnhancerHook } from "./hook" diff --git a/src/hooks/index.ts b/src/hooks/index.ts index fcaf1dad5..16ebdc230 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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"; diff --git a/src/plugin/chat-params.ts b/src/plugin/chat-params.ts index 8f996a887..f91108116 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -1,3 +1,5 @@ +import { setProvider } from "../features/hashline-provider-state" + type ChatParamsInput = { sessionID: string agent: { name?: string } @@ -66,6 +68,8 @@ export function createChatParamsHandler(args: { if (!normalizedInput) return if (!isChatParamsOutput(output)) return + setProvider(normalizedInput.sessionID, normalizedInput.model.providerID) + await args.anthropicEffort?.["chat.params"]?.(normalizedInput, output) } } diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index ba0cb7f4b..062cf6d6e 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -10,6 +10,8 @@ import { createRulesInjectorHook, createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, + createHashlineEditDisablerHook, + createHashlineReadEnhancerHook, } from "../../hooks" import { getOpenCodeVersion, @@ -28,6 +30,8 @@ export type ToolGuardHooks = { rulesInjector: ReturnType | null tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null + hashlineEditDisabler: ReturnType | null + hashlineReadEnhancer: ReturnType | null } export function createToolGuardHooks(args: { @@ -85,6 +89,14 @@ export function createToolGuardHooks(args: { ? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx)) : null + const hashlineEditDisabler = isHookEnabled("hashline-edit-disabler") + ? safeHook("hashline-edit-disabler", () => createHashlineEditDisablerHook(pluginConfig)) + : null + + const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer") + ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.experimental?.hashline_edit ?? false } })) + : null + return { commentChecker, toolOutputTruncator, @@ -94,5 +106,7 @@ export function createToolGuardHooks(args: { rulesInjector, tasksTodowriteDisabler, writeExistingFileGuard, + hashlineEditDisabler, + hashlineReadEnhancer, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index 21282e3d3..31f20f593 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -43,5 +43,6 @@ export function createToolExecuteAfterHandler(args: { await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) + await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output) } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 0a7bd38ed..42a876ddd 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -29,6 +29,7 @@ export function createToolExecuteBeforeHandler(args: { await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + await hooks.hashlineEditDisabler?.["tool.execute.before"]?.(input, output) if (input.tool === "task") { const argsObject = output.args diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 14267fe0a..99993c4d5 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -25,6 +25,7 @@ import { createTaskGetTool, createTaskList, createTaskUpdateTool, + createHashlineEditTool, } from "../tools" import { getMainSessionID } from "../features/claude-code-session-state" import { filterDisabledTools } from "../shared/disabled-tools" @@ -117,6 +118,11 @@ export function createToolRegistry(args: { } : {} + const hashlineEnabled = pluginConfig.experimental?.hashline_edit ?? false + const hashlineToolsRecord: Record = hashlineEnabled + ? { hashline_edit: createHashlineEditTool() } + : {} + const allTools: Record = { ...builtinTools, ...createGrepTools(ctx), @@ -132,6 +138,7 @@ export function createToolRegistry(args: { slashcommand: slashcommandTool, interactive_bash, ...taskToolsRecord, + ...hashlineToolsRecord, } const filteredTools = filterDisabledTools(allTools, pluginConfig.disabled_tools) diff --git a/src/tools/hashline-edit/edit-operations.test.ts b/src/tools/hashline-edit/edit-operations.test.ts new file mode 100644 index 000000000..41b5c2e6d --- /dev/null +++ b/src/tools/hashline-edit/edit-operations.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from "bun:test" +import { + applyHashlineEdits, + applyInsertAfter, + applyReplace, + applyReplaceLines, + applySetLine, +} from "./edit-operations" +import type { HashlineEdit, InsertAfter, Replace, ReplaceLines, SetLine } from "./types" + +describe("applySetLine", () => { + it("replaces a single line at the specified anchor", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const anchor = "2:b2" // line 2 hash + + //#when + const result = applySetLine(lines, anchor, "new line 2") + + //#then + expect(result).toEqual(["line 1", "new line 2", "line 3"]) + }) + + it("handles newline escapes in replacement text", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const anchor = "2:b2" + + //#when + const result = applySetLine(lines, anchor, "new\\nline") + + //#then + expect(result).toEqual(["line 1", "new\nline", "line 3"]) + }) + + it("throws on hash mismatch", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const anchor = "2:ff" // wrong hash + + //#when / #then + expect(() => applySetLine(lines, anchor, "new")).toThrow("Hash mismatch") + }) + + it("throws on out of bounds line", () => { + //#given + const lines = ["line 1", "line 2"] + const anchor = "5:00" + + //#when / #then + expect(() => applySetLine(lines, anchor, "new")).toThrow("out of bounds") + }) +}) + +describe("applyReplaceLines", () => { + it("replaces a range of lines", () => { + //#given + const lines = ["line 1", "line 2", "line 3", "line 4", "line 5"] + const startAnchor = "2:b2" + const endAnchor = "4:5f" + + //#when + const result = applyReplaceLines(lines, startAnchor, endAnchor, "replacement") + + //#then + expect(result).toEqual(["line 1", "replacement", "line 5"]) + }) + + it("handles newline escapes in replacement text", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const startAnchor = "2:b2" + const endAnchor = "2:b2" + + //#when + const result = applyReplaceLines(lines, startAnchor, endAnchor, "a\\nb") + + //#then + expect(result).toEqual(["line 1", "a", "b", "line 3"]) + }) + + it("throws on start hash mismatch", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const startAnchor = "2:ff" + const endAnchor = "3:83" + + //#when / #then + expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow( + "Hash mismatch" + ) + }) + + it("throws on end hash mismatch", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const startAnchor = "2:b2" + const endAnchor = "3:ff" + + //#when / #then + expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow( + "Hash mismatch" + ) + }) + + it("throws when start > end", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const startAnchor = "3:83" + const endAnchor = "2:b2" + + //#when / #then + expect(() => applyReplaceLines(lines, startAnchor, endAnchor, "new")).toThrow( + "start line 3 cannot be greater than end line 2" + ) + }) +}) + +describe("applyInsertAfter", () => { + it("inserts text after the specified line", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const anchor = "2:b2" + + //#when + const result = applyInsertAfter(lines, anchor, "inserted") + + //#then + expect(result).toEqual(["line 1", "line 2", "inserted", "line 3"]) + }) + + it("handles newline escapes to insert multiple lines", () => { + //#given + const lines = ["line 1", "line 2", "line 3"] + const anchor = "2:b2" + + //#when + const result = applyInsertAfter(lines, anchor, "a\\nb\\nc") + + //#then + expect(result).toEqual(["line 1", "line 2", "a", "b", "c", "line 3"]) + }) + + it("inserts at end when anchor is last line", () => { + //#given + const lines = ["line 1", "line 2"] + const anchor = "2:b2" + + //#when + const result = applyInsertAfter(lines, anchor, "inserted") + + //#then + expect(result).toEqual(["line 1", "line 2", "inserted"]) + }) + + it("throws on hash mismatch", () => { + //#given + const lines = ["line 1", "line 2"] + const anchor = "2:ff" + + //#when / #then + expect(() => applyInsertAfter(lines, anchor, "new")).toThrow("Hash mismatch") + }) +}) + +describe("applyReplace", () => { + it("replaces exact text match", () => { + //#given + const content = "hello world foo bar" + const oldText = "world" + const newText = "universe" + + //#when + const result = applyReplace(content, oldText, newText) + + //#then + expect(result).toEqual("hello universe foo bar") + }) + + it("replaces all occurrences", () => { + //#given + const content = "foo bar foo baz foo" + const oldText = "foo" + const newText = "qux" + + //#when + const result = applyReplace(content, oldText, newText) + + //#then + expect(result).toEqual("qux bar qux baz qux") + }) + + it("handles newline escapes in newText", () => { + //#given + const content = "hello world" + const oldText = "world" + const newText = "new\\nline" + + //#when + const result = applyReplace(content, oldText, newText) + + //#then + expect(result).toEqual("hello new\nline") + }) + + it("throws when oldText not found", () => { + //#given + const content = "hello world" + const oldText = "notfound" + const newText = "replacement" + + //#when / #then + expect(() => applyReplace(content, oldText, newText)).toThrow("Text not found") + }) +}) + +describe("applyHashlineEdits", () => { + it("applies single set_line edit", () => { + //#given + const content = "line 1\nline 2\nline 3" + const edits: SetLine[] = [{ type: "set_line", line: "2:b2", text: "new line 2" }] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\nnew line 2\nline 3") + }) + + it("applies multiple edits bottom-up (descending line order)", () => { + //#given + const content = "line 1\nline 2\nline 3\nline 4\nline 5" + const edits: SetLine[] = [ + { type: "set_line", line: "2:b2", text: "new 2" }, + { type: "set_line", line: "4:5f", text: "new 4" }, + ] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\nnew 2\nline 3\nnew 4\nline 5") + }) + + it("applies mixed edit types", () => { + //#given + const content = "line 1\nline 2\nline 3" + const edits: HashlineEdit[] = [ + { type: "insert_after", line: "1:02", text: "inserted" }, + { type: "set_line", line: "3:83", text: "modified" }, + ] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\ninserted\nline 2\nmodified") + }) + + it("applies replace_lines edit", () => { + //#given + const content = "line 1\nline 2\nline 3\nline 4" + const edits: ReplaceLines[] = [ + { type: "replace_lines", start_line: "2:b2", end_line: "3:83", text: "replaced" }, + ] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\nreplaced\nline 4") + }) + + it("applies replace fallback edit", () => { + //#given + const content = "hello world foo" + const edits: Replace[] = [{ type: "replace", old_text: "world", new_text: "universe" }] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("hello universe foo") + }) + + it("handles empty edits array", () => { + //#given + const content = "line 1\nline 2" + const edits: HashlineEdit[] = [] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("line 1\nline 2") + }) + + it("throws on hash mismatch with descriptive error", () => { + //#given + const content = "line 1\nline 2\nline 3" + const edits: SetLine[] = [{ type: "set_line", line: "2:ff", text: "new" }] + + //#when / #then + expect(() => applyHashlineEdits(content, edits)).toThrow("Hash mismatch") + }) + + it("correctly handles index shifting with multiple edits", () => { + //#given + const content = "a\nb\nc\nd\ne" + const edits: InsertAfter[] = [ + { type: "insert_after", line: "2:bf", text: "x" }, + { type: "insert_after", line: "4:90", text: "y" }, + ] + + //#when + const result = applyHashlineEdits(content, edits) + + //#then + expect(result).toEqual("a\nb\nx\nc\nd\ny\ne") + }) +}) diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts new file mode 100644 index 000000000..a91863ee9 --- /dev/null +++ b/src/tools/hashline-edit/edit-operations.ts @@ -0,0 +1,123 @@ +import { parseLineRef, validateLineRef } from "./validation" +import type { HashlineEdit } from "./types" + +function unescapeNewlines(text: string): string { + return text.replace(/\\n/g, "\n") +} + +export function applySetLine(lines: string[], anchor: string, newText: string): string[] { + validateLineRef(lines, anchor) + const { line } = parseLineRef(anchor) + const result = [...lines] + result[line - 1] = unescapeNewlines(newText) + return result +} + +export function applyReplaceLines( + lines: string[], + startAnchor: string, + endAnchor: string, + newText: string +): string[] { + validateLineRef(lines, startAnchor) + validateLineRef(lines, endAnchor) + + const { line: startLine } = parseLineRef(startAnchor) + const { line: endLine } = parseLineRef(endAnchor) + + if (startLine > endLine) { + throw new Error( + `Invalid range: start line ${startLine} cannot be greater than end line ${endLine}` + ) + } + + const result = [...lines] + const newLines = unescapeNewlines(newText).split("\n") + result.splice(startLine - 1, endLine - startLine + 1, ...newLines) + return result +} + +export function applyInsertAfter(lines: string[], anchor: string, text: string): string[] { + validateLineRef(lines, anchor) + const { line } = parseLineRef(anchor) + const result = [...lines] + const newLines = unescapeNewlines(text).split("\n") + result.splice(line, 0, ...newLines) + return result +} + +export function applyReplace(content: string, oldText: string, newText: string): string { + if (!content.includes(oldText)) { + throw new Error(`Text not found: "${oldText}"`) + } + return content.replaceAll(oldText, unescapeNewlines(newText)) +} + +function getEditLineNumber(edit: HashlineEdit): number { + switch (edit.type) { + case "set_line": + return parseLineRef(edit.line).line + case "replace_lines": + return parseLineRef(edit.end_line).line + case "insert_after": + return parseLineRef(edit.line).line + case "replace": + return Number.POSITIVE_INFINITY + default: + return Number.POSITIVE_INFINITY + } +} + +export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string { + if (edits.length === 0) { + return content + } + + const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a)) + + let result = content + let lines = result.split("\n") + + for (const edit of sortedEdits) { + switch (edit.type) { + case "set_line": { + validateLineRef(lines, edit.line) + const { line } = parseLineRef(edit.line) + lines[line - 1] = unescapeNewlines(edit.text) + break + } + case "replace_lines": { + validateLineRef(lines, edit.start_line) + validateLineRef(lines, edit.end_line) + const { line: startLine } = parseLineRef(edit.start_line) + const { line: endLine } = parseLineRef(edit.end_line) + if (startLine > endLine) { + throw new Error( + `Invalid range: start line ${startLine} cannot be greater than end line ${endLine}` + ) + } + const newLines = unescapeNewlines(edit.text).split("\n") + lines.splice(startLine - 1, endLine - startLine + 1, ...newLines) + break + } + case "insert_after": { + validateLineRef(lines, edit.line) + const { line } = parseLineRef(edit.line) + const newLines = unescapeNewlines(edit.text).split("\n") + lines.splice(line, 0, ...newLines) + break + } + case "replace": { + result = lines.join("\n") + if (!result.includes(edit.old_text)) { + throw new Error(`Text not found: "${edit.old_text}"`) + } + result = result.replaceAll(edit.old_text, unescapeNewlines(edit.new_text)) + lines = result.split("\n") + break + } + } + } + + return lines.join("\n") +} diff --git a/src/tools/hashline-edit/index.ts b/src/tools/hashline-edit/index.ts index 6c12810f2..3aaf44e0e 100644 --- a/src/tools/hashline-edit/index.ts +++ b/src/tools/hashline-edit/index.ts @@ -3,3 +3,11 @@ export { parseLineRef, validateLineRef } from "./validation" export type { LineRef } from "./validation" export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types" export { HASH_DICT, HASHLINE_PATTERN } from "./constants" +export { + applyHashlineEdits, + applyInsertAfter, + applyReplace, + applyReplaceLines, + applySetLine, +} from "./edit-operations" +export { createHashlineEditTool } from "./tools" diff --git a/src/tools/hashline-edit/tools.test.ts b/src/tools/hashline-edit/tools.test.ts new file mode 100644 index 000000000..518a0df6b --- /dev/null +++ b/src/tools/hashline-edit/tools.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +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" + +describe("createHashlineEditTool", () => { + let tempDir: string + let tool: ReturnType + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-edit-test-")) + tool = createHashlineEditTool() + }) + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + describe("tool definition", () => { + it("has correct description", () => { + //#given tool is created + //#when accessing tool properties + //#then description explains LINE:HASH format + expect(tool.description).toContain("LINE:HASH") + expect(tool.description).toContain("set_line") + expect(tool.description).toContain("replace_lines") + expect(tool.description).toContain("insert_after") + expect(tool.description).toContain("replace") + }) + + it("has path parameter", () => { + //#given tool is created + //#when checking parameters + //#then path parameter exists as required string + expect(tool.args.path).toBeDefined() + }) + + it("has edits parameter as array", () => { + //#given tool is created + //#when checking parameters + //#then edits parameter exists as array + expect(tool.args.edits).toBeDefined() + }) + }) + + describe("execute", () => { + it("returns error when file does not exist", async () => { + //#given non-existent file path + const nonExistentPath = path.join(tempDir, "non-existent.txt") + + //#when executing tool + const result = await tool.execute( + { + path: nonExistentPath, + edits: [{ type: "set_line", line: "1:00", text: "new content" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then error is returned + expect(result).toContain("Error") + expect(result).toContain("not found") + }) + + it("applies set_line edit and returns diff", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2\nline3") + const line2Hash = computeLineHash(2, "line2") + + //#when executing set_line edit + const result = await tool.execute( + { + path: filePath, + edits: [{ type: "set_line", line: `2:${line2Hash}`, text: "modified line2" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then file is modified and diff is returned + const content = fs.readFileSync(filePath, "utf-8") + expect(content).toBe("line1\nmodified line2\nline3") + expect(result).toContain("modified line2") + }) + + it("applies insert_after edit", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2") + const line1Hash = computeLineHash(1, "line1") + + //#when executing insert_after edit + const result = await tool.execute( + { + path: filePath, + edits: [{ type: "insert_after", line: `1:${line1Hash}`, text: "inserted" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then line is inserted after specified line + const content = fs.readFileSync(filePath, "utf-8") + expect(content).toBe("line1\ninserted\nline2") + }) + + it("applies replace_lines edit", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2\nline3\nline4") + const line2Hash = computeLineHash(2, "line2") + const line3Hash = computeLineHash(3, "line3") + + //#when executing replace_lines edit + const result = await tool.execute( + { + path: filePath, + edits: [ + { + type: "replace_lines", + start_line: `2:${line2Hash}`, + end_line: `3:${line3Hash}`, + text: "replaced", + }, + ], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then lines are replaced + const content = fs.readFileSync(filePath, "utf-8") + expect(content).toBe("line1\nreplaced\nline4") + }) + + it("applies replace edit", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "hello world\nfoo bar") + + //#when executing replace edit + const result = await tool.execute( + { + path: filePath, + edits: [{ type: "replace", old_text: "world", new_text: "universe" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then text is replaced + const content = fs.readFileSync(filePath, "utf-8") + expect(content).toBe("hello universe\nfoo bar") + }) + + it("applies multiple edits in bottom-up order", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2\nline3") + const line1Hash = computeLineHash(1, "line1") + const line3Hash = computeLineHash(3, "line3") + + //#when executing multiple edits + const result = await tool.execute( + { + path: 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() } + ) + + //#then both edits are applied + const content = fs.readFileSync(filePath, "utf-8") + expect(content).toBe("new1\nline2\nnew3") + }) + + it("returns error on hash mismatch", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2") + + //#when executing with wrong hash (valid format but wrong value) + const result = await tool.execute( + { + path: filePath, + edits: [{ type: "set_line", line: "1:ff", text: "new" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then hash mismatch error is returned + expect(result).toContain("Error") + expect(result).toContain("hash") + }) + + it("handles escaped newlines in text", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "line1\nline2") + const line1Hash = computeLineHash(1, "line1") + + //#when executing with escaped newline + const result = await tool.execute( + { + path: filePath, + edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new\\nline" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then newline is unescaped + const content = fs.readFileSync(filePath, "utf-8") + expect(content).toBe("new\nline\nline2") + }) + + it("returns success result with diff summary", async () => { + //#given file with content + const filePath = path.join(tempDir, "test.txt") + fs.writeFileSync(filePath, "old content") + const line1Hash = computeLineHash(1, "old content") + + //#when executing edit + const result = await tool.execute( + { + path: filePath, + edits: [{ type: "set_line", line: `1:${line1Hash}`, text: "new content" }], + }, + { sessionID: "test", messageID: "test", agent: "test", abort: new AbortController() } + ) + + //#then result contains success indicator and diff + expect(result).toContain("Successfully") + expect(result).toContain("old content") + expect(result).toContain("new content") + }) + }) +}) diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts new file mode 100644 index 000000000..a4bfab18f --- /dev/null +++ b/src/tools/hashline-edit/tools.ts @@ -0,0 +1,137 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import type { HashlineEdit } from "./types" +import { applyHashlineEdits } from "./edit-operations" +import { computeLineHash } from "./hash-computation" + +interface HashlineEditArgs { + path: string + edits: HashlineEdit[] +} + +function generateDiff(oldContent: string, newContent: string, filePath: string): string { + const oldLines = oldContent.split("\n") + const newLines = newContent.split("\n") + + let diff = `--- ${filePath}\n+++ ${filePath}\n` + + const maxLines = Math.max(oldLines.length, newLines.length) + for (let i = 0; i < maxLines; i++) { + const oldLine = oldLines[i] ?? "" + const newLine = newLines[i] ?? "" + const lineNum = i + 1 + const hash = computeLineHash(lineNum, newLine) + + if (i >= oldLines.length) { + diff += `+ ${lineNum}:${hash}|${newLine}\n` + } else if (i >= newLines.length) { + diff += `- ${lineNum}: |${oldLine}\n` + } else if (oldLine !== newLine) { + diff += `- ${lineNum}: |${oldLine}\n` + diff += `+ ${lineNum}:${hash}|${newLine}\n` + } + } + + return diff +} + +export function createHashlineEditTool(): ToolDefinition { + return tool({ + description: `Edit files using LINE:HASH format for precise, safe modifications. + +LINE:HASH FORMAT: +Each line reference must be in "LINE:HASH" format where: +- LINE: 1-based line number +- HASH: First 2 characters of SHA-256 hash of line content (computed with computeLineHash) +- Example: "5:a3|const x = 1" means line 5 with hash "a3" + +GETTING HASHES: +Use the read tool - it returns lines in "LINE:HASH|content" format. + +FOUR OPERATION TYPES: + +1. set_line: Replace a single line + { "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" } + +3. insert_after: Insert lines after a specific line + { "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" } + +4. replace: Simple text replacement (no hash validation) + { "type": "replace", "old_text": "foo", "new_text": "bar" } + +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. + +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"), + edits: tool.schema + .array( + tool.schema.union([ + tool.schema.object({ + type: tool.schema.literal("set_line"), + line: tool.schema.string().describe("Line reference in LINE:HASH format"), + text: tool.schema.string().describe("New content for the line"), + }), + tool.schema.object({ + type: tool.schema.literal("replace_lines"), + start_line: tool.schema.string().describe("Start line in LINE:HASH format"), + end_line: tool.schema.string().describe("End line in LINE:HASH format"), + text: tool.schema.string().describe("New content to replace the range"), + }), + tool.schema.object({ + type: tool.schema.literal("insert_after"), + line: tool.schema.string().describe("Line reference in LINE:HASH format"), + text: tool.schema.string().describe("Content to insert after the line"), + }), + tool.schema.object({ + type: tool.schema.literal("replace"), + old_text: tool.schema.string().describe("Text to find"), + new_text: tool.schema.string().describe("Replacement text"), + }), + ]) + ) + .describe("Array of edit operations to apply"), + }, + execute: async (args: HashlineEditArgs) => { + try { + const { path: filePath, edits } = args + + if (!filePath) { + return "Error: path parameter is required" + } + + if (!edits || !Array.isArray(edits) || edits.length === 0) { + return "Error: edits parameter must be a non-empty array" + } + + const file = Bun.file(filePath) + const exists = await file.exists() + if (!exists) { + return `Error: File not found: ${filePath}` + } + + const oldContent = await file.text() + const newContent = applyHashlineEdits(oldContent, edits) + + await Bun.write(filePath, newContent) + + const diff = generateDiff(oldContent, newContent, filePath) + + return `Successfully applied ${edits.length} edit(s) to ${filePath}\n\n${diff}` + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes("hash")) { + return `Error: Hash mismatch - ${message}` + } + return `Error: ${message}` + } + }, + }) +} diff --git a/src/tools/index.ts b/src/tools/index.ts index a38a6c74c..0f2b8a1cc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -43,6 +43,7 @@ export { createTaskList, createTaskUpdateTool, } from "./task" +export { createHashlineEditTool } from "./hashline-edit" export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record { const outputManager: BackgroundOutputManager = manager