Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const HookNameSchema = z.enum([
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"json-error-recovery",
"prometheus-md-only",
"start-work",
"sisyphus-orchestrator",
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ 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";
export { createJsonErrorRecoveryHook } from "./json-error-recovery";
export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
export { createTaskResumeInfoHook } from "./task-resume-info";
export { createStartWorkHook } from "./start-work";
Expand Down
47 changes: 47 additions & 0 deletions src/hooks/json-error-recovery/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { PluginInput } from "@opencode-ai/plugin"

/**
* System reminder injected when a tool call fails due to JSON parse error.
* Forces the agent to stop and correct its output format.
*/
export const JSON_ERROR_REMINDER = `
[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]

You sent invalid JSON arguments. The system could not parse your tool call.
STOP and do this NOW:

1. LOOK at the error message above to see what was expected vs what you sent.
2. CORRECT your JSON syntax (missing braces, unescaped quotes, trailing commas, etc).
3. RETRY the tool call with valid JSON.

DO NOT repeat the exact same invalid call.
`

/**
* Detects JSON parse errors in tool outputs and injects a recovery reminder.
*
* Catches errors like:
* - JSON Parse error: Expected '}'
* - JSON Parse error: Unexpected EOF
* - SyntaxError: Unexpected token
*/
export function createJsonErrorRecoveryHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
// Check for common JSON error patterns in the output
const outputLower = output.output.toLowerCase()
const isJsonError =
outputLower.includes("json parse error") ||
outputLower.includes("syntaxerror: unexpected token") ||
outputLower.includes("expected '}'") ||
outputLower.includes("unexpected eof")

if (isJsonError) {
output.output += `\n${JSON_ERROR_REMINDER}`
}
},
}
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {
createRalphLoopHook,
createAutoSlashCommandHook,
createEditErrorRecoveryHook,
createJsonErrorRecoveryHook,
createTaskResumeInfoHook,

createStartWorkHook,
createSisyphusOrchestratorHook,
createPrometheusMdOnlyHook,
Expand Down Expand Up @@ -202,7 +204,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createEditErrorRecoveryHook(ctx)
: null;

const jsonErrorRecovery = isHookEnabled("json-error-recovery")
? createJsonErrorRecoveryHook(ctx)
: null;

const startWork = isHookEnabled("start-work")

? createStartWorkHook(ctx)
: null;

Expand Down Expand Up @@ -555,7 +562,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await agentUsageReminder?.["tool.execute.after"](input, output);
await interactiveBashSession?.["tool.execute.after"](input, output);
await editErrorRecovery?.["tool.execute.after"](input, output);
await jsonErrorRecovery?.["tool.execute.after"](input, output);
await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output);

await taskResumeInfo["tool.execute.after"](input, output);
},
};
Expand Down
44 changes: 44 additions & 0 deletions tests/edit_recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@

import { describe, it, expect, vi } from "vitest"
import { createEditErrorRecoveryHook } from "../src/hooks/edit-error-recovery"
import { EDIT_ERROR_REMINDER } from "../src/hooks/edit-error-recovery"

// Mock PluginInput
const mockCtx: any = {
client: {},
directory: "/tmp",
}

describe("Edit Error Recovery Hook", () => {
it("should inject reminder when edit error occurs", async () => {
const hook = createEditErrorRecoveryHook(mockCtx)
const output = {
title: "Edit",
output: "Error: oldString not found in content",
metadata: {},
}

await hook["tool.execute.after"](
{ tool: "Edit", sessionID: "test", callID: "call_1" },
output
)

expect(output.output).toContain(EDIT_ERROR_REMINDER)
})

it("should not inject reminder for successful edits", async () => {
const hook = createEditErrorRecoveryHook(mockCtx)
const output = {
title: "Edit",
output: "Successfully edited file",
metadata: {},
}

await hook["tool.execute.after"](
{ tool: "Edit", sessionID: "test", callID: "call_1" },
output
)

expect(output.output).not.toContain(EDIT_ERROR_REMINDER)
})
})
59 changes: 59 additions & 0 deletions tests/json_recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@

import { describe, it, expect } from "vitest"
import { createJsonErrorRecoveryHook, JSON_ERROR_REMINDER } from "../src/hooks/json-error-recovery"

// Test context mock
const mockCtx: any = {
client: {},
directory: "/tmp",
}

describe("JSON Error Recovery Hook", () => {
it("should inject reminder when json parse error occurs", async () => {
const hook = createJsonErrorRecoveryHook(mockCtx)
const output = {
title: "Tool Error",
output: "JSON Parse error: Expected '}'",
metadata: {},
}

await hook["tool.execute.after"](
{ tool: "AnyTool", sessionID: "test", callID: "call_1" },
output
)

expect(output.output).toContain(JSON_ERROR_REMINDER)
})

it("should inject reminder for SyntaxError", async () => {
const hook = createJsonErrorRecoveryHook(mockCtx)
const output = {
title: "Tool Error",
output: "SyntaxError: Unexpected token",
metadata: {},
}

await hook["tool.execute.after"](
{ tool: "AnyTool", sessionID: "test", callID: "call_1" },
output
)

expect(output.output).toContain(JSON_ERROR_REMINDER)
})

it("should not inject reminder for normal output", async () => {
const hook = createJsonErrorRecoveryHook(mockCtx)
const output = {
title: "Success",
output: "Task completed successfully",
metadata: {},
}

await hook["tool.execute.after"](
{ tool: "AnyTool", sessionID: "test", callID: "call_1" },
output
)

expect(output.output).not.toContain(JSON_ERROR_REMINDER)
})
})
Loading