diff --git a/src/config/schema.ts b/src/config/schema.ts index 3fb7982..e1e3908 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -74,6 +74,7 @@ export const HookNameSchema = z.enum([ "compaction-context-injector", "claude-code-hooks", "auto-slash-command", + "edit-error-recovery", ]) export const BuiltinCommandNameSchema = z.enum([ diff --git a/src/hooks/edit-error-recovery/index.test.ts b/src/hooks/edit-error-recovery/index.test.ts new file mode 100644 index 0000000..bafe931 --- /dev/null +++ b/src/hooks/edit-error-recovery/index.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach } from "bun:test" +import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index" + +describe("createEditErrorRecoveryHook", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createEditErrorRecoveryHook({} as any) + }) + + describe("tool.execute.after", () => { + const createInput = (tool: string) => ({ + tool, + sessionID: "test-session", + callID: "test-call-id", + }) + + const createOutput = (outputText: string) => ({ + title: "Edit", + output: outputText, + metadata: {}, + }) + + describe("#given Edit tool with oldString/newString same error", () => { + describe("#when the error message is detected", () => { + it("#then should append the recovery reminder", async () => { + const input = createInput("Edit") + const output = createOutput("Error: oldString and newString must be different") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(EDIT_ERROR_REMINDER) + expect(output.output).toContain("oldString and newString must be different") + }) + }) + + describe("#when the error appears without Error prefix", () => { + it("#then should still detect and append reminder", async () => { + const input = createInput("Edit") + const output = createOutput("oldString and newString must be different") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(EDIT_ERROR_REMINDER) + }) + }) + }) + + describe("#given Edit tool with oldString not found error", () => { + describe("#when oldString not found in content", () => { + it("#then should append the recovery reminder", async () => { + const input = createInput("Edit") + const output = createOutput("Error: oldString not found in content") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(EDIT_ERROR_REMINDER) + }) + }) + }) + + describe("#given Edit tool with multiple matches error", () => { + describe("#when oldString found multiple times", () => { + it("#then should append the recovery reminder", async () => { + const input = createInput("Edit") + const output = createOutput( + "Error: oldString found multiple times and requires more code context to uniquely identify the intended match" + ) + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(EDIT_ERROR_REMINDER) + }) + }) + }) + + describe("#given non-Edit tool", () => { + describe("#when tool is not Edit", () => { + it("#then should not modify output", async () => { + const input = createInput("Read") + const originalOutput = "some output" + const output = createOutput(originalOutput) + + await hook["tool.execute.after"](input, output) + + expect(output.output).toBe(originalOutput) + }) + }) + }) + + describe("#given Edit tool with successful output", () => { + describe("#when no error in output", () => { + it("#then should not modify output", async () => { + const input = createInput("Edit") + const originalOutput = "File edited successfully" + const output = createOutput(originalOutput) + + await hook["tool.execute.after"](input, output) + + expect(output.output).toBe(originalOutput) + }) + }) + }) + + describe("#given case insensitive tool name", () => { + describe("#when tool is 'edit' lowercase", () => { + it("#then should still detect and append reminder", async () => { + const input = createInput("edit") + const output = createOutput("oldString and newString must be different") + + await hook["tool.execute.after"](input, output) + + expect(output.output).toContain(EDIT_ERROR_REMINDER) + }) + }) + }) + }) + + describe("EDIT_ERROR_PATTERNS", () => { + it("#then should contain all known Edit error patterns", () => { + expect(EDIT_ERROR_PATTERNS).toContain("oldString and newString must be different") + expect(EDIT_ERROR_PATTERNS).toContain("oldString not found") + expect(EDIT_ERROR_PATTERNS).toContain("oldString found multiple times") + }) + }) +}) diff --git a/src/hooks/edit-error-recovery/index.ts b/src/hooks/edit-error-recovery/index.ts new file mode 100644 index 0000000..84ac9e9 --- /dev/null +++ b/src/hooks/edit-error-recovery/index.ts @@ -0,0 +1,57 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +/** + * Known Edit tool error patterns that indicate the AI made a mistake + */ +export const EDIT_ERROR_PATTERNS = [ + "oldString and newString must be different", + "oldString not found", + "oldString found multiple times", +] as const + +/** + * System reminder injected when Edit tool fails due to AI mistake + * Short, direct, and commanding - forces immediate corrective action + */ +export const EDIT_ERROR_REMINDER = ` +[EDIT ERROR - IMMEDIATE ACTION REQUIRED] + +You made an Edit mistake. STOP and do this NOW: + +1. READ the file immediately to see its ACTUAL current state +2. VERIFY what the content really looks like (your assumption was wrong) +3. APOLOGIZE briefly to the user for the error +4. CONTINUE with corrected action based on the real file content + +DO NOT attempt another edit until you've read and verified the file state. +` + +/** + * Detects Edit tool errors caused by AI mistakes and injects a recovery reminder + * + * This hook catches common Edit tool failures: + * - oldString and newString must be different (trying to "edit" to same content) + * - oldString not found (wrong assumption about file content) + * - oldString found multiple times (ambiguous match, need more context) + * + * @see https://github.com/sst/opencode/issues/4718 + */ +export function createEditErrorRecoveryHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown } + ) => { + if (input.tool.toLowerCase() !== "edit") return + + const outputLower = output.output.toLowerCase() + const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) => + outputLower.includes(pattern.toLowerCase()) + ) + + if (hasEditError) { + output.output += `\n${EDIT_ERROR_REMINDER}` + } + }, + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index efa76cd..36ea9c4 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -24,3 +24,4 @@ export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer"; export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; export { createAutoSlashCommandHook } from "./auto-slash-command"; +export { createEditErrorRecoveryHook } from "./edit-error-recovery"; diff --git a/src/index.ts b/src/index.ts index 95d977f..5e18f54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,6 +25,7 @@ import { createThinkingBlockValidatorHook, createRalphLoopHook, createAutoSlashCommandHook, + createEditErrorRecoveryHook, } from "./hooks"; import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity"; import { @@ -152,6 +153,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createAutoSlashCommandHook() : null; + const editErrorRecovery = isHookEnabled("edit-error-recovery") + ? createEditErrorRecoveryHook(ctx) + : null; + const backgroundManager = new BackgroundManager(ctx); const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") @@ -436,6 +441,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await emptyTaskResponseDetector?.["tool.execute.after"](input, output); await agentUsageReminder?.["tool.execute.after"](input, output); await interactiveBashSession?.["tool.execute.after"](input, output); + await editErrorRecovery?.["tool.execute.after"](input, output); }, }; };