From fec12b63a6c8226c063111399c2b8968d69222bc Mon Sep 17 00:00:00 2001 From: jsl9208 Date: Tue, 10 Feb 2026 11:19:12 +0800 Subject: [PATCH 01/18] fix(ast-grep): fix ast_grep_replace silent write failure ast-grep CLI silently ignores --update-all when --json=compact is present, causing replace operations to report success while never modifying files. Split into two separate CLI invocations. --- src/tools/ast-grep/cli.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/tools/ast-grep/cli.ts b/src/tools/ast-grep/cli.ts index 868a1c544..86dc211ee 100644 --- a/src/tools/ast-grep/cli.ts +++ b/src/tools/ast-grep/cli.ts @@ -29,11 +29,17 @@ export interface RunOptions { } export async function runSg(options: RunOptions): Promise { + // 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 { 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 } From 0f5b8e921a3c5294730009313659dc50ffc39b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pe=C3=AFo=20Thibault?= Date: Tue, 10 Feb 2026 18:44:41 +0100 Subject: [PATCH 02/18] test(call-omo-agent): add disabled_agents validation tests Closes #1716 ## Summary - Added 4 tests for disabled_agents validation in call_omo_agent tool - Tests verify agent rejection when in disabled_agents list - Tests verify case-insensitive matching - Tests verify agents not in disabled list are allowed - Tests verify empty disabled_agents allows all agents --- src/tools/call-omo-agent/tools.test.ts | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/tools/call-omo-agent/tools.test.ts diff --git a/src/tools/call-omo-agent/tools.test.ts b/src/tools/call-omo-agent/tools.test.ts new file mode 100644 index 000000000..a560c8bea --- /dev/null +++ b/src/tools/call-omo-agent/tools.test.ts @@ -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") + }) +}) From cd0949ccfa1326974d1ae8b9fbc2fe516643f8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pe=C3=AFo=20Thibault?= Date: Tue, 10 Feb 2026 18:44:45 +0100 Subject: [PATCH 03/18] fix(call-omo-agent): enforce disabled_agents config (#1716) ## Summary - Added disabled_agents parameter to createCallOmoAgent factory - Check runs after ALLOWED_AGENTS validation, before agent execution - Case-insensitive matching consistent with existing patterns - Clear error message distinguishes 'disabled' from 'invalid agent type' - Threaded disabledAgents config into tool factory from pluginConfig ## Changes - tools.ts: Add disabledAgents parameter and validation check - tool-registry.ts: Pass pluginConfig.disabled_agents to factory --- src/plugin/tool-registry.ts | 2 +- src/tools/call-omo-agent/tools.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 7236ddc48..162b81ed7 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -48,7 +48,7 @@ export function createToolRegistry(args: { const { ctx, pluginConfig, managers, skillContext, availableCategories } = args const backgroundTools = createBackgroundTools(managers.backgroundManager, ctx.client) - const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager) + const callOmoAgent = createCallOmoAgent(ctx, managers.backgroundManager, pluginConfig.disabled_agents ?? []) const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( (agent) => agent.toLowerCase() === "multimodal-looker", diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index dbcfcf970..8eca44ea3 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -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.` From 5298ff2879e6cd390c1bdc0b652e369634ed554b Mon Sep 17 00:00:00 2001 From: Dan Kochetov Date: Mon, 16 Feb 2026 00:58:33 +0200 Subject: [PATCH 04/18] fix(background-agent): allow disabling parent session reminders --- src/features/background-agent/manager.test.ts | 46 ++++++ src/features/background-agent/manager.ts | 152 ++++++++++-------- 2 files changed, 128 insertions(+), 70 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 35f1afe44..0ade38611 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -981,6 +981,52 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => { }) }) +describe("BackgroundManager.notifyParentSession - notifications toggle", () => { + test("should skip parent prompt injection when notifications are disabled", async () => { + //#given + let promptCalled = false + const promptMock = async () => { + promptCalled = true + return {} + } + const client = { + session: { + prompt: promptMock, + promptAsync: promptMock, + abort: async () => ({}), + messages: async () => ({ data: [] }), + }, + } + const manager = new BackgroundManager( + { client, directory: tmpdir() } as unknown as PluginInput, + undefined, + { enableParentSessionNotifications: false }, + ) + const task: BackgroundTask = { + id: "task-no-parent-notification", + sessionID: "session-child", + parentSessionID: "session-parent", + parentMessageID: "msg-parent", + description: "task notifications disabled", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getPendingByParent(manager).set("session-parent", new Set([task.id])) + + //#when + await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise }) + .notifyParentSession(task) + + //#then + expect(promptCalled).toBe(false) + + manager.shutdown() + }) +}) + function buildNotificationPromptBody( task: BackgroundTask, currentMessage: CurrentMessage | null diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 86ab03d35..88641aae5 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -93,6 +93,7 @@ export class BackgroundManager { private completionTimers: Map> = new Map() private idleDeferralTimers: Map> = new Map() private notificationQueueByParent: Map> = new Map() + private enableParentSessionNotifications: boolean readonly taskHistory = new TaskHistory() constructor( @@ -102,6 +103,7 @@ export class BackgroundManager { tmuxConfig?: TmuxConfig onSubagentSessionCreated?: OnSubagentSessionCreated onShutdown?: () => void + enableParentSessionNotifications?: boolean } ) { this.tasks = new Map() @@ -114,6 +116,7 @@ export class BackgroundManager { this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false this.onSubagentSessionCreated = options?.onSubagentSessionCreated this.onShutdown = options?.onShutdown + this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true this.registerProcessCleanup() } @@ -1186,19 +1189,22 @@ export class BackgroundManager { allComplete = true } - const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED" - const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" - - let notification: string - let completedTasks: BackgroundTask[] = [] - if (allComplete) { - completedTasks = Array.from(this.tasks.values()) + const completedTasks = allComplete + ? Array.from(this.tasks.values()) .filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running" && t.status !== "pending") - const completedTasksText = completedTasks - .map(t => `- \`${t.id}\`: ${t.description}`) - .join("\n") + : [] - notification = ` + if (this.enableParentSessionNotifications) { + const statusText = task.status === "completed" ? "COMPLETED" : task.status === "interrupt" ? "INTERRUPTED" : "CANCELLED" + const errorInfo = task.error ? `\n**Error:** ${task.error}` : "" + + let notification: string + if (allComplete) { + const completedTasksText = completedTasks + .map(t => `- \`${t.id}\`: ${t.description}`) + .join("\n") + + notification = ` [ALL BACKGROUND TASKS COMPLETE] **Completed:** @@ -1206,9 +1212,9 @@ ${completedTasksText || `- \`${task.id}\`: ${task.description}`} Use \`background_output(task_id="")\` to retrieve each result. ` - } else { - // Individual completion - silent notification - notification = ` + } else { + // Individual completion - silent notification + notification = ` [BACKGROUND TASK ${statusText}] **ID:** \`${task.id}\` **Description:** ${task.description} @@ -1219,71 +1225,77 @@ Do NOT poll - continue productive work. Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready. ` - } + } - let agent: string | undefined = task.parentAgent - let model: { providerID: string; modelID: string } | undefined + let agent: string | undefined = task.parentAgent + let model: { providerID: string; modelID: string } | undefined - try { - const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) - const messages = (messagesResp.data ?? []) as Array<{ - info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } - }> - for (let i = messages.length - 1; i >= 0; i--) { - const info = messages[i].info - if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { - agent = info.agent ?? task.parentAgent - model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) - break + try { + const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } }) + const messages = (messagesResp.data ?? []) as Array<{ + info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string } + }> + for (let i = messages.length - 1; i >= 0; i--) { + const info = messages[i].info + if (info?.agent || info?.model || (info?.modelID && info?.providerID)) { + agent = info.agent ?? task.parentAgent + model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined) + break + } } + } catch (error) { + if (this.isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } + const messageDir = getMessageDir(task.parentSessionID) + const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null + agent = currentMessage?.agent ?? task.parentAgent + model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined } - } catch (error) { - if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { - taskId: task.id, - parentSessionID: task.parentSessionID, - }) - return - } - const messageDir = getMessageDir(task.parentSessionID) - const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null - agent = currentMessage?.agent ?? task.parentAgent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - } - log("[background-agent] notifyParentSession context:", { - taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, - }) - - try { - await this.client.session.promptAsync({ - path: { id: task.parentSessionID }, - body: { - noReply: !allComplete, - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - ...(task.parentTools ? { tools: task.parentTools } : {}), - parts: [{ type: "text", text: notification }], - }, - }) - log("[background-agent] Sent notification to parent session:", { + log("[background-agent] notifyParentSession context:", { taskId: task.id, - allComplete, - noReply: !allComplete, + resolvedAgent: agent, + resolvedModel: model, }) - } catch (error) { - if (this.isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted, skipping notification:", { - taskId: task.id, - parentSessionID: task.parentSessionID, + + try { + await this.client.session.promptAsync({ + path: { id: task.parentSessionID }, + body: { + noReply: !allComplete, + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + ...(task.parentTools ? { tools: task.parentTools } : {}), + parts: [{ type: "text", text: notification }], + }, }) - return + log("[background-agent] Sent notification to parent session:", { + taskId: task.id, + allComplete, + noReply: !allComplete, + }) + } catch (error) { + if (this.isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted, skipping notification:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) + return + } + log("[background-agent] Failed to send notification:", error) } - log("[background-agent] Failed to send notification:", error) + } else { + log("[background-agent] Parent session notifications disabled, skipping prompt injection:", { + taskId: task.id, + parentSessionID: task.parentSessionID, + }) } if (allComplete) { From 0f287eb1c2a2162782c0e7319df5a55aad890d0d Mon Sep 17 00:00:00 2001 From: Dan Kochetov Date: Mon, 16 Feb 2026 00:58:46 +0200 Subject: [PATCH 05/18] fix(plugin): honor disabled background-notification hook --- src/create-managers.ts | 4 +++- src/index.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/create-managers.ts b/src/create-managers.ts index fb8891d21..de4f5de2f 100644 --- a/src/create-managers.ts +++ b/src/create-managers.ts @@ -22,8 +22,9 @@ export function createManagers(args: { pluginConfig: OhMyOpenCodeConfig tmuxConfig: TmuxConfig modelCacheState: ModelCacheState + backgroundNotificationHookEnabled: boolean }): Managers { - const { ctx, pluginConfig, tmuxConfig, modelCacheState } = args + const { ctx, pluginConfig, tmuxConfig, modelCacheState, backgroundNotificationHookEnabled } = args const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig) @@ -57,6 +58,7 @@ export function createManagers(args: { log("[index] tmux cleanup error during shutdown:", error) }) }, + enableParentSessionNotifications: backgroundNotificationHookEnabled, }, ) diff --git a/src/index.ts b/src/index.ts index 747078518..a444e5128 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { pluginConfig, tmuxConfig, modelCacheState, + backgroundNotificationHookEnabled: isHookEnabled("background-notification"), }) const toolsResult = await createTools({ From 27f8feda042fdd3fc940bbeebac7db9d64469521 Mon Sep 17 00:00:00 2001 From: Jonas Herrmansdsoerfer Date: Fri, 13 Feb 2026 11:15:38 +0100 Subject: [PATCH 06/18] feat(browser-automation): add playwright-cli as browser automation provider - Add playwright-cli to BrowserAutomationProviderSchema enum - Add playwright-cli to BuiltinSkillNameSchema - Create playwrightCliSkill with official Microsoft template - Update skill selection logic to handle 3 providers - Add comprehensive tests for schema and skill selection - Regenerate JSON schema Closes # --- src/config/schema.test.ts | 35 +++ src/config/schema/browser-automation.ts | 2 + src/features/builtin-skills/skills.test.ts | 31 ++ src/features/builtin-skills/skills.ts | 10 +- src/features/builtin-skills/skills/index.ts | 1 + .../builtin-skills/skills/playwright-cli.ts | 268 ++++++++++++++++++ 6 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 src/features/builtin-skills/skills/playwright-cli.ts diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 2d151ec53..2efccaa37 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -553,6 +553,18 @@ describe("BrowserAutomationProviderSchema", () => { // then expect(result.success).toBe(false) }) + + test("accepts 'playwright-cli' as valid provider", () => { + // given + const input = "playwright-cli" + + // when + const result = BrowserAutomationProviderSchema.safeParse(input) + + // then + expect(result.success).toBe(true) + expect(result.data).toBe("playwright-cli") + }) }) describe("BrowserAutomationConfigSchema", () => { @@ -577,6 +589,17 @@ describe("BrowserAutomationConfigSchema", () => { // then expect(result.provider).toBe("agent-browser") }) + + test("accepts playwright-cli provider in config", () => { + // given + const input = { provider: "playwright-cli" } + + // when + const result = BrowserAutomationConfigSchema.parse(input) + + // then + expect(result.provider).toBe("playwright-cli") + }) }) describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => { @@ -607,6 +630,18 @@ describe("OhMyOpenCodeConfigSchema - browser_automation_engine", () => { expect(result.success).toBe(true) expect(result.data?.browser_automation_engine).toBeUndefined() }) + + test("accepts browser_automation_engine with playwright-cli", () => { + // given + const input = { browser_automation_engine: { provider: "playwright-cli" } } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(input) + + // then + expect(result.success).toBe(true) + expect(result.data?.browser_automation_engine?.provider).toBe("playwright-cli") + }) }) describe("ExperimentalConfigSchema feature flags", () => { diff --git a/src/config/schema/browser-automation.ts b/src/config/schema/browser-automation.ts index 294dcb965..f07edd4b0 100644 --- a/src/config/schema/browser-automation.ts +++ b/src/config/schema/browser-automation.ts @@ -4,6 +4,7 @@ export const BrowserAutomationProviderSchema = z.enum([ "playwright", "agent-browser", "dev-browser", + "playwright-cli", ]) export const BrowserAutomationConfigSchema = z.object({ @@ -12,6 +13,7 @@ export const BrowserAutomationConfigSchema = z.object({ * - "playwright": Uses Playwright MCP server (@playwright/mcp) - default * - "agent-browser": Uses Vercel's agent-browser CLI (requires: bun add -g agent-browser) * - "dev-browser": Uses dev-browser skill with persistent browser state + * - "playwright-cli": Uses Playwright CLI (@playwright/cli) - token-efficient CLI alternative */ provider: BrowserAutomationProviderSchema.default("playwright"), }) diff --git a/src/features/builtin-skills/skills.test.ts b/src/features/builtin-skills/skills.test.ts index 33f0cb56f..59a4198d1 100644 --- a/src/features/builtin-skills/skills.test.ts +++ b/src/features/builtin-skills/skills.test.ts @@ -140,4 +140,35 @@ describe("createBuiltinSkills", () => { // #then expect(skills.length).toBe(4) }) + + test("returns playwright-cli skill when browserProvider is 'playwright-cli'", () => { + // given + const options = { browserProvider: "playwright-cli" as const } + + // when + const skills = createBuiltinSkills(options) + + // then + const playwrightSkill = skills.find((s) => s.name === "playwright") + const agentBrowserSkill = skills.find((s) => s.name === "agent-browser") + expect(playwrightSkill).toBeDefined() + expect(playwrightSkill!.description).toContain("browser") + expect(playwrightSkill!.allowedTools).toContain("Bash(playwright-cli:*)") + expect(playwrightSkill!.mcpConfig).toBeUndefined() + expect(agentBrowserSkill).toBeUndefined() + }) + + test("playwright-cli skill template contains CLI commands", () => { + // given + const options = { browserProvider: "playwright-cli" as const } + + // when + const skills = createBuiltinSkills(options) + const skill = skills.find((s) => s.name === "playwright") + + // then + expect(skill!.template).toContain("playwright-cli open") + expect(skill!.template).toContain("playwright-cli snapshot") + expect(skill!.template).toContain("playwright-cli click") + }) }) diff --git a/src/features/builtin-skills/skills.ts b/src/features/builtin-skills/skills.ts index 2f872698f..d0405f600 100644 --- a/src/features/builtin-skills/skills.ts +++ b/src/features/builtin-skills/skills.ts @@ -4,6 +4,7 @@ import type { BrowserAutomationProvider } from "../../config/schema" import { playwrightSkill, agentBrowserSkill, + playwrightCliSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill, @@ -17,7 +18,14 @@ export interface CreateBuiltinSkillsOptions { export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): BuiltinSkill[] { const { browserProvider = "playwright", disabledSkills } = options - const browserSkill = browserProvider === "agent-browser" ? agentBrowserSkill : playwrightSkill + let browserSkill: BuiltinSkill + if (browserProvider === "agent-browser") { + browserSkill = agentBrowserSkill + } else if (browserProvider === "playwright-cli") { + browserSkill = playwrightCliSkill + } else { + browserSkill = playwrightSkill + } const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill] diff --git a/src/features/builtin-skills/skills/index.ts b/src/features/builtin-skills/skills/index.ts index fdd79d253..073930865 100644 --- a/src/features/builtin-skills/skills/index.ts +++ b/src/features/builtin-skills/skills/index.ts @@ -1,4 +1,5 @@ export { playwrightSkill, agentBrowserSkill } from "./playwright" +export { playwrightCliSkill } from "./playwright-cli" export { frontendUiUxSkill } from "./frontend-ui-ux" export { gitMasterSkill } from "./git-master" export { devBrowserSkill } from "./dev-browser" diff --git a/src/features/builtin-skills/skills/playwright-cli.ts b/src/features/builtin-skills/skills/playwright-cli.ts new file mode 100644 index 000000000..728ae380e --- /dev/null +++ b/src/features/builtin-skills/skills/playwright-cli.ts @@ -0,0 +1,268 @@ +import type { BuiltinSkill } from "../types" + +/** + * Playwright CLI skill — token-efficient CLI alternative to the MCP-based playwright skill. + * + * Uses name "playwright" (not "playwright-cli") because agents hardcode "playwright" as the + * canonical browser skill name. The browserProvider config swaps the implementation behind + * the same name: "playwright" gives MCP, "playwright-cli" gives this CLI variant. + * The binary is still called `playwright-cli` (see allowedTools). + */ +export const playwrightCliSkill: BuiltinSkill = { + name: "playwright", + description: "MUST USE for any browser-related tasks. Browser automation via playwright-cli - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.", + template: `# Browser Automation with playwright-cli + +## Quick start + +\`\`\`bash +# open new browser +playwright-cli open +# navigate to a page +playwright-cli goto https://playwright.dev +# interact with the page using refs from the snapshot +playwright-cli click e15 +playwright-cli type "page.click" +playwright-cli press Enter +# take a screenshot +playwright-cli screenshot +# close the browser +playwright-cli close +\`\`\` + +## Commands + +### Core + +\`\`\`bash +playwright-cli open +# open and navigate right away +playwright-cli open https://example.com/ +playwright-cli goto https://playwright.dev +playwright-cli type "search query" +playwright-cli click e3 +playwright-cli dblclick e7 +playwright-cli fill e5 "user@example.com" +playwright-cli drag e2 e8 +playwright-cli hover e4 +playwright-cli select e9 "option-value" +playwright-cli upload ./document.pdf +playwright-cli check e12 +playwright-cli uncheck e12 +playwright-cli snapshot +playwright-cli snapshot --filename=after-click.yaml +playwright-cli eval "document.title" +playwright-cli eval "el => el.textContent" e5 +playwright-cli dialog-accept +playwright-cli dialog-accept "confirmation text" +playwright-cli dialog-dismiss +playwright-cli resize 1920 1080 +playwright-cli close +\`\`\` + +### Navigation + +\`\`\`bash +playwright-cli go-back +playwright-cli go-forward +playwright-cli reload +\`\`\` + +### Keyboard + +\`\`\`bash +playwright-cli press Enter +playwright-cli press ArrowDown +playwright-cli keydown Shift +playwright-cli keyup Shift +\`\`\` + +### Mouse + +\`\`\`bash +playwright-cli mousemove 150 300 +playwright-cli mousedown +playwright-cli mousedown right +playwright-cli mouseup +playwright-cli mouseup right +playwright-cli mousewheel 0 100 +\`\`\` + +### Save as + +\`\`\`bash +playwright-cli screenshot +playwright-cli screenshot e5 +playwright-cli screenshot --filename=page.png +playwright-cli pdf --filename=page.pdf +\`\`\` + +### Tabs + +\`\`\`bash +playwright-cli tab-list +playwright-cli tab-new +playwright-cli tab-new https://example.com/page +playwright-cli tab-close +playwright-cli tab-close 2 +playwright-cli tab-select 0 +\`\`\` + +### Storage + +\`\`\`bash +playwright-cli state-save +playwright-cli state-save auth.json +playwright-cli state-load auth.json + +# Cookies +playwright-cli cookie-list +playwright-cli cookie-list --domain=example.com +playwright-cli cookie-get session_id +playwright-cli cookie-set session_id abc123 +playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure +playwright-cli cookie-delete session_id +playwright-cli cookie-clear + +# LocalStorage +playwright-cli localstorage-list +playwright-cli localstorage-get theme +playwright-cli localstorage-set theme dark +playwright-cli localstorage-delete theme +playwright-cli localstorage-clear + +# SessionStorage +playwright-cli sessionstorage-list +playwright-cli sessionstorage-get step +playwright-cli sessionstorage-set step 3 +playwright-cli sessionstorage-delete step +playwright-cli sessionstorage-clear +\`\`\` + +### Network + +\`\`\`bash +playwright-cli route "**/*.jpg" --status=404 +playwright-cli route "https://api.example.com/**" --body='{"mock": true}' +playwright-cli route-list +playwright-cli unroute "**/*.jpg" +playwright-cli unroute +\`\`\` + +### DevTools + +\`\`\`bash +playwright-cli console +playwright-cli console warning +playwright-cli network +playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])" +playwright-cli tracing-start +playwright-cli tracing-stop +playwright-cli video-start +playwright-cli video-stop video.webm +\`\`\` + +### Install + +\`\`\`bash +playwright-cli install --skills +playwright-cli install-browser +\`\`\` + +### Configuration +\`\`\`bash +# Use specific browser when creating session +playwright-cli open --browser=chrome +playwright-cli open --browser=firefox +playwright-cli open --browser=webkit +playwright-cli open --browser=msedge +# Connect to browser via extension +playwright-cli open --extension + +# Use persistent profile (by default profile is in-memory) +playwright-cli open --persistent +# Use persistent profile with custom directory +playwright-cli open --profile=/path/to/profile + +# Start with config file +playwright-cli open --config=my-config.json + +# Close the browser +playwright-cli close +# Delete user data for the default session +playwright-cli delete-data +\`\`\` + +### Browser Sessions + +\`\`\`bash +# create new browser session named "mysession" with persistent profile +playwright-cli -s=mysession open example.com --persistent +# same with manually specified profile directory (use when requested explicitly) +playwright-cli -s=mysession open example.com --profile=/path/to/profile +playwright-cli -s=mysession click e6 +playwright-cli -s=mysession close # stop a named browser +playwright-cli -s=mysession delete-data # delete user data for persistent session + +playwright-cli list +# Close all browsers +playwright-cli close-all +# Forcefully kill all browser processes +playwright-cli kill-all +\`\`\` + +## Example: Form submission + +\`\`\`bash +playwright-cli open https://example.com/form +playwright-cli snapshot + +playwright-cli fill e1 "user@example.com" +playwright-cli fill e2 "password123" +playwright-cli click e3 +playwright-cli snapshot +playwright-cli close +\`\`\` + +## Example: Multi-tab workflow + +\`\`\`bash +playwright-cli open https://example.com +playwright-cli tab-new https://example.com/other +playwright-cli tab-list +playwright-cli tab-select 0 +playwright-cli snapshot +playwright-cli close +\`\`\` + +## Example: Debugging with DevTools + +\`\`\`bash +playwright-cli open https://example.com +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli console +playwright-cli network +playwright-cli close +\`\`\` + +\`\`\`bash +playwright-cli open https://example.com +playwright-cli tracing-start +playwright-cli click e4 +playwright-cli fill e7 "test" +playwright-cli tracing-stop +playwright-cli close +\`\`\` + +## Specific tasks + +* **Request mocking** [references/request-mocking.md](references/request-mocking.md) +* **Running Playwright code** [references/running-code.md](references/running-code.md) +* **Browser session management** [references/session-management.md](references/session-management.md) +* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md) +* **Test generation** [references/test-generation.md](references/test-generation.md) +* **Tracing** [references/tracing.md](references/tracing.md) +* **Video recording** [references/video-recording.md](references/video-recording.md)`, + allowedTools: ["Bash(playwright-cli:*)"], +} From 149de9da666586b7f83380dabf755555593ad47c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 16 Feb 2026 15:59:12 +0900 Subject: [PATCH 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 = { From 65216ed0812b34c68c06553683193bffc1c5331f Mon Sep 17 00:00:00 2001 From: sisyphus-dev-ai Date: Mon, 16 Feb 2026 16:21:51 +0000 Subject: [PATCH 14/18] chore: changes by sisyphus-dev-ai --- bun.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/bun.lock b/bun.lock index dbac7140d..4c5c4b1fc 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.5.5", - "oh-my-opencode-darwin-x64": "3.5.5", - "oh-my-opencode-linux-arm64": "3.5.5", - "oh-my-opencode-linux-arm64-musl": "3.5.5", - "oh-my-opencode-linux-x64": "3.5.5", - "oh-my-opencode-linux-x64-musl": "3.5.5", - "oh-my-opencode-windows-x64": "3.5.5", + "oh-my-opencode-darwin-arm64": "3.6.0", + "oh-my-opencode-darwin-x64": "3.6.0", + "oh-my-opencode-linux-arm64": "3.6.0", + "oh-my-opencode-linux-arm64-musl": "3.6.0", + "oh-my-opencode-linux-x64": "3.6.0", + "oh-my-opencode-linux-x64-musl": "3.6.0", + "oh-my-opencode-windows-x64": "3.6.0", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.6.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-JkyJC3b9ueRgSyPJMjTKlBO99gIyTpI87lEV5Tk7CBv6TFbj2ZFxfaA8mEm138NbwmYa/Z4Rf7I5tZyp2as93A=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.6.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-5HsXz3F42T6CmPk6IW+pErJVSmPnqc3Gc1OntoKp/b4FwuWkFJh9kftDSH3cnKTX98H6XBqnwZoFKCNCiiVLEA=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.6.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KjCSC2i9XdjzGsX6coP9xwj7naxTpdqnB53TiLbVH+KeF0X0dNsVV7PHbme3I1orjjzYoEbVYVC3ZNaleubzog=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.6.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-EARvFQXnkqSnwPpKtghmoV5e/JmweJXhjcOrRNvEwQ8HSb4FIhdRmJkTw4Z/EzyoIRTQcY019ALOiBbdIiOUEA=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.6.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jYyew4NKAOM6NrMM0+LlRlz6s1EVMI9cQdK/o0t8uqFheZVeb7u4cBZwwfhJ79j7EWkSWGc0Jdj9G2dOukbDxg=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.6.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BrR+JftCXP/il04q2uImWIueCiuTmXbivsXYkfFONdO1Rq9b4t0BVua9JIYk7l3OUfeRlrKlFNYNfpFhvVADOw=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.6.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-cIYQYzcQGhGFE99ulHGXs8S1vDHjgCtT3ID2dDoOztnOQW0ZVa61oCHlkBtjdP/BEv2tH5AGvKrXAICXs19iFw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], From d7a53e8a5b94a2d14bc96bc26f27a3bf57c84363 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 01:26:58 +0900 Subject: [PATCH 15/18] fix: report errors instead of silent catch in subagent-resolver (#1283) --- .../delegate-task/subagent-resolver.test.ts | 82 +++++++++++++++++++ src/tools/delegate-task/subagent-resolver.ts | 16 +++- 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 src/tools/delegate-task/subagent-resolver.test.ts diff --git a/src/tools/delegate-task/subagent-resolver.test.ts b/src/tools/delegate-task/subagent-resolver.test.ts new file mode 100644 index 000000000..8482c6cf6 --- /dev/null +++ b/src/tools/delegate-task/subagent-resolver.test.ts @@ -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 { + return { + description: "Run review", + prompt: "Review the current changes", + run_in_background: false, + load_skills: [], + subagent_type: "oracle", + ...overrides, + } +} + +function createExecutorContext(agentsFn: () => Promise): ExecutorContext { + const client = { + app: { + agents: agentsFn, + }, + } as ExecutorContext["client"] + + return { + client, + manager: {} as ExecutorContext["manager"], + directory: "/tmp/test", + } +} + +describe("resolveSubagentExecution", () => { + let logSpy: ReturnType | 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", + }) + }) +}) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 5651fba29..ad48b714e 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -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 } From 5ae45c8c8e9748294e9d35003797718320c59e96 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 01:29:25 +0900 Subject: [PATCH 16/18] fix: use correct project directory for Windows subagents (#1718) --- .../spawner/parent-directory-resolver.test.ts | 33 ++++++++ .../spawner/parent-directory-resolver.ts | 7 +- src/shared/index.ts | 1 + src/shared/session-directory-resolver.test.ts | 79 +++++++++++++++++++ src/shared/session-directory-resolver.ts | 39 +++++++++ .../subagent-session-creator.test.ts | 72 +++++++++++++---- .../subagent-session-creator.ts | 8 +- 7 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 src/features/background-agent/spawner/parent-directory-resolver.test.ts create mode 100644 src/shared/session-directory-resolver.test.ts create mode 100644 src/shared/session-directory-resolver.ts diff --git a/src/features/background-agent/spawner/parent-directory-resolver.test.ts b/src/features/background-agent/spawner/parent-directory-resolver.test.ts new file mode 100644 index 000000000..2fcae8255 --- /dev/null +++ b/src/features/background-agent/spawner/parent-directory-resolver.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" + +import { resolveParentDirectory } from "./parent-directory-resolver" + +describe("background-agent parent-directory-resolver", () => { + const originalPlatform = process.platform + + test("uses current working directory on Windows when parent session directory is AppData", async () => { + //#given + Object.defineProperty(process, "platform", { value: "win32" }) + try { + const client = { + session: { + get: async () => ({ + data: { directory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop" }, + }), + }, + } + + //#when + const result = await resolveParentDirectory({ + client: client as Parameters[0]["client"], + parentSessionID: "ses_parent", + defaultDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + }) + + //#then + expect(result).toBe(process.cwd()) + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }) + } + }) +}) diff --git a/src/features/background-agent/spawner/parent-directory-resolver.ts b/src/features/background-agent/spawner/parent-directory-resolver.ts index 7e527551f..48b1cee53 100644 --- a/src/features/background-agent/spawner/parent-directory-resolver.ts +++ b/src/features/background-agent/spawner/parent-directory-resolver.ts @@ -1,5 +1,5 @@ import type { OpencodeClient } from "../constants" -import { log } from "../../../shared" +import { log, resolveSessionDirectory } from "../../../shared" export async function resolveParentDirectory(options: { client: OpencodeClient @@ -15,7 +15,10 @@ export async function resolveParentDirectory(options: { return null }) - const parentDirectory = parentSession?.data?.directory ?? defaultDirectory + const parentDirectory = resolveSessionDirectory({ + parentDirectory: parentSession?.data?.directory, + fallbackDirectory: defaultDirectory, + }) log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`) return parentDirectory } diff --git a/src/shared/index.ts b/src/shared/index.ts index 85a62b831..f9b58f41c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -54,3 +54,4 @@ export * from "./truncate-description" export * from "./opencode-storage-paths" export * from "./opencode-message-dir" export * from "./normalize-sdk-response" +export * from "./session-directory-resolver" diff --git a/src/shared/session-directory-resolver.test.ts b/src/shared/session-directory-resolver.test.ts new file mode 100644 index 000000000..717d9f902 --- /dev/null +++ b/src/shared/session-directory-resolver.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test" + +import { isWindowsAppDataDirectory, resolveSessionDirectory } from "./session-directory-resolver" + +describe("session-directory-resolver", () => { + describe("isWindowsAppDataDirectory", () => { + test("returns true when path is under AppData Local", () => { + //#given + const directory = "C:/Users/test/AppData/Local/opencode" + + //#when + const result = isWindowsAppDataDirectory(directory) + + //#then + expect(result).toBe(true) + }) + + test("returns false when path is outside AppData", () => { + //#given + const directory = "D:/projects/oh-my-opencode" + + //#when + const result = isWindowsAppDataDirectory(directory) + + //#then + expect(result).toBe(false) + }) + }) + + describe("resolveSessionDirectory", () => { + test("uses process working directory on Windows when parent directory drifts to AppData", () => { + //#given + const options = { + parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop", + fallbackDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + platform: "win32" as const, + currentWorkingDirectory: "D:\\projects\\oh-my-opencode", + } + + //#when + const result = resolveSessionDirectory(options) + + //#then + expect(result).toBe("D:\\projects\\oh-my-opencode") + }) + + test("keeps AppData directory when current working directory is also AppData", () => { + //#given + const options = { + parentDirectory: "C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop", + fallbackDirectory: "C:\\Users\\test\\AppData\\Roaming\\opencode", + platform: "win32" as const, + currentWorkingDirectory: "C:\\Users\\test\\AppData\\Local\\Temp", + } + + //#when + const result = resolveSessionDirectory(options) + + //#then + expect(result).toBe("C:\\Users\\test\\AppData\\Local\\ai.opencode.desktop") + }) + + test("keeps original directory outside Windows", () => { + //#given + const options = { + parentDirectory: "/tmp/opencode", + fallbackDirectory: "/workspace/project", + platform: "darwin" as const, + currentWorkingDirectory: "/workspace/project", + } + + //#when + const result = resolveSessionDirectory(options) + + //#then + expect(result).toBe("/tmp/opencode") + }) + }) +}) diff --git a/src/shared/session-directory-resolver.ts b/src/shared/session-directory-resolver.ts new file mode 100644 index 000000000..bc76f1a51 --- /dev/null +++ b/src/shared/session-directory-resolver.ts @@ -0,0 +1,39 @@ +const WINDOWS_APPDATA_SEGMENTS = ["\\appdata\\local\\", "\\appdata\\roaming\\", "\\appdata\\locallow\\"] + +function normalizeWindowsPath(directory: string): string { + return directory.replaceAll("/", "\\").toLowerCase() +} + +export function isWindowsAppDataDirectory(directory: string): boolean { + const normalizedDirectory = normalizeWindowsPath(directory) + return WINDOWS_APPDATA_SEGMENTS.some((segment) => normalizedDirectory.includes(segment)) +} + +export function resolveSessionDirectory(options: { + parentDirectory: string | null | undefined + fallbackDirectory: string + platform?: NodeJS.Platform + currentWorkingDirectory?: string +}): string { + const { + parentDirectory, + fallbackDirectory, + platform = process.platform, + currentWorkingDirectory = process.cwd(), + } = options + + const sessionDirectory = parentDirectory ?? fallbackDirectory + if (platform !== "win32") { + return sessionDirectory + } + + if (!isWindowsAppDataDirectory(sessionDirectory)) { + return sessionDirectory + } + + if (isWindowsAppDataDirectory(currentWorkingDirectory)) { + return sessionDirectory + } + + return currentWorkingDirectory +} diff --git a/src/tools/call-omo-agent/subagent-session-creator.test.ts b/src/tools/call-omo-agent/subagent-session-creator.test.ts index bacf60f46..dea60d524 100644 --- a/src/tools/call-omo-agent/subagent-session-creator.test.ts +++ b/src/tools/call-omo-agent/subagent-session-creator.test.ts @@ -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[0] + args: Parameters[1] + toolContext: Parameters[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 = [] 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[0] const args = { description: "sync test", prompt: "hello", subagent_type: "explore", run_in_background: false, - } + } satisfies Parameters[1] const toolContext = { sessionID: "ses_parent", messageID: "msg_parent", agent: "sisyphus", abort: new AbortController().signal, - } + } satisfies Parameters[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 }) + } + }) }) diff --git a/src/tools/call-omo-agent/subagent-session-creator.ts b/src/tools/call-omo-agent/subagent-session-creator.ts index cd637d236..383cae638 100644 --- a/src/tools/call-omo-agent/subagent-session-creator.ts +++ b/src/tools/call-omo-agent/subagent-session-creator.ts @@ -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, From 90ede4487b76a2fc1ec5592623b4c33cfd36ee80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jason=20K=C3=B6lker?= Date: Sun, 15 Feb 2026 04:42:15 +0000 Subject: [PATCH 17/18] fix(config): preserve configured default_agent oh-my-opencode overwrote OpenCode's default_agent with sisyphus whenever Sisyphus orchestration was enabled. This made explicit defaults like Hephaestus ineffective and forced manual agent switching in new sessions. Only assign sisyphus as default when default_agent is missing or blank, and preserve existing configured values. Add tests for both preservation and fallback behavior to prevent regressions. --- src/plugin-handlers/agent-config-handler.ts | 10 ++++- src/plugin-handlers/config-handler.test.ts | 49 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 101300a61..91cbf2a96 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -23,6 +23,11 @@ type AgentConfigRecord = Record | undefined> & { plan?: Record; }; +function hasConfiguredDefaultAgent(config: Record): boolean { + const defaultAgent = config.default_agent; + return typeof defaultAgent === "string" && defaultAgent.trim().length > 0; +} + export async function applyAgentConfig(params: { config: Record; pluginConfig: OhMyOpenCodeConfig; @@ -106,7 +111,10 @@ export async function applyAgentConfig(params: { const configAgent = params.config.agent as AgentConfigRecord | undefined; if (isSisyphusEnabled && builtinAgents.sisyphus) { - (params.config as { default_agent?: string }).default_agent = getAgentDisplayName("sisyphus"); + if (!hasConfiguredDefaultAgent(params.config)) { + (params.config as { default_agent?: string }).default_agent = + getAgentDisplayName("sisyphus"); + } const agentConfig: Record = { sisyphus: builtinAgents.sisyphus, diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index cf6e2461b..c8e25f588 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -349,6 +349,55 @@ describe("Agent permission defaults", () => { }) }) +describe("default_agent behavior with Sisyphus orchestration", () => { + test("preserves existing default_agent when already set", async () => { + // #given + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + default_agent: "hephaestus", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + expect(config.default_agent).toBe("hephaestus") + }) + + test("sets default_agent to sisyphus when missing", async () => { + // #given + const pluginConfig: OhMyOpenCodeConfig = {} + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + expect(config.default_agent).toBe(getAgentDisplayName("sisyphus")) + }) +}) + describe("Prometheus category config resolution", () => { test("resolves ultrabrain category config", () => { // given From 24789334e414f06f4b80cb28535c077739339d1b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 17 Feb 2026 01:45:14 +0900 Subject: [PATCH 18/18] fix: detect AppData directory paths without trailing separators --- src/shared/session-directory-resolver.test.ts | 22 +++++++++++++++++++ src/shared/session-directory-resolver.ts | 6 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/shared/session-directory-resolver.test.ts b/src/shared/session-directory-resolver.test.ts index 717d9f902..da1903f0f 100644 --- a/src/shared/session-directory-resolver.test.ts +++ b/src/shared/session-directory-resolver.test.ts @@ -15,6 +15,17 @@ describe("session-directory-resolver", () => { expect(result).toBe(true) }) + test("returns true when path ends with AppData directory segment", () => { + //#given + const directory = "C:/Users/test/AppData/Local" + + //#when + const result = isWindowsAppDataDirectory(directory) + + //#then + expect(result).toBe(true) + }) + test("returns false when path is outside AppData", () => { //#given const directory = "D:/projects/oh-my-opencode" @@ -25,6 +36,17 @@ describe("session-directory-resolver", () => { //#then expect(result).toBe(false) }) + + test("returns false for lookalike non-AppData segment", () => { + //#given + const directory = "D:/projects/appdata/local-tools" + + //#when + const result = isWindowsAppDataDirectory(directory) + + //#then + expect(result).toBe(false) + }) }) describe("resolveSessionDirectory", () => { diff --git a/src/shared/session-directory-resolver.ts b/src/shared/session-directory-resolver.ts index bc76f1a51..16d389448 100644 --- a/src/shared/session-directory-resolver.ts +++ b/src/shared/session-directory-resolver.ts @@ -1,4 +1,4 @@ -const WINDOWS_APPDATA_SEGMENTS = ["\\appdata\\local\\", "\\appdata\\roaming\\", "\\appdata\\locallow\\"] +const WINDOWS_APPDATA_SEGMENTS = ["\\appdata\\local", "\\appdata\\roaming", "\\appdata\\locallow"] function normalizeWindowsPath(directory: string): string { return directory.replaceAll("/", "\\").toLowerCase() @@ -6,7 +6,9 @@ function normalizeWindowsPath(directory: string): string { export function isWindowsAppDataDirectory(directory: string): boolean { const normalizedDirectory = normalizeWindowsPath(directory) - return WINDOWS_APPDATA_SEGMENTS.some((segment) => normalizedDirectory.includes(segment)) + return WINDOWS_APPDATA_SEGMENTS.some((segment) => { + return normalizedDirectory.endsWith(segment) || normalizedDirectory.includes(`${segment}\\`) + }) } export function resolveSessionDirectory(options: {