diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b88e20873..e9bde3326 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: bun test src/cli/doctor/format-default.test.ts bun test src/tools/call-omo-agent/sync-executor.test.ts bun test src/tools/call-omo-agent/session-creator.test.ts + bun test src/tools/session-manager bun test src/features/opencode-skill-loader/loader.test.ts - name: Run remaining tests @@ -63,7 +64,7 @@ jobs: # Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files # that were already run in isolation above. # Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts - # Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts + # Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts, session-manager (all) bun test bin script src/config src/mcp src/index.test.ts \ src/agents src/shared \ src/cli/run src/cli/config-manager src/cli/mcp-oauth \ @@ -72,7 +73,7 @@ jobs: src/cli/doctor/runner.test.ts src/cli/doctor/checks \ src/tools/ast-grep src/tools/background-task src/tools/delegate-task \ src/tools/glob src/tools/grep src/tools/interactive-bash \ - src/tools/look-at src/tools/lsp src/tools/session-manager \ + src/tools/look-at src/tools/lsp \ src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \ src/tools/call-omo-agent/background-agent-executor.test.ts \ src/tools/call-omo-agent/background-executor.test.ts \ diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index c443ff7e2..8106f6e29 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -98,7 +98,8 @@ "stop-continuation-guard", "tasks-todowrite-disabler", "write-existing-file-guard", - "anthropic-effort" + "anthropic-effort", + "hashline-read-enhancer" ] } }, @@ -2830,6 +2831,9 @@ }, "safe_hook_creation": { "type": "boolean" + }, + "hashline_edit": { + "type": "boolean" } }, "additionalProperties": false 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.test.ts b/src/config/schema.test.ts index 2efccaa37..3543a45ad 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -698,6 +698,59 @@ describe("ExperimentalConfigSchema feature flags", () => { expect(result.data.safe_hook_creation).toBeUndefined() } }) + + test("accepts hashline_edit as true", () => { + //#given + const config = { hashline_edit: true } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.hashline_edit).toBe(true) + } + }) + + test("accepts hashline_edit as false", () => { + //#given + const config = { hashline_edit: false } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.hashline_edit).toBe(false) + } + }) + + test("hashline_edit is optional", () => { + //#given + const config = { safe_hook_creation: true } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.hashline_edit).toBeUndefined() + } + }) + + test("rejects non-boolean hashline_edit", () => { + //#given + const config = { hashline_edit: "true" } + + //#when + const result = ExperimentalConfigSchema.safeParse(config) + + //#then + expect(result.success).toBe(false) + }) }) describe("GitMasterConfigSchema", () => { diff --git a/src/config/schema/experimental.ts b/src/config/schema/experimental.ts index 52747aae9..927a13a28 100644 --- a/src/config/schema/experimental.ts +++ b/src/config/schema/experimental.ts @@ -15,6 +15,8 @@ export const ExperimentalConfigSchema = z.object({ plugin_load_timeout_ms: z.number().min(1000).optional(), /** Wrap hook creation in try/catch to prevent one failing hook from crashing the plugin (default: true at call site) */ safe_hook_creation: z.boolean().optional(), + /** Enable hashline_edit tool for improved file editing with hash-based line anchors */ + hashline_edit: z.boolean().optional(), }) export type ExperimentalConfig = z.infer diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index add671887..d0e1e1917 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -45,6 +45,7 @@ export const HookNameSchema = z.enum([ "tasks-todowrite-disabler", "write-existing-file-guard", "anthropic-effort", + "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-read-enhancer/hook.ts b/src/hooks/hashline-read-enhancer/hook.ts new file mode 100644 index 000000000..18dfabe2e --- /dev/null +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -0,0 +1,66 @@ +import type { PluginInput } from "@opencode-ai/plugin" +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(config: HashlineReadEnhancerConfig): boolean { + return config.hashline_edit?.enabled ?? false +} + +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(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..0c640f09d --- /dev/null +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, beforeEach } from "bun:test" +import { createHashlineReadEnhancerHook } from "./hook" +import type { PluginInput } from "@opencode-ai/plugin" + +//#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() + }) + + 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("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..bdc27211d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -43,3 +43,4 @@ 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 { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index ba0cb7f4b..46a36140c 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -10,6 +10,7 @@ import { createRulesInjectorHook, createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, + createHashlineReadEnhancerHook, } from "../../hooks" import { getOpenCodeVersion, @@ -28,6 +29,7 @@ export type ToolGuardHooks = { rulesInjector: ReturnType | null tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null + hashlineReadEnhancer: ReturnType | null } export function createToolGuardHooks(args: { @@ -85,6 +87,10 @@ export function createToolGuardHooks(args: { ? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx)) : 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 +100,6 @@ export function createToolGuardHooks(args: { rulesInjector, tasksTodowriteDisabler, writeExistingFileGuard, + 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..70d023b4c 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -29,7 +29,6 @@ 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) - if (input.tool === "task") { const argsObject = output.args const category = typeof argsObject.category === "string" ? argsObject.category : undefined diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 3e28302a2..f8fe09a8b 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 + ? { 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/constants.ts b/src/tools/hashline-edit/constants.ts new file mode 100644 index 000000000..17638a49a --- /dev/null +++ b/src/tools/hashline-edit/constants.ts @@ -0,0 +1,30 @@ +export const HASH_DICT = [ + "00", "01", "02", "03", "04", "05", "06", "07", "08", "09", + "0a", "0b", "0c", "0d", "0e", "0f", "10", "11", "12", "13", + "14", "15", "16", "17", "18", "19", "1a", "1b", "1c", "1d", + "1e", "1f", "20", "21", "22", "23", "24", "25", "26", "27", + "28", "29", "2a", "2b", "2c", "2d", "2e", "2f", "30", "31", + "32", "33", "34", "35", "36", "37", "38", "39", "3a", "3b", + "3c", "3d", "3e", "3f", "40", "41", "42", "43", "44", "45", + "46", "47", "48", "49", "4a", "4b", "4c", "4d", "4e", "4f", + "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", + "5a", "5b", "5c", "5d", "5e", "5f", "60", "61", "62", "63", + "64", "65", "66", "67", "68", "69", "6a", "6b", "6c", "6d", + "6e", "6f", "70", "71", "72", "73", "74", "75", "76", "77", + "78", "79", "7a", "7b", "7c", "7d", "7e", "7f", "80", "81", + "82", "83", "84", "85", "86", "87", "88", "89", "8a", "8b", + "8c", "8d", "8e", "8f", "90", "91", "92", "93", "94", "95", + "96", "97", "98", "99", "9a", "9b", "9c", "9d", "9e", "9f", + "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", + "aa", "ab", "ac", "ad", "ae", "af", "b0", "b1", "b2", "b3", + "b4", "b5", "b6", "b7", "b8", "b9", "ba", "bb", "bc", "bd", + "be", "bf", "c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", + "c8", "c9", "ca", "cb", "cc", "cd", "ce", "cf", "d0", "d1", + "d2", "d3", "d4", "d5", "d6", "d7", "d8", "d9", "da", "db", + "dc", "dd", "de", "df", "e0", "e1", "e2", "e3", "e4", "e5", + "e6", "e7", "e8", "e9", "ea", "eb", "ec", "ed", "ee", "ef", + "f0", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", + "fa", "fb", "fc", "fd", "fe", "ff", +] as const + +export const HASHLINE_PATTERN = /^(\d+):([0-9a-f]{2})\|(.*)$/ 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..7a6340db6 --- /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.NEGATIVE_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/hash-computation.test.ts b/src/tools/hashline-edit/hash-computation.test.ts new file mode 100644 index 000000000..8a0790357 --- /dev/null +++ b/src/tools/hashline-edit/hash-computation.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "bun:test" +import { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation" + +describe("computeLineHash", () => { + it("returns consistent 2-char hex for same input", () => { + //#given + const lineNumber = 1 + const content = "function hello() {" + + //#when + const hash1 = computeLineHash(lineNumber, content) + const hash2 = computeLineHash(lineNumber, content) + + //#then + expect(hash1).toBe(hash2) + expect(hash1).toMatch(/^[0-9a-f]{2}$/) + }) + + it("strips whitespace before hashing", () => { + //#given + const lineNumber = 1 + const content1 = "function hello() {" + const content2 = " function hello() { " + + //#when + const hash1 = computeLineHash(lineNumber, content1) + const hash2 = computeLineHash(lineNumber, content2) + + //#then + expect(hash1).toBe(hash2) + }) + + it("handles empty lines", () => { + //#given + const lineNumber = 1 + const content = "" + + //#when + const hash = computeLineHash(lineNumber, content) + + //#then + expect(hash).toMatch(/^[0-9a-f]{2}$/) + }) + + it("returns different hashes for different content", () => { + //#given + const lineNumber = 1 + const content1 = "function hello() {" + const content2 = "function world() {" + + //#when + const hash1 = computeLineHash(lineNumber, content1) + const hash2 = computeLineHash(lineNumber, content2) + + //#then + expect(hash1).not.toBe(hash2) + }) +}) + +describe("formatHashLine", () => { + it("formats line with hash prefix", () => { + //#given + const lineNumber = 42 + const content = "function hello() {" + + //#when + const result = formatHashLine(lineNumber, content) + + //#then + expect(result).toMatch(/^42:[0-9a-f]{2}\|function hello\(\) \{$/) + }) + + it("preserves content after hash prefix", () => { + //#given + const lineNumber = 1 + const content = "const x = 42" + + //#when + const result = formatHashLine(lineNumber, content) + + //#then + expect(result).toContain("|const x = 42") + }) +}) + +describe("formatHashLines", () => { + it("formats all lines with hash prefixes", () => { + //#given + const content = "function hello() {\n return 42\n}" + + //#when + const result = formatHashLines(content) + + //#then + const lines = result.split("\n") + expect(lines).toHaveLength(3) + expect(lines[0]).toMatch(/^1:[0-9a-f]{2}\|/) + expect(lines[1]).toMatch(/^2:[0-9a-f]{2}\|/) + expect(lines[2]).toMatch(/^3:[0-9a-f]{2}\|/) + }) + + it("handles empty file", () => { + //#given + const content = "" + + //#when + const result = formatHashLines(content) + + //#then + expect(result).toBe("") + }) + + it("handles single line", () => { + //#given + const content = "const x = 42" + + //#when + const result = formatHashLines(content) + + //#then + expect(result).toMatch(/^1:[0-9a-f]{2}\|const x = 42$/) + }) +}) diff --git a/src/tools/hashline-edit/hash-computation.ts b/src/tools/hashline-edit/hash-computation.ts new file mode 100644 index 000000000..348cf3ea2 --- /dev/null +++ b/src/tools/hashline-edit/hash-computation.ts @@ -0,0 +1,19 @@ +import { HASH_DICT } from "./constants" + +export function computeLineHash(lineNumber: number, content: string): string { + const stripped = content.replace(/\s+/g, "") + const hash = Bun.hash.xxHash32(stripped) + const index = hash % 256 + return HASH_DICT[index] +} + +export function formatHashLine(lineNumber: number, content: string): string { + const hash = computeLineHash(lineNumber, content) + return `${lineNumber}:${hash}|${content}` +} + +export function formatHashLines(content: string): string { + if (!content) return "" + const lines = content.split("\n") + return lines.map((line, index) => formatHashLine(index + 1, line)).join("\n") +} diff --git a/src/tools/hashline-edit/index.ts b/src/tools/hashline-edit/index.ts new file mode 100644 index 000000000..3aaf44e0e --- /dev/null +++ b/src/tools/hashline-edit/index.ts @@ -0,0 +1,13 @@ +export { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation" +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..8c970582e --- /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 xxHash32 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/hashline-edit/types.ts b/src/tools/hashline-edit/types.ts new file mode 100644 index 000000000..3de342b1a --- /dev/null +++ b/src/tools/hashline-edit/types.ts @@ -0,0 +1,26 @@ +export interface SetLine { + type: "set_line" + line: string + text: string +} + +export interface ReplaceLines { + type: "replace_lines" + start_line: string + end_line: string + text: string +} + +export interface InsertAfter { + type: "insert_after" + line: string + text: string +} + +export interface Replace { + type: "replace" + old_text: string + new_text: string +} + +export type HashlineEdit = SetLine | ReplaceLines | InsertAfter | Replace diff --git a/src/tools/hashline-edit/validation.test.ts b/src/tools/hashline-edit/validation.test.ts new file mode 100644 index 000000000..b7ac1ab69 --- /dev/null +++ b/src/tools/hashline-edit/validation.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "bun:test" +import { parseLineRef, validateLineRef } from "./validation" + +describe("parseLineRef", () => { + it("parses valid line reference", () => { + //#given + const ref = "42:a3" + + //#when + const result = parseLineRef(ref) + + //#then + expect(result).toEqual({ line: 42, hash: "a3" }) + }) + + it("parses line reference with different hash", () => { + //#given + const ref = "1:ff" + + //#when + const result = parseLineRef(ref) + + //#then + expect(result).toEqual({ line: 1, hash: "ff" }) + }) + + it("throws on invalid format - no colon", () => { + //#given + const ref = "42a3" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) + + it("throws on invalid format - non-numeric line", () => { + //#given + const ref = "abc:a3" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) + + it("throws on invalid format - invalid hash", () => { + //#given + const ref = "42:xyz" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) + + it("throws on empty string", () => { + //#given + const ref = "" + + //#when & #then + expect(() => parseLineRef(ref)).toThrow() + }) +}) + +describe("validateLineRef", () => { + it("validates matching hash", () => { + //#given + const lines = ["function hello() {", " return 42", "}"] + const ref = "1:42" + + //#when & #then + expect(() => validateLineRef(lines, ref)).not.toThrow() + }) + + it("throws on hash mismatch", () => { + //#given + const lines = ["function hello() {", " return 42", "}"] + const ref = "1:00" // Wrong hash + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow() + }) + + it("throws on line out of bounds", () => { + //#given + const lines = ["function hello() {", " return 42", "}"] + const ref = "99:a3" + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow() + }) + + it("throws on invalid line number", () => { + //#given + const lines = ["function hello() {"] + const ref = "0:a3" // Line numbers start at 1 + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow() + }) + + it("error message includes current hash", () => { + //#given + const lines = ["function hello() {"] + const ref = "1:00" + + //#when & #then + expect(() => validateLineRef(lines, ref)).toThrow(/current hash/) + }) +}) diff --git a/src/tools/hashline-edit/validation.ts b/src/tools/hashline-edit/validation.ts new file mode 100644 index 000000000..178f35d8e --- /dev/null +++ b/src/tools/hashline-edit/validation.ts @@ -0,0 +1,39 @@ +import { computeLineHash } from "./hash-computation" + +export interface LineRef { + line: number + hash: string +} + +export function parseLineRef(ref: string): LineRef { + const match = ref.match(/^(\d+):([0-9a-f]{2})$/) + if (!match) { + throw new Error( + `Invalid line reference format: "${ref}". Expected format: "LINE:HASH" (e.g., "42:a3")` + ) + } + return { + line: Number.parseInt(match[1], 10), + hash: match[2], + } +} + +export function validateLineRef(lines: string[], ref: string): void { + const { line, hash } = parseLineRef(ref) + + if (line < 1 || line > lines.length) { + throw new Error( + `Line number ${line} out of bounds. File has ${lines.length} lines.` + ) + } + + const content = lines[line - 1] + const currentHash = computeLineHash(line, content) + + if (currentHash !== hash) { + throw new Error( + `Hash mismatch at line ${line}. Expected hash: ${hash}, current hash: ${currentHash}. ` + + `Line content may have changed. Current content: "${content}"` + ) + } +} 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 diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 63d3eca28..447362719 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" -import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs" +import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import { randomUUID } from "node:crypto" @@ -38,6 +38,27 @@ mock.module("../../shared/opencode-storage-paths", () => ({ SESSION_STORAGE: TEST_SESSION_STORAGE, })) +mock.module("../../shared/opencode-message-dir", () => ({ + getMessageDir: (sessionID: string) => { + if (!sessionID.startsWith("ses_")) return null + if (/[/\\]|\.\./.test(sessionID)) return null + if (!existsSync(TEST_MESSAGE_STORAGE)) return null + + const directPath = join(TEST_MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) { + return directPath + } + + for (const dir of readdirSync(TEST_MESSAGE_STORAGE)) { + const nestedPath = join(TEST_MESSAGE_STORAGE, dir, sessionID) + if (existsSync(nestedPath)) { + return nestedPath + } + } + + return null + }, +})) const { getAllSessions, getMessageDir, sessionExists, readSessionMessages, readSessionTodos, getSessionInfo } = await import("./storage")