diff --git a/CHANGELOG.md b/CHANGELOG.md index f0329a0..7673b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.3.1 - Unreleased +- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi. - Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi. - Fixed Bun package-manager detection to recognize the text `bun.lock` lockfile, thanks @austinm911. diff --git a/src/provider.test.ts b/src/provider.test.ts index 3c9b82e..239185e 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { ClawpatchError } from "./errors.js"; import { __testing, extractJson, providerByName } from "./provider.js"; +import { safeProviderPreview } from "./provider-json.js"; import { revalidateOutputSchema, reviewOutputSchema } from "./types.js"; // eslint-disable-next-line no-underscore-dangle @@ -55,6 +56,10 @@ function expectMalformed(fn: () => unknown, message: RegExp): void { throw new Error("expected malformed-output"); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + describe("extractJson", () => { it("parses strict JSON directly", () => { const input = '{"findings":[],"inspected":{"files":[],"symbols":[],"notes":[]}}'; @@ -484,6 +489,46 @@ describe("extractOpencodeJson", () => { expectMalformed(() => extractOpencodeJson(stdout), /no extractable text.*step_finish/u); }); + it("treats whitespace-only opencode text as no extractable text", () => { + const stdout = [ + JSON.stringify({ type: "text", part: { text: " \n\t " } }), + JSON.stringify({ type: "step_finish", part: { reason: "stop" } }), + ].join("\n"); + + expectMalformed(() => extractOpencodeJson(stdout), /no extractable text.*text, step_finish/u); + }); + + it("throws malformed-output with a preview when opencode text is unparsable", () => { + const stdout = [ + JSON.stringify({ + type: "text", + part: { text: '{"findings": [' }, + }), + JSON.stringify({ type: "step_finish", part: { reason: "stop" } }), + ].join("\n"); + + expectMalformed( + () => extractOpencodeJson(stdout), + /unparsable JSON.*text chars=14.*observed event kinds: \[text, step_finish\].*output preview: \{"findings": \[/u, + ); + }); + + it("bounds the opencode unparsable text preview", () => { + const text = `{"findings":["${"x".repeat(300)}`; + const stdout = JSON.stringify({ + type: "text", + part: { text }, + }); + const preview = safeProviderPreview(text); + + expect(preview.length).toBe(200); + + expectMalformed( + () => extractOpencodeJson(stdout), + new RegExp(`output preview: ${escapeRegExp(preview)}\\)`, "u"), + ); + }); + it("throws provider-failure for opencode error events", () => { const stdout = JSON.stringify({ type: "error", diff --git a/src/provider.ts b/src/provider.ts index 1efacf1..72628e6 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -630,7 +630,13 @@ export function extractOpencodeJson(stdout: string): unknown { } const parsed = extractJson(combined); if (parsed === null) { - throw new ClawpatchError("opencode provider produced unparsable JSON", 8, "malformed-output"); + throw new ClawpatchError( + `opencode provider produced unparsable JSON ` + + `(text chars=${combined.length}, observed event kinds: ` + + `[${[...observedKinds].join(", ")}], output preview: ${safeProviderPreview(combined)})`, + 8, + "malformed-output", + ); } return parsed; }