From 938fbfdb1f4880aa0ead12f25621c4d2348b1f88 Mon Sep 17 00:00:00 2001 From: Cole Tebou Date: Sun, 17 May 2026 20:29:17 +0000 Subject: [PATCH] chore(provider): format zod errors compactly for run.errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw ZodError JSON blobs in run.errors[].message with one-line path=value (code, expected ...) summaries. Adds formatZodIssue / formatZodError helpers and a parseOrThrow wrapper. Replaces the 16 schema.parse(output) call sites across codex, opencode, acpx, and grok providers (map / review / fix / revalidate). Behavior is unchanged: still throws ClawpatchError with code 'malformed-output' and exit code 8 — only the message text is compact. Bad values for enum mismatches are looked up from the original input via the issue path (zod 4 omits 'received' for invalid_value). --- CHANGELOG.md | 1 + src/provider.test.ts | 116 +++++++++++++++++++++++++++++++ src/provider.ts | 162 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 259 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570182c..113d7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 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. +- Improved provider schema validation failures so `run.errors[].message` shows a one-line `path=value (code, expected ...)` summary instead of a multi-kilobyte JSON-encoded zod issue blob. ## 0.3.0 - 2026-05-18 diff --git a/src/provider.test.ts b/src/provider.test.ts index 239185e..dd4ecc6 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -12,8 +12,11 @@ const { codexFailureMessage, extractAcpxJson, extractOpencodeJson, + formatZodError, + formatZodIssue, parseAcpxAgent, parseCodexJson, + parseOrThrow, piThinkingLevel, providerJsonSchema, } = __testing; @@ -555,6 +558,119 @@ describe("extractOpencodeJson", () => { }); }); +function makeBadReview(overrides: Record = {}): unknown { + return { + findings: [ + { + title: "x", + category: "quality", + severity: "medium", + confidence: "high", + evidence: [], + reasoning: "r", + reproduction: null, + recommendation: "rec", + whyTestsDoNotAlreadyCoverThis: "w", + suggestedRegressionTest: null, + minimumFixScope: "m", + ...overrides, + }, + ], + inspected: { files: [], symbols: [], notes: [] }, + }; +} + +describe("formatZodError", () => { + it("reports invalid enum compactly with bad value and expected list", () => { + const input = makeBadReview(); + const result = reviewOutputSchema.safeParse(input); + expect(result.success).toBe(false); + if (result.success) return; + const msg = formatZodError(result.error, input); + expect(msg).toMatch(/findings\[0\]\.category="quality"/u); + expect(msg).toMatch(/invalid_value/u); + expect(msg).toMatch(/expected one of [^()]*\bbug\b/u); + expect(msg.split("\n")).toHaveLength(1); + }); + + it("reports missing required field compactly", () => { + const bad = { + findings: [ + { + title: "x", + category: "bug", + severity: "medium", + confidence: "high", + evidence: [], + reproduction: null, + recommendation: "rec", + whyTestsDoNotAlreadyCoverThis: "w", + suggestedRegressionTest: null, + minimumFixScope: "m", + // reasoning omitted on purpose + }, + ], + inspected: { files: [], symbols: [], notes: [] }, + }; + const result = reviewOutputSchema.safeParse(bad); + expect(result.success).toBe(false); + if (result.success) return; + const msg = formatZodError(result.error, bad); + expect(msg).toMatch(/findings\[0\]\.reasoning/u); + expect(msg).toMatch(/invalid_type/u); + expect(msg).toMatch(/expected string/u); + }); + + it("truncates long received string values to a bounded preview", () => { + const longValue = "a".repeat(500); + const issue = formatZodIssue({ + code: "invalid_type", + path: ["findings", 0, "reasoning"], + message: "x", + expected: "string", + received: longValue, + } as unknown as Parameters[0]); + expect(issue.length).toBeLessThan(longValue.length); + expect(issue).toMatch(/findings\[0\]\.reasoning=/u); + }); + + it("includes a +N more suffix when zod reports many issues", () => { + const fakeError = { + issues: Array.from({ length: 5 }, (_, i) => ({ + code: "invalid_type", + path: ["x", i], + message: "x", + expected: "string", + received: "n", + })), + } as unknown as Parameters[0]; + const msg = formatZodError(fakeError); + expect(msg).toMatch(/\(\+2 more\)$/u); + }); +}); + +describe("parseOrThrow", () => { + it("returns parsed data on success", () => { + const ok = { + findings: [], + inspected: { files: [], symbols: [], notes: [] }, + }; + expect(parseOrThrow(reviewOutputSchema, ok, "test")).toEqual(ok); + }); + + it("throws ClawpatchError with malformed-output / exit 8 on bad input", () => { + expectMalformed( + () => + parseOrThrow( + reviewOutputSchema, + { findings: [{ category: "quality" }], inspected: {} }, + "test-label", + ), + /test-label: schema validation failed: findings\[0\]/u, + ); + }); +}); + describe("providerByName", () => { it("returns provider instances for optional CLI-backed providers", () => { expect(providerByName("acpx").name).toBe("acpx"); diff --git a/src/provider.ts b/src/provider.ts index 3bc387d..d002868 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,6 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ZodError, ZodIssue, ZodType } from "zod"; import { runCommandArgs } from "./exec.js"; import { ClawpatchError } from "./errors.js"; import { @@ -25,6 +26,124 @@ import { export { extractJson } from "./provider-json.js"; +const ZOD_VALUE_PREVIEW_LIMIT = 80; +const ZOD_ISSUE_HEAD_LIMIT = 3; + +function formatZodPath(path: ReadonlyArray): string { + if (path.length === 0) { + return ""; + } + let out = ""; + for (let i = 0; i < path.length; i += 1) { + const segment = path[i]; + if (typeof segment === "number") { + out += `[${segment}]`; + } else if (i === 0) { + out += String(segment); + } else { + out += `.${String(segment)}`; + } + } + return out; +} + +function previewZodValue(value: unknown): string { + let rendered: string; + if (typeof value === "string") { + rendered = JSON.stringify(value); + } else if (typeof value === "number" || typeof value === "boolean" || value === null) { + rendered = String(value); + } else if (value === undefined) { + return ""; + } else { + try { + rendered = JSON.stringify(value) ?? String(value); + } catch { + rendered = String(value); + } + } + if (rendered.length > ZOD_VALUE_PREVIEW_LIMIT) { + return `${rendered.slice(0, ZOD_VALUE_PREVIEW_LIMIT - 1)}…`; + } + return rendered; +} + +function lookupAtPath(input: unknown, path: ReadonlyArray): unknown { + let cur: unknown = input; + for (const segment of path) { + if (cur === null || cur === undefined) { + return undefined; + } + if (typeof segment === "number") { + if (!Array.isArray(cur)) { + return undefined; + } + cur = cur[segment]; + } else if (typeof cur === "object") { + cur = (cur as Record)[String(segment)]; + } else { + return undefined; + } + } + return cur; +} + +export function formatZodIssue(issue: ZodIssue, input?: unknown): string { + const path = formatZodPath(issue.path); + const issueRecord = issue as ZodIssue & { + received?: unknown; + expected?: unknown; + values?: unknown; + }; + let received: unknown; + let hasReceived = false; + if ("received" in issueRecord && issueRecord.received !== undefined) { + received = issueRecord.received; + hasReceived = true; + } else if (input !== undefined && issue.path.length > 0) { + const looked = lookupAtPath(input, issue.path); + if (looked !== undefined) { + received = looked; + hasReceived = true; + } + } + const receivedSegment = hasReceived ? `=${previewZodValue(received)}` : ""; + let expectedSegment = ""; + if (Array.isArray(issueRecord.values)) { + const list = issueRecord.values.map((v) => String(v)).join(","); + expectedSegment = `, expected one of ${list}`; + } else if (typeof issueRecord.expected === "string" && issueRecord.expected.length > 0) { + expectedSegment = `, expected ${issueRecord.expected}`; + } + return `${path}${receivedSegment} (${issue.code}${expectedSegment})`; +} + +export function formatZodError(error: ZodError, input?: unknown): string { + const issues = error.issues ?? []; + if (issues.length === 0) { + return "schema validation failed"; + } + const head = issues + .slice(0, ZOD_ISSUE_HEAD_LIMIT) + .map((issue) => formatZodIssue(issue, input)) + .join("; "); + const more = + issues.length > ZOD_ISSUE_HEAD_LIMIT ? ` (+${issues.length - ZOD_ISSUE_HEAD_LIMIT} more)` : ""; + return `schema validation failed: ${head}${more}`; +} + +function parseOrThrow(schema: ZodType, input: unknown, label: string): T { + const result = schema.safeParse(input); + if (result.success) { + return result.data; + } + throw new ClawpatchError( + `${label}: ${formatZodError(result.error, input)}`, + 8, + "malformed-output", + ); +} + export type ProviderOptions = { model: string | null; reasoningEffort: ReasoningEffort | null; @@ -75,15 +194,15 @@ const codexProvider: Provider = { }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runCodexJson(root, prompt, options, agentMapJsonSchema); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "codex agent-map"); }, async review(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runCodexJson(root, prompt, options, reviewJsonSchema); - return reviewOutputSchema.parse(output); + return parseOrThrow(reviewOutputSchema, output, "codex review"); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runCodexJson(root, prompt, options, fixPlanJsonSchema, "workspace-write"); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "codex fix-plan"); }, async revalidate( root: string, @@ -91,7 +210,7 @@ const codexProvider: Provider = { options: ProviderOptions, ): Promise { const output = await runCodexJson(root, prompt, options, revalidateJsonSchema); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "codex revalidate"); }, }; @@ -106,15 +225,15 @@ const opencodeProvider: Provider = { }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runOpencodeJson(root, prompt, options.model, agentMapJsonSchema, true); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "opencode agent-map"); }, async review(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runOpencodeJson(root, prompt, options.model, reviewJsonSchema, true); - return reviewOutputSchema.parse(output); + return parseOrThrow(reviewOutputSchema, output, "opencode review"); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runOpencodeJson(root, prompt, options.model, fixPlanJsonSchema, false); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "opencode fix-plan"); }, async revalidate( root: string, @@ -122,7 +241,7 @@ const opencodeProvider: Provider = { options: ProviderOptions, ): Promise { const output = await runOpencodeJson(root, prompt, options.model, revalidateJsonSchema, true); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "opencode revalidate"); }, }; @@ -145,15 +264,15 @@ const acpxProvider: Provider = { }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runAcpxJson(root, prompt, options.model, agentMapJsonSchema, "read"); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "acpx agent-map"); }, async review(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runAcpxJson(root, prompt, options.model, reviewJsonSchema, "read"); - return reviewOutputSchema.parse(output); + return parseOrThrow(reviewOutputSchema, output, "acpx review"); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runAcpxJson(root, prompt, options.model, fixPlanJsonSchema, "approve"); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "acpx fix-plan"); }, async revalidate( root: string, @@ -161,7 +280,7 @@ const acpxProvider: Provider = { options: ProviderOptions, ): Promise { const output = await runAcpxJson(root, prompt, options.model, revalidateJsonSchema, "read"); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "acpx revalidate"); }, }; @@ -176,15 +295,15 @@ const grokProvider: Provider = { }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runGrokJson(root, prompt, options.model, agentMapJsonSchema, true); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "grok agent-map"); }, async review(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runGrokJson(root, prompt, options.model, reviewJsonSchema, true); - return reviewOutputSchema.parse(output); + return parseOrThrow(reviewOutputSchema, output, "grok review"); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runGrokJson(root, prompt, options.model, fixPlanJsonSchema, false); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "grok fix-plan"); }, async revalidate( root: string, @@ -192,7 +311,7 @@ const grokProvider: Provider = { options: ProviderOptions, ): Promise { const output = await runGrokJson(root, prompt, options.model, revalidateJsonSchema, true); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "grok revalidate"); }, }; @@ -209,15 +328,15 @@ const piProvider: Provider = { }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runPiJson(root, prompt, options, agentMapJsonSchema, true); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "pi map"); }, async review(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runPiJson(root, prompt, options, reviewJsonSchema, true); - return reviewOutputSchema.parse(output); + return parseOrThrow(reviewOutputSchema, output, "pi review"); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runPiJson(root, prompt, options, fixPlanJsonSchema, false); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "pi fix-plan"); }, async revalidate( root: string, @@ -225,7 +344,7 @@ const piProvider: Provider = { options: ProviderOptions, ): Promise { const output = await runPiJson(root, prompt, options, revalidateJsonSchema, true); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "pi revalidate"); }, }; @@ -1070,8 +1189,11 @@ export const __testing = { codexFailureMessage, extractAcpxJson, extractOpencodeJson, + formatZodError, + formatZodIssue, parseAcpxAgent, parseCodexJson, + parseOrThrow, piThinkingLevel, providerJsonSchema, };