diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 284e7a2d5a..b57a7262be 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -295,9 +295,14 @@ export function createClaudeCodeAi(parentEnv: Record ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"], { env, input: prompt, timeoutMs: resolveCliTimeoutMs(parentEnv), cwd: await isolatedCliCwd() }, ); - if (code !== 0) throw new Error(`claude_code_exit_${code ?? "null"}: ${redactSecrets(stderr ?? "", [token]).slice(0, 500)}`); + // Surface the STRUCTURED error envelope FIRST. `claude --output-format json` reports API/auth/model errors in its + // stdout JSON ({is_error,api_error_status}) on a NON-ZERO exit too — e.g. an unknown model exits 1 with the 404 + // envelope in stdout and EMPTY stderr. Checking it before the exit code turns an opaque `claude_code_exit_1: ` + // (the #1610 symptom) into a precise `claude_code_error_404` — the signal that makes a reviewer outage + // diagnosable in logs + Sentry instead of a dead end. const errStatus = claudeErrorStatus(stdout); if (errStatus) throw new Error(`claude_code_error_${errStatus}`); + if (code !== 0) throw new Error(`claude_code_exit_${code ?? "null"}: ${redactSecrets(stderr ?? "", [token]).slice(0, 500)}`); const text = extractCliText(stdout); if (!text) throw new Error("claude_code_empty_output"); return { response: text }; diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index 52e1e2b391..2f10329e82 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -359,6 +359,13 @@ describe("subscription CLI helpers + fail-safe", () => { const stub: StubSpawn = async () => ({ stdout: JSON.stringify({ is_error: true, api_error_status: 401, result: "Failed to authenticate" }), code: 0 }); await expect(createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, stub).run("m", { prompt: "x" })).rejects.toThrow(/claude_code_error_401/); }); + it("surfaces the structured stdout error on a NON-ZERO exit (precise status, not opaque exit code) (#1610)", async () => { + // Regression: an unknown model exits 1 with the error envelope in STDOUT ({is_error,api_error_status:404}) and + // EMPTY stderr. The exit-code throw used to win → `claude_code_exit_1: ` (blank, undiagnosable). Now the + // structured status is checked first → `claude_code_error_404`, the signal that surfaces in logs + Sentry. + const stub: StubSpawn = async () => ({ stdout: JSON.stringify({ is_error: true, api_error_status: 404, result: "There's an issue with the selected model (claude-code)." }), code: 1, stderr: "" }); + await expect(createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, stub).run("m", { prompt: "x" })).rejects.toThrow(/claude_code_error_404/); + }); it("Claude Code returns the model text on success and scrubs billable keys", async () => { let capturedEnv: Record = {}; const stub: StubSpawn = async (_c, _a, o) => {