From 149de9da666586b7f83380dabf755555593ad47c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 15:59:12 +0900 Subject: [PATCH 1/7] feat(config): add experimental.hashline_edit flag and provider state module --- assets/oh-my-opencode.schema.json | 3 + src/config/schema.test.ts | 53 ++++++++ src/config/schema/experimental.ts | 2 + src/features/hashline-provider-state.test.ts | 113 ++++++++++++++++ src/features/hashline-provider-state.ts | 13 ++ src/tools/hashline-edit/constants.ts | 30 +++++ .../hashline-edit/hash-computation.test.ts | 123 ++++++++++++++++++ src/tools/hashline-edit/hash-computation.ts | 19 +++ src/tools/hashline-edit/index.ts | 5 + src/tools/hashline-edit/types.ts | 26 ++++ src/tools/hashline-edit/validation.test.ts | 105 +++++++++++++++ src/tools/hashline-edit/validation.ts | 39 ++++++ 12 files changed, 531 insertions(+) create mode 100644 src/features/hashline-provider-state.test.ts create mode 100644 src/features/hashline-provider-state.ts create mode 100644 src/tools/hashline-edit/constants.ts create mode 100644 src/tools/hashline-edit/hash-computation.test.ts create mode 100644 src/tools/hashline-edit/hash-computation.ts create mode 100644 src/tools/hashline-edit/index.ts create mode 100644 src/tools/hashline-edit/types.ts create mode 100644 src/tools/hashline-edit/validation.test.ts create mode 100644 src/tools/hashline-edit/validation.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index c443ff7e2..052956403 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2830,6 +2830,9 @@ }, "safe_hook_creation": { "type": "boolean" + }, + "hashline_edit": { + "type": "boolean" } }, "additionalProperties": false diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 2d151ec53..448219d61 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -663,6 +663,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/features/hashline-provider-state.test.ts b/src/features/hashline-provider-state.test.ts new file mode 100644 index 000000000..d8eb272ff --- /dev/null +++ b/src/features/hashline-provider-state.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { setProvider, getProvider, clearProvider } from "./hashline-provider-state" + +describe("hashline-provider-state", () => { + beforeEach(() => { + // Clear state before each test + clearProvider("test-session-1") + clearProvider("test-session-2") + }) + + describe("setProvider", () => { + test("should store provider ID for a session", () => { + // given + const sessionID = "test-session-1" + const providerID = "openai" + + // when + setProvider(sessionID, providerID) + + // then + expect(getProvider(sessionID)).toBe("openai") + }) + + test("should overwrite existing provider for same session", () => { + // given + const sessionID = "test-session-1" + setProvider(sessionID, "openai") + + // when + setProvider(sessionID, "anthropic") + + // then + expect(getProvider(sessionID)).toBe("anthropic") + }) + }) + + describe("getProvider", () => { + test("should return undefined for non-existent session", () => { + // given + const sessionID = "non-existent-session" + + // when + const result = getProvider(sessionID) + + // then + expect(result).toBeUndefined() + }) + + test("should return stored provider ID", () => { + // given + const sessionID = "test-session-1" + setProvider(sessionID, "anthropic") + + // when + const result = getProvider(sessionID) + + // then + expect(result).toBe("anthropic") + }) + + test("should handle multiple sessions independently", () => { + // given + setProvider("session-1", "openai") + setProvider("session-2", "anthropic") + + // when + const result1 = getProvider("session-1") + const result2 = getProvider("session-2") + + // then + expect(result1).toBe("openai") + expect(result2).toBe("anthropic") + }) + }) + + describe("clearProvider", () => { + test("should remove provider for a session", () => { + // given + const sessionID = "test-session-1" + setProvider(sessionID, "openai") + + // when + clearProvider(sessionID) + + // then + expect(getProvider(sessionID)).toBeUndefined() + }) + + test("should not affect other sessions", () => { + // given + setProvider("session-1", "openai") + setProvider("session-2", "anthropic") + + // when + clearProvider("session-1") + + // then + expect(getProvider("session-1")).toBeUndefined() + expect(getProvider("session-2")).toBe("anthropic") + }) + + test("should handle clearing non-existent session gracefully", () => { + // given + const sessionID = "non-existent" + + // when + clearProvider(sessionID) + + // then + expect(getProvider(sessionID)).toBeUndefined() + }) + }) +}) diff --git a/src/features/hashline-provider-state.ts b/src/features/hashline-provider-state.ts new file mode 100644 index 000000000..5c04a730f --- /dev/null +++ b/src/features/hashline-provider-state.ts @@ -0,0 +1,13 @@ +const providerStateMap = new Map() + +export function setProvider(sessionID: string, providerID: string): void { + providerStateMap.set(sessionID, providerID) +} + +export function getProvider(sessionID: string): string | undefined { + return providerStateMap.get(sessionID) +} + +export function clearProvider(sessionID: string): void { + providerStateMap.delete(sessionID) +} 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/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..6c12810f2 --- /dev/null +++ b/src/tools/hashline-edit/index.ts @@ -0,0 +1,5 @@ +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" 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}"` + ) + } +} From 51dde4d43f82efddc8e34650d6f97d49490c2ed3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 16:32:33 +0900 Subject: [PATCH 2/7] feat(hashline): port hashline edit tool from oh-my-pi This PR ports the hashline edit tool from oh-my-pi to oh-my-opencode as an experimental feature. ## Features - New experimental.hashline_edit config flag - hashline_edit tool with 4 operations: set_line, replace_lines, insert_after, replace - Hash-based line anchors for safe concurrent editing - Edit tool disabler for non-OpenAI providers - Read output enhancer with LINE:HASH prefixes - Provider state tracking module ## Technical Details - xxHash32-based 2-char hex hashes - Bottom-up edit application to prevent index shifting - OpenAI provider exemption (uses native apply_patch) - 90 tests covering all operations and edge cases - All files under 200 LOC limit ## Files Added/Modified - src/tools/hashline-edit/ (7 files, ~400 LOC) - src/hooks/hashline-edit-disabler/ (4 files, ~200 LOC) - src/hooks/hashline-read-enhancer/ (3 files, ~400 LOC) - src/features/hashline-provider-state.ts (13 LOC) - src/config/schema/experimental.ts (hashline_edit flag) - src/config/schema/hooks.ts (2 new hook names) - src/plugin/tool-registry.ts (conditional registration) - src/plugin/chat-params.ts (provider state tracking) - src/tools/index.ts (export) - src/hooks/index.ts (exports) --- assets/oh-my-opencode.schema.json | 4 +- src/cli/run/integration.test.ts | 9 +- src/cli/run/server-connection.test.ts | 10 +- src/config/schema/hooks.ts | 2 + .../recovery-deduplication.test.ts | 7 +- .../storage.test.ts | 6 +- .../compaction-todo-preserver/index.test.ts | 56 +-- src/hooks/hashline-edit-disabler/constants.ts | 3 + src/hooks/hashline-edit-disabler/hook.ts | 37 ++ .../hashline-edit-disabler/index.test.ts | 168 +++++++++ src/hooks/hashline-edit-disabler/index.ts | 2 + src/hooks/hashline-read-enhancer/hook.ts | 74 ++++ .../hashline-read-enhancer/index.test.ts | 299 ++++++++++++++++ src/hooks/hashline-read-enhancer/index.ts | 1 + src/hooks/index.ts | 2 + src/plugin/chat-params.ts | 4 + src/plugin/hooks/create-tool-guard-hooks.ts | 14 + src/plugin/tool-execute-after.ts | 1 + src/plugin/tool-execute-before.ts | 1 + src/plugin/tool-registry.ts | 7 + .../hashline-edit/edit-operations.test.ts | 321 ++++++++++++++++++ src/tools/hashline-edit/edit-operations.ts | 123 +++++++ src/tools/hashline-edit/index.ts | 8 + src/tools/hashline-edit/tools.test.ts | 239 +++++++++++++ src/tools/hashline-edit/tools.ts | 137 ++++++++ src/tools/index.ts | 1 + 26 files changed, 1509 insertions(+), 27 deletions(-) create mode 100644 src/hooks/hashline-edit-disabler/constants.ts create mode 100644 src/hooks/hashline-edit-disabler/hook.ts create mode 100644 src/hooks/hashline-edit-disabler/index.test.ts create mode 100644 src/hooks/hashline-edit-disabler/index.ts create mode 100644 src/hooks/hashline-read-enhancer/hook.ts create mode 100644 src/hooks/hashline-read-enhancer/index.test.ts create mode 100644 src/hooks/hashline-read-enhancer/index.ts create mode 100644 src/tools/hashline-edit/edit-operations.test.ts create mode 100644 src/tools/hashline-edit/edit-operations.ts create mode 100644 src/tools/hashline-edit/tools.test.ts create mode 100644 src/tools/hashline-edit/tools.ts 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 From 359c6b6655f5c828d0a162e2c6ee20d070e6b6a5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 16:42:38 +0900 Subject: [PATCH 3/7] fix(hashline): address Cubic review comments - P2: Change replace edit sorting from POSITIVE_INFINITY to NEGATIVE_INFINITY so replace edits run LAST after line-based edits, preventing line number shifts that would invalidate subsequent anchors - P3: Update tool description from SHA-256 to xxHash32 to match actual implementation in hash-computation.ts --- src/tools/hashline-edit/edit-operations.ts | 2 +- src/tools/hashline-edit/tools.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/hashline-edit/edit-operations.ts b/src/tools/hashline-edit/edit-operations.ts index a91863ee9..7a6340db6 100644 --- a/src/tools/hashline-edit/edit-operations.ts +++ b/src/tools/hashline-edit/edit-operations.ts @@ -62,7 +62,7 @@ function getEditLineNumber(edit: HashlineEdit): number { case "insert_after": return parseLineRef(edit.line).line case "replace": - return Number.POSITIVE_INFINITY + return Number.NEGATIVE_INFINITY default: return Number.POSITIVE_INFINITY } diff --git a/src/tools/hashline-edit/tools.ts b/src/tools/hashline-edit/tools.ts index a4bfab18f..8c970582e 100644 --- a/src/tools/hashline-edit/tools.ts +++ b/src/tools/hashline-edit/tools.ts @@ -41,7 +41,7 @@ export function createHashlineEditTool(): ToolDefinition { 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) +- 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: From 25f2003962c7db8f00902af8766fb1ce271e196f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 18:20:56 +0900 Subject: [PATCH 4/7] fix(ci): isolate session-manager tests to prevent flakiness - Move src/tools/session-manager to isolated test section - Prevents mock.module() pollution across parallel test runs - Fixes 4 flaky storage tests that failed in CI --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 \ From b56c777943d8838cc1e79f009f945c5319badd2b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 18:24:20 +0900 Subject: [PATCH 5/7] test: skip 4 flaky session-manager tests (test order dependency) --- src/tools/session-manager/storage.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 63d3eca28..07da80141 100644 --- a/src/tools/session-manager/storage.test.ts +++ b/src/tools/session-manager/storage.test.ts @@ -71,7 +71,8 @@ describe("session-manager storage", () => { expect(sessions).toEqual([]) }) - test("getMessageDir finds session in direct path", () => { + // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency + test.skip("getMessageDir finds session in direct path", () => { // given const sessionID = "ses_test123" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -93,7 +94,8 @@ describe("session-manager storage", () => { expect(exists).toBe(false) }) - test("sessionExists returns true for existing session", async () => { + // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency + test.skip("sessionExists returns true for existing session", async () => { // given const sessionID = "ses_exists" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -115,7 +117,8 @@ describe("session-manager storage", () => { expect(messages).toEqual([]) }) - test("readSessionMessages sorts messages by timestamp", async () => { + // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency + test.skip("readSessionMessages sorts messages by timestamp", async () => { // given const sessionID = "ses_test123" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -155,7 +158,8 @@ describe("session-manager storage", () => { expect(info).toBeNull() }) - test("getSessionInfo aggregates session metadata correctly", async () => { + // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency + test.skip("getSessionInfo aggregates session metadata correctly", async () => { // given const sessionID = "ses_test123" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) From 9eb786debda1c593a2ca3b43952abaf94923a9ef Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 18:31:32 +0900 Subject: [PATCH 6/7] test(session-manager): fix storage tests by mocking message-dir dependency --- src/tools/session-manager/storage.test.ts | 35 +++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/src/tools/session-manager/storage.test.ts b/src/tools/session-manager/storage.test.ts index 07da80141..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") @@ -71,8 +92,7 @@ describe("session-manager storage", () => { expect(sessions).toEqual([]) }) - // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency - test.skip("getMessageDir finds session in direct path", () => { + test("getMessageDir finds session in direct path", () => { // given const sessionID = "ses_test123" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -94,8 +114,7 @@ describe("session-manager storage", () => { expect(exists).toBe(false) }) - // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency - test.skip("sessionExists returns true for existing session", async () => { + test("sessionExists returns true for existing session", async () => { // given const sessionID = "ses_exists" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -117,8 +136,7 @@ describe("session-manager storage", () => { expect(messages).toEqual([]) }) - // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency - test.skip("readSessionMessages sorts messages by timestamp", async () => { + test("readSessionMessages sorts messages by timestamp", async () => { // given const sessionID = "ses_test123" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) @@ -158,8 +176,7 @@ describe("session-manager storage", () => { expect(info).toBeNull() }) - // FIXME: Flaky test - fails when run in isolation due to mock.module() order dependency - test.skip("getSessionInfo aggregates session metadata correctly", async () => { + test("getSessionInfo aggregates session metadata correctly", async () => { // given const sessionID = "ses_test123" const sessionPath = join(TEST_MESSAGE_STORAGE, sessionID) From af7b1ee620ff779f489191b24c4efb012e537734 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 22:02:52 +0900 Subject: [PATCH 7/7] refactor(hashline): override native edit tool instead of separate tool + disabler hook Replace 3-component hashline system (separate hashline_edit tool + edit disabler hook + OpenAI-exempted read enhancer) with 2-component system that directly overrides the native edit tool key, matching the delegate_task pattern. - Register hashline tool as 'edit' key to override native edit - Delete hashline-edit-disabler hook (no longer needed) - Delete hashline-provider-state module (no remaining consumers) - Remove OpenAI exemption from read enhancer (explicit opt-in means all providers) - Remove setProvider wiring from chat-params --- assets/oh-my-opencode.schema.json | 1 - src/config/schema/hooks.ts | 1 - src/features/hashline-provider-state.test.ts | 113 ------------ src/features/hashline-provider-state.ts | 13 -- src/hooks/hashline-edit-disabler/constants.ts | 3 - src/hooks/hashline-edit-disabler/hook.ts | 37 ---- .../hashline-edit-disabler/index.test.ts | 168 ------------------ src/hooks/hashline-edit-disabler/index.ts | 2 - src/hooks/hashline-read-enhancer/hook.ts | 14 +- .../hashline-read-enhancer/index.test.ts | 53 +----- src/hooks/index.ts | 1 - src/plugin/chat-params.ts | 4 - src/plugin/hooks/create-tool-guard-hooks.ts | 7 - src/plugin/tool-execute-before.ts | 2 - src/plugin/tool-registry.ts | 2 +- 15 files changed, 5 insertions(+), 416 deletions(-) delete mode 100644 src/features/hashline-provider-state.test.ts delete mode 100644 src/features/hashline-provider-state.ts delete mode 100644 src/hooks/hashline-edit-disabler/constants.ts delete mode 100644 src/hooks/hashline-edit-disabler/hook.ts delete mode 100644 src/hooks/hashline-edit-disabler/index.test.ts delete mode 100644 src/hooks/hashline-edit-disabler/index.ts diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index b3ab6ce21..8106f6e29 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -99,7 +99,6 @@ "tasks-todowrite-disabler", "write-existing-file-guard", "anthropic-effort", - "hashline-edit-disabler", "hashline-read-enhancer" ] } diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index a4461e2c3..d0e1e1917 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -45,7 +45,6 @@ export const HookNameSchema = z.enum([ "tasks-todowrite-disabler", "write-existing-file-guard", "anthropic-effort", - "hashline-edit-disabler", "hashline-read-enhancer", ]) diff --git a/src/features/hashline-provider-state.test.ts b/src/features/hashline-provider-state.test.ts deleted file mode 100644 index d8eb272ff..000000000 --- a/src/features/hashline-provider-state.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test, beforeEach } from "bun:test" -import { setProvider, getProvider, clearProvider } from "./hashline-provider-state" - -describe("hashline-provider-state", () => { - beforeEach(() => { - // Clear state before each test - clearProvider("test-session-1") - clearProvider("test-session-2") - }) - - describe("setProvider", () => { - test("should store provider ID for a session", () => { - // given - const sessionID = "test-session-1" - const providerID = "openai" - - // when - setProvider(sessionID, providerID) - - // then - expect(getProvider(sessionID)).toBe("openai") - }) - - test("should overwrite existing provider for same session", () => { - // given - const sessionID = "test-session-1" - setProvider(sessionID, "openai") - - // when - setProvider(sessionID, "anthropic") - - // then - expect(getProvider(sessionID)).toBe("anthropic") - }) - }) - - describe("getProvider", () => { - test("should return undefined for non-existent session", () => { - // given - const sessionID = "non-existent-session" - - // when - const result = getProvider(sessionID) - - // then - expect(result).toBeUndefined() - }) - - test("should return stored provider ID", () => { - // given - const sessionID = "test-session-1" - setProvider(sessionID, "anthropic") - - // when - const result = getProvider(sessionID) - - // then - expect(result).toBe("anthropic") - }) - - test("should handle multiple sessions independently", () => { - // given - setProvider("session-1", "openai") - setProvider("session-2", "anthropic") - - // when - const result1 = getProvider("session-1") - const result2 = getProvider("session-2") - - // then - expect(result1).toBe("openai") - expect(result2).toBe("anthropic") - }) - }) - - describe("clearProvider", () => { - test("should remove provider for a session", () => { - // given - const sessionID = "test-session-1" - setProvider(sessionID, "openai") - - // when - clearProvider(sessionID) - - // then - expect(getProvider(sessionID)).toBeUndefined() - }) - - test("should not affect other sessions", () => { - // given - setProvider("session-1", "openai") - setProvider("session-2", "anthropic") - - // when - clearProvider("session-1") - - // then - expect(getProvider("session-1")).toBeUndefined() - expect(getProvider("session-2")).toBe("anthropic") - }) - - test("should handle clearing non-existent session gracefully", () => { - // given - const sessionID = "non-existent" - - // when - clearProvider(sessionID) - - // then - expect(getProvider(sessionID)).toBeUndefined() - }) - }) -}) diff --git a/src/features/hashline-provider-state.ts b/src/features/hashline-provider-state.ts deleted file mode 100644 index 5c04a730f..000000000 --- a/src/features/hashline-provider-state.ts +++ /dev/null @@ -1,13 +0,0 @@ -const providerStateMap = new Map() - -export function setProvider(sessionID: string, providerID: string): void { - providerStateMap.set(sessionID, providerID) -} - -export function getProvider(sessionID: string): string | undefined { - return providerStateMap.get(sessionID) -} - -export function clearProvider(sessionID: string): void { - providerStateMap.delete(sessionID) -} diff --git a/src/hooks/hashline-edit-disabler/constants.ts b/src/hooks/hashline-edit-disabler/constants.ts deleted file mode 100644 index a60019650..000000000 --- a/src/hooks/hashline-edit-disabler/constants.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index aa49e26a4..000000000 --- a/src/hooks/hashline-edit-disabler/hook.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 2112497d1..000000000 --- a/src/hooks/hashline-edit-disabler/index.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -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 deleted file mode 100644 index 7bc6f96ea..000000000 --- a/src/hooks/hashline-edit-disabler/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 index 5aefbf31a..18dfabe2e 100644 --- a/src/hooks/hashline-read-enhancer/hook.ts +++ b/src/hooks/hashline-read-enhancer/hook.ts @@ -1,5 +1,4 @@ import type { PluginInput } from "@opencode-ai/plugin" -import { getProvider } from "../../features/hashline-provider-state" import { computeLineHash } from "../../tools/hashline-edit/hash-computation" interface HashlineReadEnhancerConfig { @@ -12,15 +11,8 @@ 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 shouldProcess(config: HashlineReadEnhancerConfig): boolean { + return config.hashline_edit?.enabled ?? false } function isTextFile(output: string): boolean { @@ -65,7 +57,7 @@ export function createHashlineReadEnhancerHook( if (typeof output.output !== "string") { return } - if (!shouldProcess(input.sessionID, config)) { + 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 index 243e00970..0c640f09d 100644 --- a/src/hooks/hashline-read-enhancer/index.test.ts +++ b/src/hooks/hashline-read-enhancer/index.test.ts @@ -1,7 +1,6 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { describe, it, expect, beforeEach } 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 { @@ -27,11 +26,6 @@ describe("createHashlineReadEnhancerHook", () => { beforeEach(() => { mockCtx = createMockContext() - clearProvider(sessionID) - }) - - afterEach(() => { - clearProvider(sessionID) }) describe("tool name matching", () => { @@ -120,51 +114,6 @@ describe("createHashlineReadEnhancerHook", () => { }) }) - 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 diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 16ebdc230..bdc27211d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -43,5 +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 { 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 f91108116..8f996a887 100644 --- a/src/plugin/chat-params.ts +++ b/src/plugin/chat-params.ts @@ -1,5 +1,3 @@ -import { setProvider } from "../features/hashline-provider-state" - type ChatParamsInput = { sessionID: string agent: { name?: string } @@ -68,8 +66,6 @@ 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 062cf6d6e..46a36140c 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -10,7 +10,6 @@ import { createRulesInjectorHook, createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, - createHashlineEditDisablerHook, createHashlineReadEnhancerHook, } from "../../hooks" import { @@ -30,7 +29,6 @@ export type ToolGuardHooks = { rulesInjector: ReturnType | null tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null - hashlineEditDisabler: ReturnType | null hashlineReadEnhancer: ReturnType | null } @@ -89,10 +87,6 @@ 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 @@ -106,7 +100,6 @@ export function createToolGuardHooks(args: { rulesInjector, tasksTodowriteDisabler, writeExistingFileGuard, - hashlineEditDisabler, hashlineReadEnhancer, } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 42a876ddd..70d023b4c 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -29,8 +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) - await hooks.hashlineEditDisabler?.["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 99993c4d5..b4de8b5c1 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -120,7 +120,7 @@ export function createToolRegistry(args: { const hashlineEnabled = pluginConfig.experimental?.hashline_edit ?? false const hashlineToolsRecord: Record = hashlineEnabled - ? { hashline_edit: createHashlineEditTool() } + ? { edit: createHashlineEditTool() } : {} const allTools: Record = {