mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-06 00:03:42 +00:00
@@ -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[] = [];
|
||||
|
||||
@@ -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) ?? {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user