fix(agents): unify cli stream output classification

Fixes #98896
This commit is contained in:
Ayaan Zaidi
2026-07-01 21:43:55 -07:00
parent 809bc619c4
commit d18817019f
6 changed files with 550 additions and 97 deletions

View File

@@ -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[] = [];

View File

@@ -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, unknown>): 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<string, unknown>): 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<string, unknown>) => {
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) ?? {

View File

@@ -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({

View File

@@ -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<string, ClaudeLiveSession>();
const liveSessionCreates = new Map<string, Promise<ClaudeLiveSession>>();
@@ -732,38 +725,6 @@ function parseSessionId(parsed: Record<string, unknown>): 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<string, unknown> | 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<string, unknown>,
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,

View File

@@ -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.

View File

@@ -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;