fix(hooks): harden json-error-recovery matching and scope

This commit is contained in:
YeonGyu-Kim
2026-02-17 15:46:21 +09:00
parent 86f2a93fc9
commit 5f939f900a
3 changed files with 170 additions and 21 deletions

View File

@@ -1,12 +1,30 @@
import type { PluginInput } from "@opencode-ai/plugin"
export const JSON_ERROR_PATTERNS = [
"json parse error",
"syntaxerror: unexpected token",
"expected '}'",
"unexpected eof",
export const JSON_ERROR_TOOL_EXCLUDE_LIST = [
"bash",
"read",
"glob",
"grep",
"webfetch",
"look_at",
"grep_app_searchgithub",
"websearch_web_search_exa",
] as const
export const JSON_ERROR_PATTERNS = [
/json parse error/i,
/failed to parse json/i,
/invalid json/i,
/malformed json/i,
/unexpected end of json input/i,
/syntaxerror:\s*unexpected token.*json/i,
/json[^\n]*expected '\}'/i,
/json[^\n]*unexpected eof/i,
] as const
const JSON_ERROR_REMINDER_MARKER = "[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]"
const JSON_ERROR_EXCLUDED_TOOLS = new Set<string>(JSON_ERROR_TOOL_EXCLUDE_LIST)
export const JSON_ERROR_REMINDER = `
[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]
@@ -23,15 +41,14 @@ DO NOT repeat the exact same invalid call.
export function createJsonErrorRecoveryHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
_input: { tool: string; sessionID: string; callID: string },
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (JSON_ERROR_EXCLUDED_TOOLS.has(input.tool.toLowerCase())) return
if (typeof output.output !== "string") return
if (output.output.includes(JSON_ERROR_REMINDER_MARKER)) return
const outputLower = output.output.toLowerCase()
const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) =>
outputLower.includes(pattern)
)
const hasJsonError = JSON_ERROR_PATTERNS.some((pattern) => pattern.test(output.output))
if (hasJsonError) {
output.output += `\n${JSON_ERROR_REMINDER}`

View File

@@ -1,65 +1,196 @@
import { beforeEach, describe, expect, it } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import {
createJsonErrorRecoveryHook,
JSON_ERROR_PATTERNS,
JSON_ERROR_REMINDER,
JSON_ERROR_TOOL_EXCLUDE_LIST,
} from "./index"
describe("createJsonErrorRecoveryHook", () => {
let hook: ReturnType<typeof createJsonErrorRecoveryHook>
type ToolExecuteAfterHandler = NonNullable<
ReturnType<typeof createJsonErrorRecoveryHook>["tool.execute.after"]
>
type ToolExecuteAfterInput = Parameters<ToolExecuteAfterHandler>[0]
type ToolExecuteAfterOutput = Parameters<ToolExecuteAfterHandler>[1]
const createMockPluginInput = (): PluginInput => {
return {
client: {} as PluginInput["client"],
directory: "/tmp/test",
} as PluginInput
}
beforeEach(() => {
hook = createJsonErrorRecoveryHook({} as any)
hook = createJsonErrorRecoveryHook(createMockPluginInput())
})
describe("tool.execute.after", () => {
const createInput = () => ({
tool: "Read",
const createInput = (tool = "Edit"): ToolExecuteAfterInput => ({
tool,
sessionID: "test-session",
callID: "test-call-id",
})
const createOutput = (outputText: string) => ({
const createOutput = (outputText: string): ToolExecuteAfterOutput => ({
title: "Tool Error",
output: outputText,
metadata: {},
})
it("appends reminder when output includes JSON parse error", async () => {
const input = createInput()
const output = createOutput("JSON Parse error: Expected '}'")
const createUnknownOutput = (value: unknown): { title: string; output: unknown; metadata: Record<string, unknown> } => ({
title: "Tool Error",
output: value,
metadata: {},
})
it("appends reminder when output includes JSON parse error", async () => {
// given
const input = createInput()
const output = createOutput("JSON parse error: expected '}' in JSON body")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toContain(JSON_ERROR_REMINDER)
})
it("appends reminder when output includes SyntaxError", async () => {
// given
const input = createInput()
const output = createOutput("SyntaxError: Unexpected token in JSON at position 10")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toContain(JSON_ERROR_REMINDER)
})
it("does not append reminder for normal output", async () => {
// given
const input = createInput()
const output = createOutput("Task completed successfully")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("Task completed successfully")
})
it("does not append reminder for empty output", async () => {
// given
const input = createInput()
const output = createOutput("")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("")
})
it("does not append reminder for false positive non-JSON text", async () => {
// given
const input = createInput()
const output = createOutput("Template failed: expected '}' before newline")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("Template failed: expected '}' before newline")
})
it("does not append reminder for excluded tools", async () => {
// given
const input = createInput("Read")
const output = createOutput("JSON parse error: unexpected end of JSON input")
// when
await hook["tool.execute.after"](input, output)
// then
expect(output.output).toBe("JSON parse error: unexpected end of JSON input")
})
it("does not append reminder when reminder already exists", async () => {
// given
const input = createInput()
const output = createOutput(`JSON parse error: invalid JSON\n${JSON_ERROR_REMINDER}`)
// when
await hook["tool.execute.after"](input, output)
// then
const reminderCount = output.output.split("[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]").length - 1
expect(reminderCount).toBe(1)
})
it("does not append duplicate reminder on repeated execution", async () => {
// given
const input = createInput()
const output = createOutput("JSON parse error: invalid JSON arguments")
// when
await hook["tool.execute.after"](input, output)
await hook["tool.execute.after"](input, output)
// then
const reminderCount = output.output.split("[JSON PARSE ERROR - IMMEDIATE ACTION REQUIRED]").length - 1
expect(reminderCount).toBe(1)
})
it("ignores non-string output values", async () => {
// given
const input = createInput()
const values: unknown[] = [42, null, undefined, { error: "invalid json" }]
// when
for (const value of values) {
const output = createUnknownOutput(value)
await hook["tool.execute.after"](input, output as ToolExecuteAfterOutput)
// then
expect(output.output).toBe(value)
}
})
})
describe("JSON_ERROR_PATTERNS", () => {
it("contains known parse error patterns", () => {
expect(JSON_ERROR_PATTERNS).toContain("json parse error")
expect(JSON_ERROR_PATTERNS).toContain("syntaxerror: unexpected token")
expect(JSON_ERROR_PATTERNS).toContain("expected '}'")
expect(JSON_ERROR_PATTERNS).toContain("unexpected eof")
// given
const output = "JSON parse error: unexpected end of JSON input"
// when
const isMatched = JSON_ERROR_PATTERNS.some((pattern) => pattern.test(output))
// then
expect(isMatched).toBe(true)
})
})
describe("JSON_ERROR_TOOL_EXCLUDE_LIST", () => {
it("contains content-heavy tools that should be excluded", () => {
// given
const expectedExcludedTools: Array<(typeof JSON_ERROR_TOOL_EXCLUDE_LIST)[number]> = [
"read",
"bash",
"webfetch",
]
// when
const allExpectedToolsIncluded = expectedExcludedTools.every((toolName) =>
JSON_ERROR_TOOL_EXCLUDE_LIST.includes(toolName)
)
// then
expect(allExpectedToolsIncluded).toBe(true)
})
})
})

View File

@@ -1,5 +1,6 @@
export {
createJsonErrorRecoveryHook,
JSON_ERROR_TOOL_EXCLUDE_LIST,
JSON_ERROR_PATTERNS,
JSON_ERROR_REMINDER,
} from "./hook"