Merge branch 'dev' into fix/1681-oracle-json-parse

This commit is contained in:
YeonGyu-Kim
2026-02-17 01:54:03 +09:00
committed by GitHub
55 changed files with 2689 additions and 144 deletions
+31 -2
View File
@@ -29,11 +29,17 @@ export interface RunOptions {
}
export async function runSg(options: RunOptions): Promise<SgResult> {
// ast-grep CLI silently ignores --update-all when --json is present.
// When both rewrite and updateAll are requested, we must run two separate
// invocations: one with --json=compact to collect match results, and
// another with --update-all to perform the actual file writes.
const shouldSeparateWritePass = !!(options.rewrite && options.updateAll)
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
if (options.rewrite) {
args.push("-r", options.rewrite)
if (options.updateAll) {
if (options.updateAll && !shouldSeparateWritePass) {
args.push("--update-all")
}
}
@@ -144,5 +150,28 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
return { matches: [], totalMatches: 0, truncated: false }
}
return createSgResultFromStdout(stdout)
const jsonResult = createSgResultFromStdout(stdout)
if (shouldSeparateWritePass && jsonResult.matches.length > 0) {
const writeArgs = args.filter(a => a !== "--json=compact")
writeArgs.push("--update-all")
const writeProc = spawn([cliPath, ...writeArgs], {
stdout: "pipe",
stderr: "pipe",
})
try {
const writeOutput = await collectProcessOutputWithTimeout(writeProc, timeout)
if (writeOutput.exitCode !== 0) {
const errorDetail = writeOutput.stderr.trim() || `ast-grep exited with code ${writeOutput.exitCode}`
return { ...jsonResult, error: `Replace failed: ${errorDetail}` }
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return { ...jsonResult, error: `Replace failed: ${errorMessage}` }
}
}
return jsonResult
}
@@ -4,44 +4,88 @@ import { resolveOrCreateSessionId } from "./subagent-session-creator"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
describe("call-omo-agent resolveOrCreateSessionId", () => {
test("tracks newly created child session as subagent session", async () => {
// given
_resetForTesting()
const originalPlatform = process.platform
function buildInput(options: {
parentDirectory?: string
contextDirectory: string
}): {
ctx: Parameters<typeof resolveOrCreateSessionId>[0]
args: Parameters<typeof resolveOrCreateSessionId>[1]
toolContext: Parameters<typeof resolveOrCreateSessionId>[2]
createCalls: Array<{ query?: { directory?: string } }>
} {
const createCalls: Array<{ query?: { directory?: string } }> = []
const { parentDirectory, contextDirectory } = options
const parentSessionData = parentDirectory ? { data: { directory: parentDirectory } } : { data: {} }
const createCalls: Array<unknown> = []
const ctx = {
directory: "/project",
directory: contextDirectory,
client: {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (args: unknown) => {
createCalls.push(args)
get: async () => parentSessionData,
create: async (createInput: unknown) => {
const payload = createInput as { query?: { directory?: string } }
createCalls.push(payload)
return { data: { id: "ses_child_sync" } }
},
},
},
}
} as unknown as Parameters<typeof resolveOrCreateSessionId>[0]
const args = {
description: "sync test",
prompt: "hello",
subagent_type: "explore",
run_in_background: false,
}
} satisfies Parameters<typeof resolveOrCreateSessionId>[1]
const toolContext = {
sessionID: "ses_parent",
messageID: "msg_parent",
agent: "sisyphus",
abort: new AbortController().signal,
}
} satisfies Parameters<typeof resolveOrCreateSessionId>[2]
// when
const result = await resolveOrCreateSessionId(ctx as any, args as any, toolContext as any)
return { ctx, args, toolContext, createCalls }
}
// then
test("tracks newly created child session as subagent session", async () => {
//#given
_resetForTesting()
const { ctx, args, toolContext, createCalls } = buildInput({
parentDirectory: "/parent",
contextDirectory: "/project",
})
//#when
const result = await resolveOrCreateSessionId(ctx, args, toolContext)
//#then
expect(result).toEqual({ ok: true, sessionID: "ses_child_sync" })
expect(createCalls).toHaveLength(1)
expect(subagentSessions.has("ses_child_sync")).toBe(true)
})
test("uses current working directory on Windows when parent directory is under AppData", async () => {
//#given
_resetForTesting()
Object.defineProperty(process, "platform", { value: "win32" })
try {
const { ctx, args, toolContext, createCalls } = buildInput({
parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop",
contextDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode",
})
//#when
await resolveOrCreateSessionId(ctx, args, toolContext)
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.query?.directory).toBe(process.cwd())
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform })
}
})
})
@@ -1,5 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import { resolveSessionDirectory } from "../../shared"
import { subagentSessions } from "../../features/claude-code-session-state"
import type { CallOmoAgentArgs } from "./types"
import type { ToolContextWithMetadata } from "./tool-context-with-metadata"
@@ -27,11 +28,14 @@ export async function resolveOrCreateSessionId(
log(`[call_omo_agent] Creating new session with parent: ${toolContext.sessionID}`)
const parentSession = await ctx.client.session
.get({ path: { id: toolContext.sessionID } })
.catch((err) => {
.catch((err: unknown) => {
log("[call_omo_agent] Failed to get parent session", { error: String(err) })
return null
})
const parentDirectory = parentSession?.data?.directory ?? ctx.directory
const parentDirectory = resolveSessionDirectory({
parentDirectory: parentSession?.data?.directory,
fallbackDirectory: ctx.directory,
})
const body = {
parentID: toolContext.sessionID,
+102
View File
@@ -0,0 +1,102 @@
import { describe, test, expect, mock } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent"
import { createCallOmoAgent } from "./tools"
describe("createCallOmoAgent", () => {
const mockCtx = {
client: {},
directory: "/test",
} as unknown as PluginInput
const mockBackgroundManager = {
launch: mock(() => Promise.resolve({
id: "test-task-id",
sessionID: null,
description: "Test task",
agent: "test-agent",
status: "pending",
})),
} as unknown as BackgroundManager
test("should reject agent in disabled_agents list", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["explore"])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
expect(result).toContain("disabled via disabled_agents")
})
test("should reject agent in disabled_agents list with case-insensitive matching", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["Explore"])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
expect(result).toContain("disabled via disabled_agents")
})
test("should allow agent not in disabled_agents list", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, ["librarian"])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
// Should not contain disabled error - may fail for other reasons but disabled check should pass
expect(result).not.toContain("disabled via disabled_agents")
})
test("should allow all agents when disabled_agents is empty", async () => {
//#given
const toolDef = createCallOmoAgent(mockCtx, mockBackgroundManager, [])
const executeFunc = toolDef.execute as Function
//#when
const result = await executeFunc(
{
description: "Test",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
expect(result).not.toContain("disabled via disabled_agents")
})
})
+7 -1
View File
@@ -8,7 +8,8 @@ import { executeSync } from "./sync-executor"
export function createCallOmoAgent(
ctx: PluginInput,
backgroundManager: BackgroundManager
backgroundManager: BackgroundManager,
disabledAgents: string[] = []
): ToolDefinition {
const agentDescriptions = ALLOWED_AGENTS.map(
(name) => `- ${name}: Specialized agent for ${name} tasks`
@@ -44,6 +45,11 @@ export function createCallOmoAgent(
const normalizedAgent = args.subagent_type.toLowerCase() as AllowedAgentType
args = { ...args, subagent_type: normalizedAgent }
// Check if agent is disabled
if (disabledAgents.some((disabled) => disabled.toLowerCase() === normalizedAgent)) {
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.`
}
if (args.run_in_background) {
if (args.session_id) {
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
@@ -0,0 +1,82 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn, mock } = require("bun:test")
import { resolveSubagentExecution } from "./subagent-resolver"
import type { DelegateTaskArgs } from "./types"
import type { ExecutorContext } from "./executor-types"
import * as logger from "../../shared/logger"
function createBaseArgs(overrides?: Partial<DelegateTaskArgs>): DelegateTaskArgs {
return {
description: "Run review",
prompt: "Review the current changes",
run_in_background: false,
load_skills: [],
subagent_type: "oracle",
...overrides,
}
}
function createExecutorContext(agentsFn: () => Promise<unknown>): ExecutorContext {
const client = {
app: {
agents: agentsFn,
},
} as ExecutorContext["client"]
return {
client,
manager: {} as ExecutorContext["manager"],
directory: "/tmp/test",
}
}
describe("resolveSubagentExecution", () => {
let logSpy: ReturnType<typeof spyOn> | undefined
beforeEach(() => {
mock.restore()
logSpy = spyOn(logger, "log").mockImplementation(() => {})
})
afterEach(() => {
logSpy?.mockRestore()
})
test("returns delegation error when agent discovery fails instead of silently proceeding", async () => {
//#given
const resolverError = new Error("agents API unavailable")
const args = createBaseArgs()
const executorCtx = createExecutorContext(async () => {
throw resolverError
})
//#when
const result = await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(result.agentToUse).toBe("")
expect(result.categoryModel).toBeUndefined()
expect(result.error).toBe("Failed to delegate to agent \"oracle\": agents API unavailable")
})
test("logs failure details when subagent resolution throws", async () => {
//#given
const args = createBaseArgs({ subagent_type: "review" })
const executorCtx = createExecutorContext(async () => {
throw new Error("network timeout")
})
//#when
await resolveSubagentExecution(args, executorCtx, "sisyphus", "deep")
//#then
expect(logSpy).toHaveBeenCalledTimes(1)
const callArgs = logSpy?.mock.calls[0]
expect(callArgs?.[0]).toBe("[delegate-task] Failed to resolve subagent execution")
expect(callArgs?.[1]).toEqual({
requestedAgent: "review",
parentAgent: "sisyphus",
error: "network timeout",
})
})
})
+14 -2
View File
@@ -6,6 +6,7 @@ import { parseModelString } from "./model-string-parser"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names"
import { normalizeSDKResponse } from "../../shared"
import { log } from "../../shared/logger"
import { getAvailableModelsForDelegateTask } from "./available-models"
import { resolveModelForDelegateTask } from "./model-selection"
@@ -119,8 +120,19 @@ Create the work plan directly - that's your job as the planning agent.`,
if (!categoryModel && matchedAgent.model) {
categoryModel = matchedAgent.model
}
} catch {
// Proceed anyway - session.prompt will fail with clearer error if agent doesn't exist
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
log("[delegate-task] Failed to resolve subagent execution", {
requestedAgent: agentToUse,
parentAgent,
error: errorMessage,
})
return {
agentToUse: "",
categoryModel: undefined,
error: `Failed to delegate to agent "${agentToUse}": ${errorMessage}`,
}
}
return { agentToUse, categoryModel }
+30
View File
@@ -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})\|(.*)$/
@@ -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")
})
})
+123
View File
@@ -0,0 +1,123 @@
import { parseLineRef, validateLineRef } from "./validation"
import type { HashlineEdit } from "./types"
function unescapeNewlines(text: string): string {
return text.replace(/\\n/g, "\n")
}
export function applySetLine(lines: string[], anchor: string, newText: string): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
result[line - 1] = unescapeNewlines(newText)
return result
}
export function applyReplaceLines(
lines: string[],
startAnchor: string,
endAnchor: string,
newText: string
): string[] {
validateLineRef(lines, startAnchor)
validateLineRef(lines, endAnchor)
const { line: startLine } = parseLineRef(startAnchor)
const { line: endLine } = parseLineRef(endAnchor)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const result = [...lines]
const newLines = unescapeNewlines(newText).split("\n")
result.splice(startLine - 1, endLine - startLine + 1, ...newLines)
return result
}
export function applyInsertAfter(lines: string[], anchor: string, text: string): string[] {
validateLineRef(lines, anchor)
const { line } = parseLineRef(anchor)
const result = [...lines]
const newLines = unescapeNewlines(text).split("\n")
result.splice(line, 0, ...newLines)
return result
}
export function applyReplace(content: string, oldText: string, newText: string): string {
if (!content.includes(oldText)) {
throw new Error(`Text not found: "${oldText}"`)
}
return content.replaceAll(oldText, unescapeNewlines(newText))
}
function getEditLineNumber(edit: HashlineEdit): number {
switch (edit.type) {
case "set_line":
return parseLineRef(edit.line).line
case "replace_lines":
return parseLineRef(edit.end_line).line
case "insert_after":
return parseLineRef(edit.line).line
case "replace":
return Number.NEGATIVE_INFINITY
default:
return Number.POSITIVE_INFINITY
}
}
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
if (edits.length === 0) {
return content
}
const sortedEdits = [...edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
let result = content
let lines = result.split("\n")
for (const edit of sortedEdits) {
switch (edit.type) {
case "set_line": {
validateLineRef(lines, edit.line)
const { line } = parseLineRef(edit.line)
lines[line - 1] = unescapeNewlines(edit.text)
break
}
case "replace_lines": {
validateLineRef(lines, edit.start_line)
validateLineRef(lines, edit.end_line)
const { line: startLine } = parseLineRef(edit.start_line)
const { line: endLine } = parseLineRef(edit.end_line)
if (startLine > endLine) {
throw new Error(
`Invalid range: start line ${startLine} cannot be greater than end line ${endLine}`
)
}
const newLines = unescapeNewlines(edit.text).split("\n")
lines.splice(startLine - 1, endLine - startLine + 1, ...newLines)
break
}
case "insert_after": {
validateLineRef(lines, edit.line)
const { line } = parseLineRef(edit.line)
const newLines = unescapeNewlines(edit.text).split("\n")
lines.splice(line, 0, ...newLines)
break
}
case "replace": {
result = lines.join("\n")
if (!result.includes(edit.old_text)) {
throw new Error(`Text not found: "${edit.old_text}"`)
}
result = result.replaceAll(edit.old_text, unescapeNewlines(edit.new_text))
lines = result.split("\n")
break
}
}
}
return lines.join("\n")
}
@@ -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$/)
})
})
@@ -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")
}
+13
View File
@@ -0,0 +1,13 @@
export { computeLineHash, formatHashLine, formatHashLines } from "./hash-computation"
export { parseLineRef, validateLineRef } from "./validation"
export type { LineRef } from "./validation"
export type { SetLine, ReplaceLines, InsertAfter, Replace, HashlineEdit } from "./types"
export { HASH_DICT, HASHLINE_PATTERN } from "./constants"
export {
applyHashlineEdits,
applyInsertAfter,
applyReplace,
applyReplaceLines,
applySetLine,
} from "./edit-operations"
export { createHashlineEditTool } from "./tools"
+239
View File
@@ -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<typeof createHashlineEditTool>
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")
})
})
})
+137
View File
@@ -0,0 +1,137 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import type { HashlineEdit } from "./types"
import { applyHashlineEdits } from "./edit-operations"
import { computeLineHash } from "./hash-computation"
interface HashlineEditArgs {
path: string
edits: HashlineEdit[]
}
function generateDiff(oldContent: string, newContent: string, filePath: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
let diff = `--- ${filePath}\n+++ ${filePath}\n`
const maxLines = Math.max(oldLines.length, newLines.length)
for (let i = 0; i < maxLines; i++) {
const oldLine = oldLines[i] ?? ""
const newLine = newLines[i] ?? ""
const lineNum = i + 1
const hash = computeLineHash(lineNum, newLine)
if (i >= oldLines.length) {
diff += `+ ${lineNum}:${hash}|${newLine}\n`
} else if (i >= newLines.length) {
diff += `- ${lineNum}: |${oldLine}\n`
} else if (oldLine !== newLine) {
diff += `- ${lineNum}: |${oldLine}\n`
diff += `+ ${lineNum}:${hash}|${newLine}\n`
}
}
return diff
}
export function createHashlineEditTool(): ToolDefinition {
return tool({
description: `Edit files using LINE:HASH format for precise, safe modifications.
LINE:HASH FORMAT:
Each line reference must be in "LINE:HASH" format where:
- LINE: 1-based line number
- HASH: First 2 characters of xxHash32 hash of line content (computed with computeLineHash)
- Example: "5:a3|const x = 1" means line 5 with hash "a3"
GETTING HASHES:
Use the read tool - it returns lines in "LINE:HASH|content" format.
FOUR OPERATION TYPES:
1. set_line: Replace a single line
{ "type": "set_line", "line": "5:a3", "text": "const y = 2" }
2. replace_lines: Replace a range of lines
{ "type": "replace_lines", "start_line": "5:a3", "end_line": "7:b2", "text": "new\ncontent" }
3. insert_after: Insert lines after a specific line
{ "type": "insert_after", "line": "5:a3", "text": "console.log('hi')" }
4. replace: Simple text replacement (no hash validation)
{ "type": "replace", "old_text": "foo", "new_text": "bar" }
HASH MISMATCH HANDLING:
If the hash doesn't match the current content, the edit fails with a hash mismatch error. This prevents editing stale content.
BOTTOM-UP APPLICATION:
Edits are applied from bottom to top (highest line numbers first) to preserve line number references.
ESCAPING:
Use \\n in text to represent literal newlines.`,
args: {
path: tool.schema.string().describe("Absolute path to the file to edit"),
edits: tool.schema
.array(
tool.schema.union([
tool.schema.object({
type: tool.schema.literal("set_line"),
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
text: tool.schema.string().describe("New content for the line"),
}),
tool.schema.object({
type: tool.schema.literal("replace_lines"),
start_line: tool.schema.string().describe("Start line in LINE:HASH format"),
end_line: tool.schema.string().describe("End line in LINE:HASH format"),
text: tool.schema.string().describe("New content to replace the range"),
}),
tool.schema.object({
type: tool.schema.literal("insert_after"),
line: tool.schema.string().describe("Line reference in LINE:HASH format"),
text: tool.schema.string().describe("Content to insert after the line"),
}),
tool.schema.object({
type: tool.schema.literal("replace"),
old_text: tool.schema.string().describe("Text to find"),
new_text: tool.schema.string().describe("Replacement text"),
}),
])
)
.describe("Array of edit operations to apply"),
},
execute: async (args: HashlineEditArgs) => {
try {
const { path: filePath, edits } = args
if (!filePath) {
return "Error: path parameter is required"
}
if (!edits || !Array.isArray(edits) || edits.length === 0) {
return "Error: edits parameter must be a non-empty array"
}
const file = Bun.file(filePath)
const exists = await file.exists()
if (!exists) {
return `Error: File not found: ${filePath}`
}
const oldContent = await file.text()
const newContent = applyHashlineEdits(oldContent, edits)
await Bun.write(filePath, newContent)
const diff = generateDiff(oldContent, newContent, filePath)
return `Successfully applied ${edits.length} edit(s) to ${filePath}\n\n${diff}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes("hash")) {
return `Error: Hash mismatch - ${message}`
}
return `Error: ${message}`
}
},
})
}
+26
View File
@@ -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
+105
View File
@@ -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/)
})
})
+39
View File
@@ -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}"`
)
}
}
+1
View File
@@ -43,6 +43,7 @@ export {
createTaskList,
createTaskUpdateTool,
} from "./task"
export { createHashlineEditTool } from "./hashline-edit"
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
const outputManager: BackgroundOutputManager = manager
+22 -1
View File
@@ -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")