diff --git a/src/agents/cli-output.test.ts b/src/agents/cli-output.test.ts index b7b96f5965a6..0771e30e05a2 100644 --- a/src/agents/cli-output.test.ts +++ b/src/agents/cli-output.test.ts @@ -5,6 +5,7 @@ import { extractCliErrorMessage, parseCliJson, parseCliJsonl, + parseCliOutput, supportsCliJsonlToolEvents, type CliToolResultDelta, type CliToolUseStartDelta, @@ -34,6 +35,114 @@ describe("supportsCliJsonlToolEvents", () => { }); describe("parseCliJson", () => { + it("classifies Claude is_error JSON results as provider errors", () => { + const result = parseCliJson( + JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: 'API Error: 400 {"error":{"message":"Bad request"}}', + }), + { + command: "claude", + output: "json", + sessionIdFields: ["session_id"], + }, + "claude-cli", + ); + + expect(result).toEqual({ + text: "", + sessionId: undefined, + usage: undefined, + errorText: "Bad request", + }); + }); + + it("classifies generic is_error JSON results as provider errors", () => { + const result = parseCliJson( + JSON.stringify({ + is_error: true, + result: "429 rate limit exceeded", + }), + { + command: "custom", + output: "json", + }, + "custom-cli", + ); + + expect(result).toEqual({ + text: "", + sessionId: undefined, + usage: undefined, + errorText: "429 rate limit exceeded", + }); + }); + + it("keeps successful JSON result message payloads as assistant text", () => { + const result = parseCliJson( + JSON.stringify({ + type: "result", + message: "done", + }), + { + command: "custom", + output: "json", + }, + "custom-cli", + ); + + expect(result).toEqual({ + text: "done", + sessionId: undefined, + usage: undefined, + }); + }); + + it("does not classify null JSON result error fields as provider errors", () => { + const result = parseCliJson( + JSON.stringify({ + type: "result", + error: null, + message: "done", + }), + { + command: "custom", + output: "json", + }, + "custom-cli", + ); + + expect(result).toEqual({ + text: "done", + sessionId: undefined, + usage: undefined, + }); + }); + + it("classifies JSON status error result payloads as provider errors", () => { + const result = parseCliJson( + JSON.stringify({ + type: "result", + status: "error", + result: "rate limit", + }), + { + command: "custom", + output: "json", + }, + "custom-cli", + ); + + expect(result).toEqual({ + text: "", + sessionId: undefined, + usage: undefined, + errorText: "rate limit", + }); + }); + it("recovers mixed-output Claude session metadata from embedded JSON objects", () => { const result = parseCliJson( [ @@ -752,6 +861,103 @@ describe("parseCliJsonl", () => { expect(result).toBe(message); }); + + it("classifies Claude is_error stream-json results as provider errors", () => { + const { message, jsonl } = createClaudeApiErrorFixture(); + const result = parseCliJsonl( + jsonl, + { + command: "claude", + output: "jsonl", + sessionIdFields: ["session_id"], + }, + "claude-cli", + ); + + expect(result).toEqual({ + text: "", + sessionId: "session-api-error", + usage: undefined, + errorText: message, + }); + }); + + it("uses Claude error subtypes when result text is absent", () => { + const result = parseCliJsonl( + JSON.stringify({ + type: "result", + subtype: "error_max_turns", + session_id: "session-max-turns", + }), + { + command: "claude", + output: "jsonl", + sessionIdFields: ["session_id"], + }, + "claude-cli", + ); + + expect(result).toEqual({ + text: "", + sessionId: "session-max-turns", + usage: undefined, + errorText: "Claude CLI result subtype error_max_turns.", + }); + }); +}); + +describe("parseCliOutput", () => { + it("uses streamed Claude assistant text when the result envelope is missing", () => { + const raw = [ + JSON.stringify({ type: "init", session_id: "session-stream-missing-result" }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "partial answer" }, + }, + }), + ].join("\n"); + + const result = parseCliOutput({ + raw, + backend: { + command: "claude", + output: "jsonl", + sessionIdFields: ["session_id"], + }, + providerId: "claude-cli", + outputMode: "jsonl", + }); + + expect(result).toEqual({ + text: "partial answer", + sessionId: "session-stream-missing-result", + usage: undefined, + }); + }); + + it("fails stream-json output without result or assistant text instead of returning raw JSONL", () => { + const raw = JSON.stringify({ type: "init", session_id: "session-empty" }); + + const result = parseCliOutput({ + raw, + backend: { + command: "claude", + output: "jsonl", + sessionIdFields: ["session_id"], + }, + providerId: "claude-cli", + outputMode: "jsonl", + }); + + expect(result).toEqual({ + text: "", + sessionId: "session-empty", + usage: undefined, + errorText: "CLI stream-json output ended without a result event.", + }); + }); }); describe("createCliJsonlStreamingParser", () => { @@ -787,6 +993,66 @@ describe("createCliJsonlStreamingParser", () => { ]); }); + it("uses streamed Claude assistant text when no result envelope arrives", () => { + const parser = createCliJsonlStreamingParser({ + backend: { + command: "local-cli", + output: "jsonl", + jsonlDialect: "claude-stream-json", + sessionIdFields: ["session_id"], + }, + providerId: "local-cli", + onAssistantDelta: () => {}, + }); + + parser.push( + [ + JSON.stringify({ type: "init", session_id: "session-stream-no-result" }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "streamed answer" }, + }, + }), + ].join("\n") + "\n", + ); + parser.finish(); + + expect(parser.getOutput()).toEqual({ + text: "streamed answer", + sessionId: "session-stream-no-result", + usage: undefined, + }); + }); + + it("reports an output-limit error and ignores later chunks", () => { + const parser = createCliJsonlStreamingParser({ + backend: { + command: "local-cli", + output: "jsonl", + jsonlDialect: "claude-stream-json", + reliability: { outputLimits: { maxTurnRawChars: 1024 } }, + }, + providerId: "local-cli", + onAssistantDelta: () => {}, + }); + + parser.push("x".repeat(1025)); + parser.push(`${JSON.stringify({ type: "result", result: "late" })}\n`); + parser.finish(); + + expect(parser.getErrorText()).toBe( + "CLI JSONL output exceeded 1024 characters; refusing to parse output.", + ); + expect(parser.getOutput()).toEqual({ + text: "", + sessionId: undefined, + usage: undefined, + errorText: "CLI JSONL output exceeded 1024 characters; refusing to parse output.", + }); + }); + it("streams Gemini message deltas and tool events", () => { const deltas: Array<{ text: string; delta: string; sessionId?: string }> = []; const starts: CliToolUseStartDelta[] = []; diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index c511a7c81238..6adfcf9264ec 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -54,6 +54,14 @@ export type CliOutput = { yielded?: true; }; +export const CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024; +const CLI_STREAM_JSON_MIN_TURN_RAW_CHARS = 1_024; +const CLI_STREAM_JSON_MAX_CONFIGURABLE_TURN_RAW_CHARS = 64 * 1024 * 1024; +const CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES = 20_000; +const CLI_STREAM_JSON_MIN_TURN_LINES = 100; +const CLI_STREAM_JSON_MAX_CONFIGURABLE_TURN_LINES = 100_000; +const CLI_STREAM_JSON_MISSING_RESULT_ERROR = "CLI stream-json output ended without a result event."; + /** Incremental assistant text emitted while parsing a streaming CLI response. */ export type CliStreamingDelta = { text: string; @@ -62,6 +70,12 @@ export type CliStreamingDelta = { usage?: CliUsage; }; +export type CliStreamJsonOutputLimits = { + maxTurnRawChars: number; + maxPendingLineChars: number; + maxTurnLines: number; +}; + /** Tool-call start event reconstructed from CLI stream output. */ export type CliToolUseStartDelta = { toolCallId: string; @@ -94,6 +108,10 @@ function isGeminiStreamJsonDialect(params: { ); } +function isStreamJsonDialect(params: { backend: CliBackendConfig; providerId: string }): boolean { + return supportsCliJsonlToolEvents(params); +} + /** Returns whether JSONL output carries correlated provider tool events. */ export function supportsCliJsonlToolEvents(params: { backend: CliBackendConfig; @@ -302,15 +320,33 @@ function unwrapNestedCliResultText(raw: string): string { } function collectExplicitCliErrorText(parsed: Record): string { + const subtype = typeof parsed.subtype === "string" ? parsed.subtype.trim() : ""; + const isResultError = + parsed.is_error === true || + (parsed.type === "result" && (subtype.startsWith("error_") || parsed.status === "error")); + if (isResultError) { + const text = + collectCliText(parsed.result) || + collectCliText(parsed.message) || + collectCliText(parsed.content); + if (text) { + return unwrapCliErrorText(text); + } + const nested = readNestedErrorMessage(parsed); + if (nested) { + return unwrapCliErrorText(nested); + } + if (subtype) { + return `Claude CLI result subtype ${subtype}.`; + } + return "CLI result was marked as an error."; + } + 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)) { @@ -359,6 +395,60 @@ function shouldUnwrapNestedCliResultText(params: { return !Object.hasOwn(params.parsed, "type") || params.parsed.type === "result"; } +function normalizePositiveInt( + value: number | undefined, + fallback: number, + min: number, + max: number, +): number { + if (typeof value !== "number" || !Number.isInteger(value)) { + return fallback; + } + return Math.min(Math.max(value, min), max); +} + +export function resolveCliStreamJsonOutputLimits( + backend: CliBackendConfig, +): CliStreamJsonOutputLimits { + const configured = backend.reliability?.outputLimits; + const maxTurnRawChars = normalizePositiveInt( + configured?.maxTurnRawChars, + CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, + CLI_STREAM_JSON_MIN_TURN_RAW_CHARS, + CLI_STREAM_JSON_MAX_CONFIGURABLE_TURN_RAW_CHARS, + ); + return { + maxTurnRawChars, + maxPendingLineChars: maxTurnRawChars, + maxTurnLines: normalizePositiveInt( + configured?.maxTurnLines, + CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES, + CLI_STREAM_JSON_MIN_TURN_LINES, + CLI_STREAM_JSON_MAX_CONFIGURABLE_TURN_LINES, + ), + }; +} + +function streamJsonOutputLimitErrorText(kind: "raw" | "line" | "lines", limit: number): string { + if (kind === "line") { + return `CLI JSONL line exceeded ${limit} characters; refusing to parse output.`; + } + if (kind === "lines") { + return `CLI JSONL output exceeded ${limit} lines; refusing to parse output.`; + } + return `CLI JSONL output exceeded ${limit} characters; refusing to parse output.`; +} + +function hasExplicitCliErrorPayload(parsed: Record): boolean { + if (typeof parsed.error === "string") { + return Boolean(parsed.error.trim()); + } + if (isRecord(parsed.error)) { + return Boolean(readNestedErrorMessage(parsed.error)); + } + return false; +} + /** Parses JSON CLI output, including mixed stdout that contains embedded JSON objects. */ /** Parses a single JSON payload emitted by a CLI backend. */ export function parseCliJson( @@ -378,6 +468,18 @@ export function parseCliJson( for (const parsed of parsedRecords) { sessionId = pickCliSessionId(parsed, backend) ?? sessionId; usage = readCliUsage(parsed) ?? usage; + const subtype = typeof parsed.subtype === "string" ? parsed.subtype.trim() : ""; + const shouldClassifyError = + parsed.is_error === true || + parsed.type === "error" || + (parsed.type === "result" && + (subtype.startsWith("error_") || + parsed.status === "error" || + hasExplicitCliErrorPayload(parsed))); + const errorText = shouldClassifyError ? collectExplicitCliErrorText(parsed) : ""; + if (errorText) { + return { text: "", sessionId, usage, errorText }; + } const nextText = collectCliText(parsed.message) || collectCliText(parsed.content) || @@ -415,11 +517,19 @@ function parseClaudeCliJsonlResult(params: { if (!supportsCliJsonlToolEvents(params)) { return null; } - if ( - typeof params.parsed.type === "string" && - params.parsed.type === "result" && - typeof params.parsed.result === "string" - ) { + if (typeof params.parsed.type === "string" && params.parsed.type === "result") { + const errorText = collectExplicitCliErrorText(params.parsed); + if (errorText) { + return { + text: "", + sessionId: params.sessionId, + usage: params.usage, + errorText, + }; + } + if (typeof params.parsed.result !== "string") { + return null; + } const resultText = unwrapNestedCliResultText(params.parsed.result).trim(); if (resultText) { return { text: resultText, sessionId: params.sessionId, usage: params.usage }; @@ -754,8 +864,12 @@ export function createCliJsonlStreamingParser(params: { let sessionId: string | undefined; let usage: CliUsage | undefined; let output: CliOutput | null = null; + let parseErrorText = ""; + let rawChars = 0; + let rawLines = 0; const texts: string[] = []; const toolTracker = createToolUseTracker(); + const outputLimits = resolveCliStreamJsonOutputLimits(params.backend); // Classification is keyed on consumer presence so reclassified pre-tool text // always has a destination; a separate enable flag let it be dropped (#92092). const classifyClaudeCommentary = @@ -788,6 +902,9 @@ export function createCliJsonlStreamingParser(params: { }; const handleParsedRecord = (parsed: Record) => { + if (parseErrorText) { + return; + } sessionId = pickCliSessionId(parsed, params.backend) ?? sessionId; if (!sessionId && typeof parsed.thread_id === "string") { sessionId = parsed.thread_id.trim(); @@ -919,6 +1036,9 @@ export function createCliJsonlStreamingParser(params: { const flushLines = (flushPartial: boolean) => { while (true) { + if (parseErrorText) { + return; + } const newlineIndex = lineBuffer.indexOf("\n"); if (newlineIndex < 0) { break; @@ -928,6 +1048,12 @@ export function createCliJsonlStreamingParser(params: { if (!line) { continue; } + rawLines += 1; + if (rawLines > outputLimits.maxTurnLines) { + parseErrorText = streamJsonOutputLimitErrorText("lines", outputLimits.maxTurnLines); + lineBuffer = ""; + return; + } for (const parsed of parseJsonRecordCandidates(line)) { handleParsedRecord(parsed); } @@ -947,23 +1073,43 @@ export function createCliJsonlStreamingParser(params: { return { push(chunk: string) { - if (!chunk) { + if (!chunk || parseErrorText) { + return; + } + rawChars += chunk.length; + if (rawChars > outputLimits.maxTurnRawChars) { + parseErrorText = streamJsonOutputLimitErrorText("raw", outputLimits.maxTurnRawChars); + lineBuffer = ""; + return; + } + if (lineBuffer.length + chunk.length > outputLimits.maxPendingLineChars) { + parseErrorText = streamJsonOutputLimitErrorText("line", outputLimits.maxPendingLineChars); + lineBuffer = ""; return; } lineBuffer += chunk; flushLines(false); }, finish() { + if (parseErrorText) { + return; + } flushLines(true); if (classifyClaudeCommentary) { flushPendingClaudeAssistantText(); } }, + getErrorText() { + return parseErrorText || null; + }, getOutput() { + if (parseErrorText) { + return { text: "", sessionId, usage, errorText: parseErrorText }; + } if (output) { return output; } - if (isGeminiStreamJsonDialect(params) && (assistantText.trim() || sessionId || usage)) { + if (isStreamJsonDialect(params) && assistantText.trim()) { return { text: assistantText.trim(), sessionId, usage }; } const text = texts.join("\n").trim(); @@ -986,9 +1132,10 @@ export function parseCliJsonl( let sessionId: string | undefined; let usage: CliUsage | undefined; const texts: string[] = []; - let geminiText = ""; + let streamJsonText = ""; let geminiErrorText: string | undefined; let sawGeminiStructuredOutput = false; + const streamJsonDialect = isStreamJsonDialect({ backend, providerId }); for (const line of lines) { for (const parsed of parseJsonRecordCandidates(line)) { sessionId = pickCliSessionId(parsed, backend) ?? sessionId; @@ -1013,7 +1160,7 @@ export function parseCliJsonl( parsed.role === "assistant" && typeof parsed.content === "string" ) { - geminiText = `${geminiText}${parsed.content}`; + streamJsonText = `${streamJsonText}${parsed.content}`; sawGeminiStructuredOutput = true; continue; } @@ -1037,6 +1184,19 @@ export function parseCliJsonl( return claudeResult; } + const claudeDelta = parseClaudeCliStreamingDelta({ + backend, + providerId, + parsed, + textSoFar: streamJsonText, + sessionId, + usage, + }); + if (claudeDelta) { + streamJsonText = claudeDelta.text; + continue; + } + const item = isRecord(parsed.item) ? parsed.item : null; if (item && typeof item.text === "string") { const type = normalizeLowercaseStringOrEmpty(item.type); @@ -1049,11 +1209,11 @@ export function parseCliJsonl( if (isGeminiStreamJsonDialect({ backend, providerId }) && geminiErrorText) { return { text: "", sessionId, usage, errorText: geminiErrorText }; } - if ( - isGeminiStreamJsonDialect({ backend, providerId }) && - (sawGeminiStructuredOutput || sessionId || usage) - ) { - return { text: geminiText.trim(), sessionId, usage }; + if (streamJsonDialect && (streamJsonText.trim() || sawGeminiStructuredOutput)) { + return { text: streamJsonText.trim(), sessionId, usage }; + } + if (streamJsonDialect) { + return { text: "", sessionId, usage, errorText: CLI_STREAM_JSON_MISSING_RESULT_ERROR }; } const text = texts.join("\n").trim(); if (!text) { @@ -1076,12 +1236,18 @@ export function parseCliOutput(params: { return { text: params.raw.trim(), sessionId: params.fallbackSessionId }; } if (outputMode === "jsonl") { - return ( - parseCliJsonl(params.raw, params.backend, params.providerId) ?? { - text: params.raw.trim(), + const parsed = parseCliJsonl(params.raw, params.backend, params.providerId); + if (parsed) { + return parsed; + } + if (isStreamJsonDialect(params)) { + return { + text: "", sessionId: params.fallbackSessionId, - } - ); + errorText: CLI_STREAM_JSON_MISSING_RESULT_ERROR, + }; + } + return { text: params.raw.trim(), sessionId: params.fallbackSessionId }; } return ( parseCliJson(params.raw, params.backend, params.providerId) ?? { diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index c68eac4ff9b9..af29973630d6 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -82,6 +82,23 @@ afterEach(() => { replyRunTesting.resetReplyRunRegistry(); }); +const CLAUDE_OK_JSONL = `${JSON.stringify({ type: "result", result: "ok" })}\n`; + +function mockSuccessfulClaudeJsonlRun() { + supervisorSpawnMock.mockResolvedValueOnce( + createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: CLAUDE_OK_JSONL, + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }), + ); +} + function buildPreparedCliRunContext(params: { provider: "claude-cli" | "codex-cli" | "google-gemini-cli"; model: string; @@ -330,7 +347,7 @@ describe("runCliAgent spawn path", () => { exitCode: 0, exitSignal: null, durationMs: 50, - stdout: "ok", + stdout: CLAUDE_OK_JSONL, stderr: "", timedOut: false, noOutputTimedOut: false, @@ -418,7 +435,7 @@ describe("runCliAgent spawn path", () => { exitCode: 0, exitSignal: null, durationMs: 50, - stdout: "ok", + stdout: CLAUDE_OK_JSONL, stderr: "", timedOut: false, noOutputTimedOut: false, @@ -457,7 +474,7 @@ describe("runCliAgent spawn path", () => { exitCode: 0, exitSignal: null, durationMs: 50, - stdout: "ok", + stdout: CLAUDE_OK_JSONL, stderr: "", timedOut: false, noOutputTimedOut: false, @@ -476,7 +493,7 @@ describe("runCliAgent spawn path", () => { }); it("passes --session-id for new Claude sessions", async () => { - mockSuccessfulCliRun(); + mockSuccessfulClaudeJsonlRun(); await executePreparedCliRun( buildPreparedCliRunContext({ @@ -499,7 +516,7 @@ describe("runCliAgent spawn path", () => { }); it("does not pass a Claude session id for side-question runs", async () => { - mockSuccessfulCliRun(); + mockSuccessfulClaudeJsonlRun(); const resolveExecutionArgs = vi.fn(({ baseArgs }) => [...baseArgs, "--max-turns", "1"]); await executePreparedCliRun( @@ -523,7 +540,7 @@ describe("runCliAgent spawn path", () => { }); it("applies backend-owned per-run args before spawning", async () => { - mockSuccessfulCliRun(); + mockSuccessfulClaudeJsonlRun(); const resolveExecutionArgs = vi.fn(({ baseArgs }) => [...baseArgs, "--effort", "high"]); await executePreparedCliRun( @@ -609,7 +626,7 @@ describe("runCliAgent spawn path", () => { exitCode: 0, exitSignal: null, durationMs: 50, - stdout: "ok", + stdout: CLAUDE_OK_JSONL, stderr: "", timedOut: false, noOutputTimedOut: false, @@ -669,7 +686,7 @@ describe("runCliAgent spawn path", () => { exitCode: 0, exitSignal: null, durationMs: 50, - stdout: "ok", + stdout: CLAUDE_OK_JSONL, stderr: "", timedOut: false, noOutputTimedOut: false, @@ -3719,7 +3736,7 @@ ${JSON.stringify({ vi.stubEnv("OTEL_EXPORTER_OTLP_PROTOCOL", "none"); vi.stubEnv("OTEL_SDK_DISABLED", "true"); vi.stubEnv("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"); - mockSuccessfulCliRun(); + mockSuccessfulClaudeJsonlRun(); await executePreparedCliRun( buildPreparedCliRunContext({ diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index f11d9f9d7132..39bb410066b5 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -24,13 +24,16 @@ import { } from "../../infra/exec-approvals.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { + CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS, createCliJsonlStreamingParser, extractCliErrorMessage, parseCliOutput, type CliOutput, + type CliStreamJsonOutputLimits, type CliStreamingDelta, type CliToolResultDelta, type CliToolUseStartDelta, + resolveCliStreamJsonOutputLimits, } from "../cli-output.js"; import { classifyFailoverReason } from "../embedded-agent-helpers.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; @@ -81,11 +84,7 @@ type ClaudeLiveSession = { type ClaudeLiveRunResult = { output: CliOutput; }; -type ClaudeLiveOutputLimits = { - maxTurnRawChars: number; - maxPendingLineChars: number; - maxTurnLines: number; -}; +type ClaudeLiveOutputLimits = CliStreamJsonOutputLimits; type ClaudeLiveExecPermission = { security: ExecSecurity; ask: ExecAsk; @@ -111,12 +110,6 @@ const CLAUDE_LIVE_IDLE_TIMEOUT_MS = 10 * 60 * 1_000; const CLAUDE_LIVE_ACTIVE_TOOL_PROGRESS_MS = 10_000; const CLAUDE_LIVE_MAX_SESSIONS = 16; const CLAUDE_LIVE_MAX_STDERR_CHARS = 64 * 1024; -const CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024; -const CLAUDE_LIVE_MIN_TURN_RAW_CHARS = 1_024; -const CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_RAW_CHARS = 64 * 1024 * 1024; -const CLAUDE_LIVE_DEFAULT_MAX_TURN_LINES = 20_000; -const CLAUDE_LIVE_MIN_TURN_LINES = 100; -const CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_LINES = 100_000; const CLAUDE_LIVE_CLOSE_WAIT_TIMEOUT_MS = 5_000; const liveSessions = new Map(); const liveSessionCreates = new Map>(); @@ -732,38 +725,6 @@ function parseSessionId(parsed: Record): string | undefined { return sessionId || undefined; } -function normalizePositiveInt( - value: number | undefined, - fallback: number, - min: number, - max: number, -): number { - if (typeof value !== "number" || !Number.isInteger(value)) { - return fallback; - } - return Math.min(Math.max(value, min), max); -} - -function resolveClaudeLiveOutputLimits(backend: CliBackendConfig): ClaudeLiveOutputLimits { - const configured = backend.reliability?.outputLimits; - const maxTurnRawChars = normalizePositiveInt( - configured?.maxTurnRawChars, - CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS, - CLAUDE_LIVE_MIN_TURN_RAW_CHARS, - CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_RAW_CHARS, - ); - return { - maxTurnRawChars, - maxPendingLineChars: maxTurnRawChars, - maxTurnLines: normalizePositiveInt( - configured?.maxTurnLines, - CLAUDE_LIVE_DEFAULT_MAX_TURN_LINES, - CLAUDE_LIVE_MIN_TURN_LINES, - CLAUDE_LIVE_MAX_CONFIGURABLE_TURN_LINES, - ), - }; -} - function readConfiguredExecPolicy(context: PreparedCliRunContext): { security: ExecSecurity; ask: ExecAsk; @@ -807,7 +768,8 @@ function parseClaudeLiveJsonLine( trimmed: string, ): Record | null { const maxPendingLineChars = - session.currentTurn?.outputLimits.maxPendingLineChars ?? CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS; + session.currentTurn?.outputLimits.maxPendingLineChars ?? + CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS; if (trimmed.length > maxPendingLineChars) { closeLiveSession( session, @@ -825,13 +787,8 @@ function parseClaudeLiveJsonLine( return isRecord(parsed) ? parsed : null; } -function createResultError( - session: ClaudeLiveSession, - parsed: Record, - raw: string, -): FailoverError { - const result = typeof parsed.result === "string" ? parsed.result.trim() : ""; - const message = extractCliErrorMessage(raw) ?? (result || "Claude CLI failed."); +function createParsedOutputError(session: ClaudeLiveSession, output: CliOutput): FailoverError { + const message = output.errorText || "Claude CLI failed."; const reason = classifyFailoverReason(message, { provider: session.providerId }) ?? "unknown"; const code = reason === "context_overflow" ? "cli_context_overflow" : undefined; return new FailoverError(message, { @@ -937,28 +894,27 @@ function handleClaudeLiveLine(session: ClaudeLiveSession, line: string): void { return; } const raw = turn.rawLines.join("\n"); - if (parsed.is_error === true) { - failTurn(session, createResultError(session, parsed, raw)); + const output = parseCliOutput({ + raw, + backend: turn.backend, + providerId: session.providerId, + outputMode: "jsonl", + fallbackSessionId: turn.sessionId, + }); + if (output.errorText) { + failTurn(session, createParsedOutputError(session, output)); scheduleIdleClose(session); return; } - finishTurn( - session, - parseCliOutput({ - raw, - backend: turn.backend, - providerId: session.providerId, - outputMode: "jsonl", - fallbackSessionId: turn.sessionId, - }), - ); + finishTurn(session, output); } function handleClaudeStdout(session: ClaudeLiveSession, chunk: string) { resetNoOutputTimer(session); session.stdoutBuffer += chunk; const maxPendingLineChars = - session.currentTurn?.outputLimits.maxPendingLineChars ?? CLAUDE_LIVE_DEFAULT_MAX_TURN_RAW_CHARS; + session.currentTurn?.outputLimits.maxPendingLineChars ?? + CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS; if (session.stdoutBuffer.length > maxPendingLineChars) { closeLiveSession( session, @@ -1165,7 +1121,7 @@ function createTurn(params: { sessionId: params.context.params.sessionId, ...(params.context.params.sessionKey ? { sessionKey: params.context.params.sessionKey } : {}), }, - outputLimits: resolveClaudeLiveOutputLimits(params.context.preparedBackend.backend), + outputLimits: resolveCliStreamJsonOutputLimits(params.context.preparedBackend.backend), startedAtMs: Date.now(), rawLines: [], rawChars: 0, diff --git a/src/agents/cli-runner/execute.supervisor-capture.test.ts b/src/agents/cli-runner/execute.supervisor-capture.test.ts index 92c21cd42dd1..6c3f988b9159 100644 --- a/src/agents/cli-runner/execute.supervisor-capture.test.ts +++ b/src/agents/cli-runner/execute.supervisor-capture.test.ts @@ -279,6 +279,40 @@ describe("executePreparedCliRun supervisor output capture", () => { throw new Error("Expected CLI run to reject with a rate limit error"); }); + it("fails one-shot Claude is_error results even when the process exits successfully", async () => { + const stdout = `${JSON.stringify({ + type: "result", + subtype: "success", + is_error: true, + result: "Credit balance is too low", + session_id: "session-jsonl-error", + })}\n`; + + supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => { + const input = args[0] as SupervisorSpawnInput; + input.onStdout?.(stdout); + return createManagedRun({ + reason: "exit", + exitCode: 0, + exitSignal: null, + durationMs: 50, + stdout: input.captureOutput === false ? "" : stdout, + stderr: "", + timedOut: false, + noOutputTimedOut: false, + }); + }); + + await expect( + executePreparedCliRun( + buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" }), + ), + ).rejects.toMatchObject({ + name: "FailoverError", + message: "Credit balance is too low", + }); + }); + it("still streams every JSONL stdout chunk with supervisor capture disabled", async () => { // Streaming events are emitted from live chunks, not from the final captured // stdout string, so users still see deltas when captureOutput is false. diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 67e639b040d1..b4fbb5c51d68 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -1163,6 +1163,18 @@ export async function executePreparedCliRun( if (params.abortSignal?.aborted && result.reason === "manual-cancel") { throw createCliAbortError(); } + const streamingParserErrorText = + outputMode === "jsonl" ? (streamingParser?.getErrorText() ?? null) : null; + if (streamingParserErrorText) { + throw new FailoverError(streamingParserErrorText, { + reason: "format", + provider: params.provider, + model: context.modelId, + sessionId: params.sessionId, + lane: params.lane, + status: resolveFailoverStatus("format"), + }); + } const stdout = stdoutParseBuffer.toString("utf8").trim(); const stdoutDiagnostic = stdoutTail.toString("utf8").trim(); @@ -1335,6 +1347,7 @@ export async function executePreparedCliRun( if (parsed.errorText) { const reason = classifyFailoverReason(parsed.errorText, { provider: params.provider }) ?? "unknown"; + const code = reason === "context_overflow" ? "cli_context_overflow" : undefined; throw new FailoverError(parsed.errorText, { reason, provider: params.provider, @@ -1342,6 +1355,7 @@ export async function executePreparedCliRun( sessionId: params.sessionId, lane: params.lane, status: resolveFailoverStatus(reason), + code, }); } const rawText = parsed.text;