51dde4d43f
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)
161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
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
|
|
|
|
const mockServerClose = mock(() => {})
|
|
const mockCreateOpencode = mock(() =>
|
|
Promise.resolve({
|
|
client: { session: {} },
|
|
server: { url: "http://127.0.0.1:4096", close: mockServerClose },
|
|
})
|
|
)
|
|
const mockCreateOpencodeClient = mock(() => ({ session: {} }))
|
|
const mockIsPortAvailable = mock(() => Promise.resolve(true))
|
|
const mockGetAvailableServerPort = mock(() => Promise.resolve({ port: 4096, wasAutoSelected: false }))
|
|
const mockConsoleLog = mock(() => {})
|
|
|
|
mock.module("@opencode-ai/sdk", () => ({
|
|
createOpencode: mockCreateOpencode,
|
|
createOpencodeClient: mockCreateOpencodeClient,
|
|
}))
|
|
|
|
mock.module("../../shared/port-utils", () => ({
|
|
isPortAvailable: mockIsPortAvailable,
|
|
getAvailableServerPort: mockGetAvailableServerPort,
|
|
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", () => {
|
|
beforeEach(() => {
|
|
mockCreateOpencode.mockClear()
|
|
mockCreateOpencodeClient.mockClear()
|
|
mockIsPortAvailable.mockClear()
|
|
mockGetAvailableServerPort.mockClear()
|
|
mockServerClose.mockClear()
|
|
mockConsoleLog.mockClear()
|
|
globalThis.console = { ...console, log: mockConsoleLog } as typeof console
|
|
})
|
|
|
|
afterEach(() => {
|
|
globalThis.console = originalConsole
|
|
})
|
|
|
|
it("attach mode returns client with no-op cleanup", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
const attachUrl = "http://localhost:8080"
|
|
|
|
// when
|
|
const result = await createServerConnection({ attach: attachUrl, signal })
|
|
|
|
// then
|
|
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: attachUrl })
|
|
expect(result.client).toBeDefined()
|
|
expect(result.cleanup).toBeDefined()
|
|
result.cleanup()
|
|
expect(mockServerClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("explicit port starts server when port is available", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
const port = 8080
|
|
mockIsPortAvailable.mockResolvedValueOnce(true)
|
|
|
|
// when
|
|
const result = await createServerConnection({ port, signal })
|
|
|
|
// then
|
|
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
|
|
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 8080, hostname: "127.0.0.1" })
|
|
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
|
expect(result.client).toBeDefined()
|
|
expect(result.cleanup).toBeDefined()
|
|
result.cleanup()
|
|
expect(mockServerClose).toHaveBeenCalled()
|
|
})
|
|
|
|
it("explicit port attaches when port is occupied", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
const port = 8080
|
|
mockIsPortAvailable.mockResolvedValueOnce(false)
|
|
|
|
// when
|
|
const result = await createServerConnection({ port, signal })
|
|
|
|
// then
|
|
expect(mockIsPortAvailable).toHaveBeenCalledWith(8080, "127.0.0.1")
|
|
expect(mockCreateOpencode).not.toHaveBeenCalled()
|
|
expect(mockCreateOpencodeClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:8080" })
|
|
expect(result.client).toBeDefined()
|
|
expect(result.cleanup).toBeDefined()
|
|
result.cleanup()
|
|
expect(mockServerClose).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("auto mode uses getAvailableServerPort", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
mockGetAvailableServerPort.mockResolvedValueOnce({ port: 4100, wasAutoSelected: true })
|
|
|
|
// when
|
|
const result = await createServerConnection({ signal })
|
|
|
|
// then
|
|
expect(mockGetAvailableServerPort).toHaveBeenCalledWith(4096, "127.0.0.1")
|
|
expect(mockCreateOpencode).toHaveBeenCalledWith({ signal, port: 4100, hostname: "127.0.0.1" })
|
|
expect(mockCreateOpencodeClient).not.toHaveBeenCalled()
|
|
expect(result.client).toBeDefined()
|
|
expect(result.cleanup).toBeDefined()
|
|
result.cleanup()
|
|
expect(mockServerClose).toHaveBeenCalled()
|
|
})
|
|
|
|
it("invalid port throws error", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
|
|
// when & then
|
|
await expect(createServerConnection({ port: 0, signal })).rejects.toThrow("Port must be between 1 and 65535")
|
|
await expect(createServerConnection({ port: -1, signal })).rejects.toThrow("Port must be between 1 and 65535")
|
|
await expect(createServerConnection({ port: 99999, signal })).rejects.toThrow("Port must be between 1 and 65535")
|
|
})
|
|
|
|
it("cleanup calls server.close for owned server", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
mockIsPortAvailable.mockResolvedValueOnce(true)
|
|
|
|
// when
|
|
const result = await createServerConnection({ port: 8080, signal })
|
|
result.cleanup()
|
|
|
|
// then
|
|
expect(mockServerClose).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it("cleanup is no-op for attached server", async () => {
|
|
// given
|
|
const signal = new AbortController().signal
|
|
const attachUrl = "http://localhost:8080"
|
|
|
|
// when
|
|
const result = await createServerConnection({ attach: attachUrl, signal })
|
|
result.cleanup()
|
|
|
|
// then
|
|
expect(mockServerClose).not.toHaveBeenCalled()
|
|
})
|
|
})
|