diff --git a/src/hooks/json-error-recovery/hook.ts b/src/hooks/json-error-recovery/hook.ts index da55dd0dd..418401a10 100644 --- a/src/hooks/json-error-recovery/hook.ts +++ b/src/hooks/json-error-recovery/hook.ts @@ -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(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}` diff --git a/src/hooks/json-error-recovery/index.test.ts b/src/hooks/json-error-recovery/index.test.ts index f52d39ce0..04bcb6d89 100644 --- a/src/hooks/json-error-recovery/index.test.ts +++ b/src/hooks/json-error-recovery/index.test.ts @@ -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 + type ToolExecuteAfterHandler = NonNullable< + ReturnType["tool.execute.after"] + > + type ToolExecuteAfterInput = Parameters[0] + type ToolExecuteAfterOutput = Parameters[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 } => ({ + 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) }) }) }) diff --git a/src/hooks/json-error-recovery/index.ts b/src/hooks/json-error-recovery/index.ts index af041c291..b56649235 100644 --- a/src/hooks/json-error-recovery/index.ts +++ b/src/hooks/json-error-recovery/index.ts @@ -1,5 +1,6 @@ export { createJsonErrorRecoveryHook, + JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER, } from "./hook"