From 021d4e3c97aadc45dc1ea4649c7c635d5f0b7c76 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 31 May 2026 10:56:37 +0900 Subject: [PATCH] fix(codex): handle lsp diagnostics failures --- .../plugin/components/lsp/src/codex-hook.ts | 18 +++++- .../lsp/test/codex-hook-errors.test.ts | 55 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 packages/omo-codex/plugin/components/lsp/test/codex-hook-errors.test.ts diff --git a/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts b/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts index 913d7fec2..2f5c51838 100644 --- a/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +++ b/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts @@ -89,13 +89,29 @@ async function collectDiagnostics( nextIndex += 1; const filePath = filePaths[index]; if (filePath === undefined) return; - results[index] = { filePath, diagnostics: (await runDiagnostics(filePath)).trim() }; + results[index] = { filePath, diagnostics: await collectFileDiagnostics(filePath, runDiagnostics) }; } } await Promise.all(Array.from({ length: workerCount }, () => worker())); return results; } +async function collectFileDiagnostics(filePath: string, runDiagnostics: DiagnosticsRunner): Promise { + try { + return (await runDiagnostics(filePath)).trim(); + } catch (error) { + return formatDiagnosticsError(error); + } +} + +function formatDiagnosticsError(error: unknown): string { + if (error instanceof Error) { + const message = error.message.trim(); + if (message.length > 0) return message; + } + return String(error).trim(); +} + function formatDiagnosticBlock({ filePath, diagnostics }: DiagnosticBlock): string { return `LSP diagnostics after editing ${filePath}:\n\n${formatDiagnosticsForDisplay(diagnostics)}`; } diff --git a/packages/omo-codex/plugin/components/lsp/test/codex-hook-errors.test.ts b/packages/omo-codex/plugin/components/lsp/test/codex-hook-errors.test.ts new file mode 100644 index 000000000..076dfc331 --- /dev/null +++ b/packages/omo-codex/plugin/components/lsp/test/codex-hook-errors.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { runLspPostToolUseHook } from "../src/codex-hook.js"; + +describe("codex PostToolUse diagnostics errors", () => { + it("#given diagnostics runner throws for a mutated file #when the hook evaluates diagnostics #then it returns blocked output with the thrown message", async () => { + // given + const output = await runLspPostToolUseHook( + { + tool_name: "write", + tool_input: { path: "src/missing.ts" }, + tool_response: { ok: true }, + }, + async (filePath) => { + expect(filePath).toBe("src/missing.ts"); + throw new Error("ENOENT: no such file or directory, open 'src/missing.ts'"); + }, + ); + + // when + const parsed: unknown = JSON.parse(output); + if (!isPostToolUseHookOutput(parsed)) throw new TypeError("Expected PostToolUse hook output"); + + // then + expect(parsed.reason).toBe( + "LSP diagnostics after editing src/missing.ts:\n\nENOENT: no such file or directory, open 'src/missing.ts'", + ); + expect(parsed.hookSpecificOutput.additionalContext).toBe(parsed.reason); + }); +}); + +interface PostToolUseHookOutput { + readonly decision: "block"; + readonly reason: string; + readonly hookSpecificOutput: { + readonly hookEventName: "PostToolUse"; + readonly additionalContext: string; + }; +} + +function isPostToolUseHookOutput(value: unknown): value is PostToolUseHookOutput { + if (!isRecord(value)) return false; + const hookSpecificOutput = value["hookSpecificOutput"]; + return ( + value["decision"] === "block" && + typeof value["reason"] === "string" && + isRecord(hookSpecificOutput) && + hookSpecificOutput["hookEventName"] === "PostToolUse" && + typeof hookSpecificOutput["additionalContext"] === "string" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}