YeonGyu-Kim 51dde4d43f 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)
2026-02-17 00:03:10 +09:00

128 lines
4.1 KiB
TypeScript

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 () => {})
mock.module("./deduplication-recovery", () => ({
attemptDeduplicationRecovery: attemptDeduplicationRecoveryMock,
}))
afterAll(() => {
mock.module("./deduplication-recovery", () => originalDeduplicationRecovery)
})
function createImmediateTimeouts(): () => void {
const originalSetTimeout = globalThis.setTimeout
const originalClearTimeout = globalThis.clearTimeout
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, _delay?: number, ...args: unknown[]) => {
callback(...args)
return 0 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((_: ReturnType<typeof setTimeout>) => {}) as typeof clearTimeout
return () => {
globalThis.setTimeout = originalSetTimeout
globalThis.clearTimeout = originalClearTimeout
}
}
describe("createAnthropicContextWindowLimitRecoveryHook", () => {
beforeEach(() => {
attemptDeduplicationRecoveryMock.mockClear()
})
test("calls deduplication recovery when compaction is already in progress", async () => {
//#given
const restoreTimeouts = createImmediateTimeouts()
const experimental = {
dynamic_context_pruning: {
enabled: true,
strategies: {
deduplication: { enabled: true },
},
},
} satisfies ExperimentalConfig
let resolveSummarize: (() => void) | null = null
const summarizePromise = new Promise<void>((resolve) => {
resolveSummarize = resolve
})
const mockClient = {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => summarizePromise),
revert: mock(() => Promise.resolve()),
promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
try {
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx, { experimental })
// first error triggers compaction (setTimeout runs immediately due to mock)
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-96", error: "prompt is too long" },
},
})
//#when - second error while compaction is in progress
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-96", error: "prompt is too long" },
},
})
//#then - deduplication recovery was called for the second error
expect(attemptDeduplicationRecoveryMock).toHaveBeenCalledTimes(1)
expect(attemptDeduplicationRecoveryMock.mock.calls[0]![0]).toBe("session-96")
} finally {
if (resolveSummarize) resolveSummarize()
restoreTimeouts()
}
})
test("does not call deduplication when compaction is not in progress", async () => {
//#given
const mockClient = {
session: {
messages: mock(() => Promise.resolve({ data: [] })),
summarize: mock(() => Promise.resolve()),
revert: mock(() => Promise.resolve()),
promptAsync: mock(() => Promise.resolve()),
},
tui: {
showToast: mock(() => Promise.resolve()),
},
}
const { createAnthropicContextWindowLimitRecoveryHook } = await import("./recovery-hook")
const ctx = { client: mockClient, directory: "/tmp" } as PluginInput
const hook = createAnthropicContextWindowLimitRecoveryHook(ctx)
//#when - single error (no compaction in progress)
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-no-dedup", error: "some other error" },
},
})
//#then
expect(attemptDeduplicationRecoveryMock).not.toHaveBeenCalled()
})
})