diff --git a/src/agents/cli-output.test.ts b/src/agents/cli-output.test.ts index de875579b0c..2eed0fb02eb 100644 --- a/src/agents/cli-output.test.ts +++ b/src/agents/cli-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseCliJson, parseCliJsonl } from "./cli-output.js"; +import { extractCliErrorMessage, parseCliJson, parseCliJsonl } from "./cli-output.js"; describe("parseCliJson", () => { it("recovers mixed-output Claude session metadata from embedded JSON objects", () => { @@ -246,4 +246,41 @@ describe("parseCliJsonl", () => { usage: undefined, }); }); + + it("extracts nested Claude API errors from failed stream-json output", () => { + const message = + "Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going."; + const apiError = `API Error: 400 ${JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message, + }, + request_id: "req_011CZqHuXhFetYCnr8325DQc", + })}`; + const result = extractCliErrorMessage( + [ + JSON.stringify({ type: "system", subtype: "init", session_id: "session-api-error" }), + JSON.stringify({ + type: "assistant", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: apiError }], + }, + session_id: "session-api-error", + error: "unknown", + }), + JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: apiError, + session_id: "session-api-error", + }), + ].join("\n"), + ); + + expect(result).toBe(message); + }); }); diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 61d56586a48..16a344521e2 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -103,6 +103,42 @@ function parseJsonRecordCandidates(raw: string): Record[] { return parsedRecords; } +function readNestedErrorMessage(parsed: Record): string | undefined { + if (isRecord(parsed.error)) { + const errorMessage = readNestedErrorMessage(parsed.error); + if (errorMessage) { + return errorMessage; + } + } + if (typeof parsed.message === "string") { + const trimmed = parsed.message.trim(); + if (trimmed) { + return trimmed; + } + } + if (typeof parsed.error === "string") { + const trimmed = parsed.error.trim(); + if (trimmed) { + return trimmed; + } + } + return undefined; +} + +function unwrapCliErrorText(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + for (const parsed of parseJsonRecordCandidates(trimmed)) { + const nested = readNestedErrorMessage(parsed); + if (nested) { + return nested; + } + } + return trimmed; +} + function toCliUsage(raw: Record): CliUsage | undefined { const pick = (key: string) => typeof raw[key] === "number" && raw[key] > 0 ? raw[key] : undefined; @@ -174,6 +210,35 @@ function collectCliText(value: unknown): string { return ""; } +function collectExplicitCliErrorText(parsed: Record): string { + const nested = readNestedErrorMessage(parsed); + if (nested) { + return unwrapCliErrorText(nested); + } + + if (parsed.is_error === true && typeof parsed.result === "string") { + return unwrapCliErrorText(parsed.result); + } + + if (parsed.type === "assistant") { + const text = collectCliText(parsed.message); + if (/^\s*API Error:/i.test(text)) { + return unwrapCliErrorText(text); + } + } + + if (parsed.type === "error") { + const text = + collectCliText(parsed.message) || + collectCliText(parsed.content) || + collectCliText(parsed.result) || + collectCliText(parsed); + return unwrapCliErrorText(text); + } + + return ""; +} + function pickCliSessionId( parsed: Record, backend: CliBackendConfig, @@ -438,3 +503,20 @@ export function parseCliOutput(params: { } ); } + +export function extractCliErrorMessage(raw: string): string | null { + const parsedRecords = parseJsonRecordCandidates(raw); + if (parsedRecords.length === 0) { + return null; + } + + let errorText = ""; + for (const parsed of parsedRecords) { + const next = collectExplicitCliErrorText(parsed); + if (next) { + errorText = next; + } + } + + return errorText || null; +} diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 00aa6ec1867..9dfbabb1f30 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -355,6 +355,66 @@ describe("runCliAgent spawn path", () => { } }); + it("surfaces nested Claude stream-json API errors instead of raw event output", async () => { + const message = + "Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going."; + const apiError = `API Error: 400 ${JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + message, + }, + request_id: "req_011CZqHuXhFetYCnr8325DQc", + })}`; + + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 1, + exitSignal: null, + durationMs: 50, + stdout: [ + JSON.stringify({ type: "system", subtype: "init", session_id: "session-api-error" }), + JSON.stringify({ + type: "assistant", + message: { + model: "", + role: "assistant", + content: [{ type: "text", text: apiError }], + }, + session_id: "session-api-error", + error: "unknown", + }), + JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: apiError, + session_id: "session-api-error", + }), + ].join("\n"), + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + ); + + const run = executePreparedCliRun( + buildPreparedCliRunContext({ + provider: "claude-cli", + model: "sonnet", + runId: "run-claude-api-error", + }), + ); + + await expect(run).rejects.toMatchObject({ + name: "FailoverError", + message, + reason: "billing", + status: 402, + }); + }); + it("sanitizes dangerous backend env overrides before spawn", async () => { mockSuccessfulCliRun(); await executePreparedCliRun( diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 988f6d31ae5..96b74ad4b7e 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -7,7 +7,12 @@ import { enqueueSystemEvent as enqueueSystemEventImpl } from "../../infra/system import { getProcessSupervisor as getProcessSupervisorImpl } from "../../process/supervisor/index.js"; import { scopedHeartbeatWakeOptions } from "../../routing/session-key.js"; import { prependBootstrapPromptWarning } from "../bootstrap-budget.js"; -import { createCliJsonlStreamingParser, parseCliOutput, type CliOutput } from "../cli-output.js"; +import { + createCliJsonlStreamingParser, + extractCliErrorMessage, + parseCliOutput, + type CliOutput, +} from "../cli-output.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; import { classifyFailoverReason } from "../pi-embedded-helpers.js"; import { @@ -321,7 +326,11 @@ export async function executePreparedCliRun( status: resolveFailoverStatus("timeout"), }); } - const err = stderr || stdout || "CLI failed."; + const primaryErrorText = stderr || stdout; + const structuredError = + extractCliErrorMessage(primaryErrorText) ?? + (stderr ? extractCliErrorMessage(stdout) : null); + const err = structuredError || primaryErrorText || "CLI failed."; const reason = classifyFailoverReason(err, { provider: params.provider }) ?? "unknown"; const status = resolveFailoverStatus(reason); throw new FailoverError(err, { diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 28cc46cf013..098fbc9e2bd 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -222,6 +222,7 @@ describe("isBillingErrorMessage", () => { const samples = [ "You're out of extra usage. Add more at claude.ai/settings/usage and keep going.", "Extra usage is required for long context requests.", + "Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going.", '{"type":"error","error":{"type":"invalid_request_error","message":"You\'re out of extra usage. Add more at claude.ai/settings/usage and keep going."}}', '{"type":"error","error":{"type":"invalid_request_error","message":"Extra usage is required for long context requests."}}', ]; diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index d2cb54f9898..ea1c377e3f5 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -123,6 +123,7 @@ const ERROR_PATTERNS = { "insufficient usd or diem balance", /requires?\s+more\s+credits/i, /out of extra usage/i, + /draw from your extra usage/i, /extra usage is required(?: for long context requests)?/i, ], authPermanent: HIGH_CONFIDENCE_AUTH_PERMANENT_PATTERNS,