refactor(agents): split CLI output into concept modules (#121412)

* refactor(agents): split cli-output into concept modules

* refactor(agents): canonicalize complete JSONL on the streaming parser

* refactor(agents): delete retired codex-exec projection residue

* chore(lint): ratchet max-lines baseline after cli-output split

* fix(agents): restore truncated-stream rejection and prune split leftovers

* fix(agents): prune cli-output lint leftovers

* style(agents): use import type for cli-output contract types
This commit is contained in:
Peter Steinberger
2026-08-10 00:03:34 -07:00
committed by GitHub
parent 145ec3778f
commit 5fad24c8da
26 changed files with 5907 additions and 5805 deletions
-2
View File
@@ -349,8 +349,6 @@ src/agents/bash-tools.process.ts
src/agents/btw.test.ts
src/agents/btw.ts
src/agents/cli-auth-epoch.test.ts
src/agents/cli-output.test.ts
src/agents/cli-output.ts
src/agents/cli-runner.reliability.test.ts
src/agents/cli-runner.spawn.test.ts
src/agents/cli-runner.ts
+116
View File
@@ -0,0 +1,116 @@
import type { CliBackendConfig, CliBackendParseJsonlEvent } from "../plugins/cli-backend.types.js";
import type {
MessagingToolSend,
MessagingToolSourceReplyPayload,
} from "./embedded-agent-messaging.types.js";
import type { ToolSummaryTrace } from "./embedded-agent-runner/types.js";
export type CliUsage = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
type CliProcessDiagnostics = {
backendId: string;
processReason: string;
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
stdoutBytes: number;
stdoutHash: string;
stderrBytes: number;
stderrHash: string;
useResume: boolean;
};
export type CliTerminalFailure = {
reason: "max_turns";
limit?: number;
};
/** Normalized result from a CLI-backed model provider turn. */
export type CliOutput = {
text: string;
rawText?: string;
sessionId?: string;
/** Backend-owned assistant boundary that can safely anchor a later resumed fork. */
resumeCheckpointId?: string;
usage?: CliUsage;
/** Terminal cumulative turn usage for diagnostics; reply accounting keeps using `usage`. */
diagnosticUsage?: CliUsage;
toolSummary?: ToolSummaryTrace;
errorText?: string;
terminalFailure?: CliTerminalFailure;
diagnostics?: {
process?: CliProcessDiagnostics;
};
finalPromptText?: string;
didSendViaMessagingTool?: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts?: string[];
messagingToolSentMediaUrls?: string[];
messagingToolSentTargets?: MessagingToolSend[];
messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[];
yielded?: true;
};
export type CliStreamingDelta = {
text: string;
delta: string;
sessionId?: string;
usage?: CliUsage;
};
export type CliStreamJsonOutputLimits = {
maxTurnRawChars: number;
maxPendingLineChars: number;
maxTurnLines: number;
};
/** Incremental thinking text emitted while parsing a streaming CLI response. */
export type CliThinkingDelta = {
text: string;
delta: string;
isReasoningSnapshot?: boolean;
};
export type CliThinkingProgress = {
progressTokens: number;
};
/** Tool-call start event reconstructed from CLI stream output. */
export type CliToolUseStartDelta = {
toolCallId: string;
name: string;
// Preserve the producer kind: a server-native start without its result is not a failed local call.
kind: "tool_use" | "server_tool_use" | "mcp_tool_use";
args: Record<string, unknown>;
};
/** Tool-call result event reconstructed from CLI stream output. */
export type CliToolResultDelta = {
toolCallId: string;
name: string;
isError: boolean;
result?: unknown;
};
export type CliJsonlStreamingParserOptions = {
backend: CliBackendConfig;
providerId: string;
parseJsonlEvent?: CliBackendParseJsonlEvent;
onAssistantDelta: (delta: CliStreamingDelta) => void;
onThinkingDelta?: (delta: CliThinkingDelta) => void;
onThinkingProgress?: (progress: CliThinkingProgress) => void;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
onDisplayToolUseStart?: (delta: CliToolUseStartDelta) => void;
onDisplayToolResult?: (delta: CliToolResultDelta) => void;
onCommentaryText?: (text: string) => void;
onSessionId?: (sessionId: string) => void;
onAssistantMessage?: (message: unknown) => void;
onUsage?: (usage: CliUsage, terminal: boolean) => void;
};
+405 -1
View File
@@ -1,5 +1,13 @@
import { describe, expect, it } from "vitest";
import { formatCliOutputError } from "./cli-output.js";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
import { extractCliErrorMessage, formatCliOutputError, parseCliOutput } from "./cli-output.js";
import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js";
type ParseCliOutputParams = Parameters<typeof parseCliOutput>[0];
function parseCliJsonl(raw: string, backend: ParseCliOutputParams["backend"], providerId: string) {
return parseCliOutput({ raw, backend, providerId, outputMode: "jsonl" });
}
function hasDanglingSurrogate(value: string): boolean {
return /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
@@ -20,3 +28,399 @@ describe("formatCliOutputError", () => {
expect(error).toContain(`Claude session: ${"s".repeat(199)}.`);
});
});
describe("parseCliJsonl errors", () => {
it("keeps detailed Gemini stream-json result errors over generic error events", () => {
const result = parseCliJsonl(
[
JSON.stringify({
type: "error",
timestamp: "2026-06-16T19:36:48.000Z",
severity: "error",
}),
JSON.stringify({
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "error",
error: { message: "Final Gemini failure" },
}),
].join("\n"),
{
command: "gemini",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
},
"google-gemini-cli",
);
expect(result?.errorText).toBe("Final Gemini failure");
});
it("does not treat Gemini stream-json warning events as provider errors", () => {
const result = parseCliJsonl(
[
JSON.stringify({
type: "error",
timestamp: "2026-06-16T19:36:46.000Z",
severity: "warning",
message: "Loop detected, stopping execution",
}),
JSON.stringify({
type: "message",
timestamp: "2026-06-16T19:36:47.000Z",
role: "assistant",
content: "final output",
delta: true,
}),
JSON.stringify({
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "success",
}),
].join("\n"),
{
command: "gemini",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
},
"google-gemini-cli",
);
expect(result).toEqual({
text: "final output",
sessionId: undefined,
usage: undefined,
});
});
it("extracts nested Claude API errors from failed stream-json output", () => {
const { message, jsonl } = createClaudeApiErrorFixture();
const result = extractCliErrorMessage(jsonl);
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("preserves Claude max-turn terminal context for actionable run errors", () => {
const result = parseCliJsonl(
JSON.stringify({
type: "result",
subtype: "error_max_turns",
session_id: "session-max-turns",
num_turns: 2,
stop_reason: "tool_use",
terminal_reason: "max_turns",
errors: ["Reached maximum number of turns (1)"],
}),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result).toEqual({
text: "",
sessionId: "session-max-turns",
usage: undefined,
errorText: "Reached maximum number of turns (1)",
terminalFailure: {
reason: "max_turns",
limit: 1,
},
});
expect(
formatCliOutputError(result!, {
runId: "run-max-turns",
sessionId: "openclaw-session-max-turns",
}),
).toBe(
"Claude CLI stopped after reaching the maximum number of turns (limit: 1). " +
"OpenClaw run: run-max-turns. OpenClaw session: openclaw-session-max-turns. " +
"Claude session: session-max-turns. Tool actions may already have run; verify their effects before retrying. " +
"Retry with a higher --max-turns value or a narrower task.",
);
});
it("warns that terminal_reason-only max-turn results may have run tools", () => {
const result = parseCliJsonl(
JSON.stringify({
type: "result",
session_id: "session-terminal-reason-only",
terminal_reason: "max_turns",
}),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result).toEqual({
text: "",
sessionId: "session-terminal-reason-only",
usage: undefined,
errorText: "Reached maximum number of turns.",
terminalFailure: { reason: "max_turns" },
});
expect(formatCliOutputError(result!)).toBe(
"Claude CLI stopped after reaching the maximum number of turns. " +
"Claude session: session-terminal-reason-only. " +
"Tool actions may already have run; verify their effects before retrying. " +
"Retry with a higher --max-turns value or a narrower task.",
);
});
it("does not apply Claude terminal semantics to an explicit Gemini dialect", () => {
const result = parseCliJsonl(
JSON.stringify({
type: "result",
subtype: "error_max_turns",
terminal_reason: "max_turns",
errors: ["Reached maximum number of turns (1)"],
}),
{
command: "claude",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
},
"claude-cli",
);
expect(result?.terminalFailure).toBeUndefined();
});
});
describe("createCliJsonlStreamingParser errors", () => {
it("streams Gemini result errors as provider errors", () => {
const deltas: Array<{ text: string; delta: string; sessionId?: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "gemini",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
},
providerId: "google-gemini-cli",
onAssistantDelta: (delta) => deltas.push(delta),
});
parser.push(
[
JSON.stringify({
type: "message",
timestamp: "2026-06-16T19:36:47.000Z",
role: "assistant",
content: "partial output",
delta: true,
}),
JSON.stringify({
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "error",
error: { message: "Gemini stream failed" },
}),
].join("\n") + "\n",
);
parser.finish();
expect(deltas).toEqual([
{
text: "partial output",
delta: "partial output",
sessionId: undefined,
usage: undefined,
},
]);
expect(parser.getOutput()).toEqual({
text: "",
sessionId: undefined,
usage: undefined,
errorText: "Gemini stream failed",
});
});
it("turns plugin-owned JSONL parser exceptions into bounded provider errors", () => {
let calls = 0;
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) => {
calls += 1;
if (line.includes("result")) {
return { kind: "result", text: "must not replace the parser error" };
}
throw new Error("invalid custom event");
},
onAssistantDelta: () => {},
});
parser.push('{"type":"broken"}\n{"type":"result"}\n');
parser.finish();
expect(calls).toBe(1);
expect(parser.getOutput()).toEqual({
text: "",
sessionId: undefined,
usage: undefined,
errorText: "CLI backend acme-cli JSONL parser failed: invalid custom event",
});
});
it("keeps plugin-owned terminal errors ahead of later result summaries", () => {
const usageEvents: Array<{ usage: unknown; isTerminal: boolean }> = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) =>
line === "failed"
? { kind: "result", errorText: "provider failed" }
: {
kind: "result",
text: "must not replace the provider error",
sessionId: "late-successor",
usage: { input: 2, output: 1, total: 3 },
},
onAssistantDelta: () => {},
onUsage: (usage, isTerminal) => usageEvents.push({ usage, isTerminal }),
});
parser.push("failed\nsummary\n");
parser.finish();
expect(parser.getOutput()).toEqual({
text: "",
sessionId: "late-successor",
usage: { input: 2, output: 1, total: 3 },
errorText: "provider failed",
});
expect(usageEvents).toEqual([{ usage: { input: 2, output: 1, total: 3 }, isTerminal: true }]);
});
it("preserves plugin-owned session ids emitted after terminal errors", () => {
const sessionIds: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: () => [
{ kind: "result", errorText: "provider failed" },
{ kind: "sessionId", sessionId: "late-successor" },
],
onAssistantDelta: () => {},
onSessionId: (sessionId) => sessionIds.push(sessionId),
});
parser.push("terminal\n");
parser.finish();
expect(sessionIds).toEqual(["late-successor"]);
expect(parser.getOutput()).toEqual({
text: "",
sessionId: "late-successor",
usage: undefined,
errorText: "provider failed",
});
});
it("preserves streamed plugin text when the terminal result text is empty", () => {
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) =>
line === "delta"
? { kind: "text", text: "streamed answer" }
: { kind: "result", text: " " },
onAssistantDelta: () => {},
});
parser.push("delta\nresult\n");
parser.finish();
expect(parser.getOutput()).toEqual({
text: "streamed answer",
sessionId: undefined,
usage: undefined,
});
});
it("preserves earlier plugin result text when a later result only adds metadata", () => {
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) =>
line === "result"
? { kind: "result", text: "completed answer" }
: {
kind: "result",
sessionId: "summary-session",
usage: { input: 5, output: 3, total: 8 },
},
onAssistantDelta: () => {},
});
parser.push("result\nsummary\n");
parser.finish();
expect(parser.getOutput()).toEqual({
text: "completed answer",
sessionId: "summary-session",
usage: { input: 5, output: 3, total: 8 },
});
});
it("retains built-in fallback text after a plugin handles other lines", () => {
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) => {
if (line === "session") {
return { kind: "sessionId", sessionId: "custom-session" };
}
if (line === "prefix") {
return { kind: "text", text: "streamed prefix" };
}
return null;
},
onAssistantDelta: () => {},
});
parser.push(
[
"session",
"prefix",
JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "delegated answer" },
}),
"",
].join("\n"),
);
parser.finish();
expect(parser.getOutput()).toEqual({
text: "delegated answer",
sessionId: "custom-session",
usage: undefined,
});
});
});
+617
View File
@@ -0,0 +1,617 @@
import { describe, expect, it, vi } from "vitest";
import type { CliToolResultDelta, CliToolUseStartDelta } from "./cli-output-contracts.js";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
function joinJsonlFrames(...frames: unknown[]) {
return frames
.map((frame) => (typeof frame === "string" ? frame : JSON.stringify(frame)))
.join("\n");
}
function claudeStreamEvent(event: Record<string, unknown>) {
return { type: "stream_event", event };
}
function claudeMessageStop() {
return claudeStreamEvent({ type: "message_stop" });
}
function claudeBlockStart(contentBlock: Record<string, unknown>, index?: number) {
return claudeStreamEvent({
type: "content_block_start",
...(index === undefined ? {} : { index }),
content_block: contentBlock,
});
}
function claudeTextDelta(text: string, index?: number | string) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "text_delta", text },
});
}
describe("createCliJsonlStreamingParser events", () => {
it("streams Gemini message deltas and tool events", () => {
const deltas: Array<{ text: string; delta: string; sessionId?: string }> = [];
const starts: CliToolUseStartDelta[] = [];
const results: CliToolResultDelta[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "gemini",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "google-gemini-cli",
onAssistantDelta: (delta) => deltas.push(delta),
onToolUseStart: (delta) => starts.push(delta),
onToolResult: (delta) => results.push(delta),
});
parser.push(
[
JSON.stringify({
type: "init",
timestamp: "2026-06-16T19:36:46.000Z",
session_id: "gemini-session-stream",
model: "gemini-3.1-pro-preview",
}),
JSON.stringify({
type: "message",
timestamp: "2026-06-16T19:36:47.000Z",
role: "assistant",
content: "Checking tools. ",
delta: true,
}),
JSON.stringify({
type: "tool_use",
timestamp: "2026-06-16T19:36:48.000Z",
tool_name: "mcp_openclaw_create_goal",
tool_id: "tool-1",
parameters: { objective: "Update files" },
}),
JSON.stringify({
type: "tool_result",
timestamp: "2026-06-16T19:36:49.000Z",
tool_id: "tool-1",
status: "success",
output: "created",
}),
JSON.stringify({
type: "message",
timestamp: "2026-06-16T19:36:50.000Z",
role: "assistant",
content: "Done.",
delta: true,
}),
JSON.stringify({
type: "result",
timestamp: "2026-06-16T19:36:51.000Z",
status: "success",
stats: { total_tokens: 9, input_tokens: 4, output_tokens: 5 },
}),
].join("\n") + "\n",
);
parser.finish();
expect(deltas).toEqual([
{
text: "Checking tools. ",
delta: "Checking tools. ",
sessionId: "gemini-session-stream",
usage: undefined,
},
{
text: "Checking tools. Done.",
delta: "Done.",
sessionId: "gemini-session-stream",
usage: undefined,
},
]);
expect(starts).toEqual([
{
toolCallId: "tool-1",
name: "mcp_openclaw_create_goal",
kind: "tool_use",
args: { objective: "Update files" },
},
]);
expect(results).toEqual([
{ toolCallId: "tool-1", name: "mcp_openclaw_create_goal", isError: false, result: "created" },
]);
expect(parser.getOutput()).toEqual({
text: "Checking tools. Done.",
sessionId: "gemini-session-stream",
usage: {
input: 4,
output: 5,
cacheRead: undefined,
cacheWrite: undefined,
total: 9,
},
});
});
it("streams plugin-owned JSONL events through normalized core projections", () => {
const assistantDeltas: Array<{ text: string; delta: string; sessionId?: string }> = [];
const thinkingDeltas: Array<{ text: string; delta: string }> = [];
const displayStarts: CliToolUseStartDelta[] = [];
const displayResults: CliToolResultDelta[] = [];
const parsedStarts: CliToolUseStartDelta[] = [];
const sessionIds: string[] = [];
const usageEvents: Array<{ usage: unknown; isTerminal: boolean }> = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) => {
const event = JSON.parse(line) as {
type: string;
text?: string;
session?: string;
id?: string;
name?: string;
result?: unknown;
};
if (event.type === "session") {
return { kind: "sessionId", sessionId: event.session ?? "" };
}
if (event.type === "thinking") {
return { kind: "thinking", text: event.text ?? "" };
}
if (event.type === "text") {
return { kind: "text", text: event.text ?? "" };
}
if (event.type === "tool-start") {
return {
kind: "toolStart",
toolCallId: event.id ?? "",
name: event.name ?? "",
args: { query: "weather" },
};
}
if (event.type === "tool-result") {
return {
kind: "toolResult",
toolCallId: event.id ?? "",
name: event.name,
result: event.result,
};
}
return {
kind: "result",
text: event.text,
sessionId: event.session,
usage: { input: 3, output: 2, total: 5 },
};
},
onAssistantDelta: (delta) => assistantDeltas.push(delta),
onThinkingDelta: (delta) => thinkingDeltas.push(delta),
onToolUseStart: (delta) => parsedStarts.push(delta),
onDisplayToolUseStart: (delta) => displayStarts.push(delta),
onDisplayToolResult: (delta) => displayResults.push(delta),
onSessionId: (sessionId) => sessionIds.push(sessionId),
onUsage: (usage, isTerminal) => usageEvents.push({ usage, isTerminal }),
});
parser.push(
[
JSON.stringify({ type: "session", session: "custom-session" }),
JSON.stringify({ type: "thinking", text: "Checking " }),
JSON.stringify({ type: "thinking", text: "facts." }),
JSON.stringify({ type: "text", text: "Hello " }),
JSON.stringify({ type: "text", text: "world" }),
JSON.stringify({ type: "tool-start", id: "call-1", name: "search" }),
JSON.stringify({
type: "tool-result",
id: "call-1",
name: "search",
result: "sunny",
}),
JSON.stringify({ type: "result", text: "Hello world", session: "custom-successor" }),
"",
].join("\n"),
);
parser.finish();
expect(assistantDeltas).toEqual([
{ text: "Hello ", delta: "Hello ", sessionId: "custom-session", usage: undefined },
{ text: "Hello world", delta: "world", sessionId: "custom-session", usage: undefined },
]);
expect(thinkingDeltas).toEqual([
{ text: "Checking ", delta: "Checking ", isReasoningSnapshot: true },
{ text: "Checking facts.", delta: "facts.", isReasoningSnapshot: true },
]);
expect(displayStarts).toEqual([
{
toolCallId: "call-1",
name: "search",
kind: "tool_use",
args: { query: "weather" },
},
]);
expect(displayResults).toEqual([
{ toolCallId: "call-1", name: "search", isError: false, result: "sunny" },
]);
expect(parsedStarts).toEqual([]);
expect(sessionIds).toEqual(["custom-session", "custom-successor"]);
expect(usageEvents).toEqual([{ usage: { input: 3, output: 2, total: 5 }, isTerminal: true }]);
expect(parser.getOutput()).toEqual({
text: "Hello world",
sessionId: "custom-successor",
usage: { input: 3, output: 2, total: 5 },
});
});
it("lets plugin parsers own their frames before lazily parsing fallback JSON", () => {
const parseSpy = vi.spyOn(JSON, "parse");
const parseCountsAtPluginEntry: number[] = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent: (line) => {
parseCountsAtPluginEntry.push(parseSpy.mock.calls.length);
if (line === "plain provider frame") {
return { kind: "text", text: "plain" };
}
if (line.includes('"item.completed"')) {
return null;
}
const event = JSON.parse(line) as { text: string };
return { kind: "text", text: event.text };
},
onAssistantDelta: () => {},
});
try {
parser.push("plain provider frame\n");
expect(parseSpy).not.toHaveBeenCalled();
parser.push(`${JSON.stringify({ text: " provider JSON" })}\n`);
expect(parseSpy).toHaveBeenCalledTimes(1);
parser.push(
`${JSON.stringify({
type: "item.completed",
item: { type: "agent_message", text: "fallback" },
})}\n`,
);
expect(parseCountsAtPluginEntry).toEqual([0, 0, 1]);
expect(parseSpy).toHaveBeenCalledTimes(2);
} finally {
parseSpy.mockRestore();
}
});
it("streams detailed Gemini error events over generic result errors", () => {
const parser = createCliJsonlStreamingParser({
backend: {
command: "gemini",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
},
providerId: "google-gemini-cli",
onAssistantDelta: () => {},
});
parser.push(
[
JSON.stringify({
type: "error",
timestamp: "2026-06-16T19:36:48.000Z",
severity: "error",
message: "Invalid stream payload",
}),
JSON.stringify({
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "error",
stats: { total_tokens: 1 },
}),
].join("\n") + "\n",
);
parser.finish();
expect(parser.getOutput()).toEqual({
text: "",
sessionId: undefined,
usage: {
input: undefined,
output: undefined,
cacheRead: undefined,
cacheWrite: undefined,
total: 1,
},
errorText: "Invalid stream payload",
});
});
it("surfaces Claude tool_use start and result events", () => {
const starts: CliToolUseStartDelta[] = [];
const results: Array<{ toolCallId: string; name: string; isError: boolean; result?: unknown }> =
[];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onToolUseStart: (delta) => starts.push(delta),
onToolResult: (delta) => results.push(delta),
});
parser.push(
[
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "ls -la" } },
],
},
}),
JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_1",
content: "total 0\n",
is_error: false,
},
],
},
}),
].join("\n") + "\n",
);
parser.finish();
expect(starts).toEqual([
{ toolCallId: "toolu_1", name: "Bash", kind: "tool_use", args: { command: "ls -la" } },
]);
expect(results).toEqual([
{ toolCallId: "toolu_1", name: "Bash", isError: false, result: "total 0\n" },
]);
});
it.each(["server_tool_use", "mcp_tool_use"])("recognizes %s blocks", (type) => {
const starts: CliToolUseStartDelta[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onToolUseStart: (delta) => starts.push(delta),
});
parser.push(
[
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type, id: "toolu_hosted", name: "web_search", input: {} },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"query":"openclaw"}' },
},
}),
JSON.stringify({
type: "stream_event",
event: { type: "content_block_stop", index: 0 },
}),
].join("\n") + "\n",
);
parser.finish();
expect(starts).toEqual([
{
toolCallId: "toolu_hosted",
name: "web_search",
kind: type,
args: { query: "openclaw" },
},
]);
});
it.each([
{
useType: "server_tool_use",
resultType: "web_search_tool_result",
toolCallId: "srvtoolu_1",
name: "web_search",
input: { query: "openclaw" },
result: [{ type: "web_search_result", title: "OpenClaw", url: "https://example.com" }],
isError: false,
},
{
useType: "mcp_tool_use",
resultType: "mcp_tool_result",
toolCallId: "mcptoolu_1",
name: "echo",
input: { value: "hello" },
result: [{ type: "text", text: "hello" }],
isError: false,
},
])("emits hosted result events for $useType", (fixture) => {
const starts: CliToolUseStartDelta[] = [];
const results: Array<{ toolCallId: string; name: string; isError: boolean; result?: unknown }> =
[];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onToolUseStart: (delta) => starts.push(delta),
onToolResult: (delta) => results.push(delta),
});
parser.push(
[
JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{
type: fixture.useType,
id: fixture.toolCallId,
name: fixture.name,
input: fixture.input,
},
{
type: fixture.resultType,
tool_use_id: fixture.toolCallId,
content: fixture.result,
is_error: fixture.isError,
},
],
},
}),
].join("\n") + "\n",
);
parser.finish();
expect(starts).toEqual([
{
toolCallId: fixture.toolCallId,
name: fixture.name,
kind: fixture.useType,
args: fixture.input,
},
]);
expect(results).toEqual([
{
toolCallId: fixture.toolCallId,
name: fixture.name,
isError: fixture.isError,
result: fixture.result,
},
]);
});
it("emits streamed server tool result blocks", () => {
const results: Array<{ toolCallId: string; name: string; isError: boolean; result?: unknown }> =
[];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onToolUseStart: () => undefined,
onToolResult: (delta) => results.push(delta),
});
parser.push(
[
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "server_tool_use", id: "srvtoolu_stream", name: "web_search" },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_stop",
index: 0,
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 1,
content_block: {
type: "web_search_tool_result",
tool_use_id: "srvtoolu_stream",
content: { type: "web_search_tool_result_error", error_code: "unavailable" },
},
},
}),
].join("\n") + "\n",
);
parser.finish();
expect(results).toEqual([
{
toolCallId: "srvtoolu_stream",
name: "web_search",
isError: true,
result: { type: "web_search_tool_result_error", error_code: "unavailable" },
},
]);
});
it.each([
{
name: "fires onCommentaryText with accumulated text before a tool_use block",
frames: [
{ type: "init", session_id: "session-commentary" },
claudeTextDelta("Let me check "),
claudeTextDelta("that for you."),
claudeBlockStart({ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }, 1),
],
expectedCommentary: ["Let me check that for you."],
expectedDeltas: [],
},
{
name: "flushes Claude text as an assistant delta when no tool follows",
frames: [
{ type: "init", session_id: "session-answer" },
claudeTextDelta("Final "),
claudeTextDelta("answer."),
claudeMessageStop(),
],
expectedCommentary: [],
expectedDeltas: [{ text: "Final answer.", delta: "Final answer." }],
},
])("$name", ({ frames, expectedCommentary, expectedDeltas }) => {
const commentaryTexts: string[] = [];
const deltas: Array<{ text: string; delta: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: (delta) => deltas.push({ text: delta.text, delta: delta.delta }),
onCommentaryText: (text) => commentaryTexts.push(text),
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(commentaryTexts).toEqual(expectedCommentary);
expect(deltas).toEqual(expectedDeltas);
});
});
+698
View File
@@ -0,0 +1,698 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
createReasoningTagTextPartitioner,
scanReasoningTags,
type ReasoningTagTextDelta,
} from "../../packages/markdown-core/src/reasoning-tags.js";
import type { CliBackendConfig, CliBackendParsedJsonlEvent } from "../plugins/cli-backend.types.js";
import type {
CliOutput,
CliStreamingDelta,
CliThinkingDelta,
CliThinkingProgress,
CliToolResultDelta,
CliToolUseStartDelta,
CliUsage,
} from "./cli-output-contracts.js";
import { isGeminiStreamJsonDialect, supportsCliJsonlToolEvents } from "./cli-output-records.js";
type PendingToolUse = {
toolCallId: string;
name: string;
kind: CliToolUseStartDelta["kind"];
inputJsonParts: string[];
};
type ToolUseTracker = {
pendingByIndex: Map<number, PendingToolUse>;
nameById: Map<string, string>;
startedIds: Set<string>;
resultDeliveredIds: Set<string>;
};
export function createToolUseTracker(): ToolUseTracker {
return {
pendingByIndex: new Map(),
nameById: new Map(),
startedIds: new Set(),
resultDeliveredIds: new Set(),
};
}
function emitToolStartOnce(
tracker: ToolUseTracker,
toolCallId: string,
name: string,
kind: CliToolUseStartDelta["kind"],
args: Record<string, unknown>,
onToolUseStart?: (delta: CliToolUseStartDelta) => void,
): void {
// Streaming and final assistant records may both describe the same tool call.
if (tracker.startedIds.has(toolCallId)) {
return;
}
tracker.startedIds.add(toolCallId);
tracker.nameById.set(toolCallId, name);
onToolUseStart?.({ toolCallId, name, kind, args });
}
function emitToolResultOnce(
tracker: ToolUseTracker,
toolCallId: string,
isError: boolean,
result: unknown,
onToolResult?: (delta: CliToolResultDelta) => void,
): void {
// Tool results can arrive as assistant result blocks or echoed user tool_result blocks.
if (tracker.resultDeliveredIds.has(toolCallId)) {
return;
}
tracker.resultDeliveredIds.add(toolCallId);
onToolResult?.({
toolCallId,
name: tracker.nameById.get(toolCallId) ?? "",
isError,
result,
});
}
export type CliEventProjectionState = {
assistantText: string;
customThinkingText: string;
sessionId?: string;
usage?: CliUsage;
output: CliOutput | null;
sawCustomJsonlEvent: boolean;
};
export function projectCliBackendEvent(params: {
event: CliBackendParsedJsonlEvent;
state: CliEventProjectionState;
texts: string[];
toolTracker: ToolUseTracker;
onAssistantDelta: (delta: CliStreamingDelta) => void;
onThinkingDelta?: (delta: CliThinkingDelta) => void;
onDisplayToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onDisplayToolResult?: (delta: CliToolResultDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
onSessionId?: (sessionId: string) => void;
onUsage?: (usage: CliUsage, terminal: boolean) => void;
}): void {
const { event, state } = params;
if (state.output?.errorText && event.kind !== "sessionId" && event.kind !== "result") {
return;
}
state.sawCustomJsonlEvent = true;
if (event.kind === "sessionId") {
const sessionId = event.sessionId.trim();
if (sessionId && sessionId !== state.sessionId) {
state.sessionId = sessionId;
params.onSessionId?.(sessionId);
}
if (state.output) {
state.output = { ...state.output, sessionId: state.sessionId };
}
return;
}
if (event.kind === "text") {
if (!event.text) {
return;
}
state.assistantText += event.text;
params.onAssistantDelta({
text: state.assistantText,
delta: event.text,
sessionId: state.sessionId,
usage: state.usage,
});
return;
}
if (event.kind === "thinking") {
if (!event.text || !params.onThinkingDelta) {
return;
}
state.customThinkingText += event.text;
params.onThinkingDelta({
text: state.customThinkingText,
delta: event.text,
isReasoningSnapshot: true,
});
return;
}
if (event.kind === "toolStart") {
emitToolStartOnce(
params.toolTracker,
event.toolCallId,
event.name,
"tool_use",
event.args ?? {},
params.onDisplayToolUseStart ?? params.onToolUseStart,
);
return;
}
if (event.kind === "toolResult") {
if (event.name) {
params.toolTracker.nameById.set(event.toolCallId, event.name);
}
emitToolResultOnce(
params.toolTracker,
event.toolCallId,
event.isError === true,
event.result,
params.onDisplayToolResult ?? params.onToolResult,
);
return;
}
const normalizedSessionId = event.sessionId?.trim();
if (normalizedSessionId && normalizedSessionId !== state.sessionId) {
state.sessionId = normalizedSessionId;
params.onSessionId?.(normalizedSessionId);
}
if (event.usage) {
state.usage = event.usage;
params.onUsage?.(event.usage, true);
}
const existingErrorText = state.output?.errorText;
const eventText = event.text?.trim() ?? "";
const existingText = state.output?.text.trim() ?? "";
const streamedText = state.assistantText.trim();
const delegatedText = params.texts.join("\n").trim();
const resultText = existingErrorText
? existingText || delegatedText || streamedText
: eventText || existingText || delegatedText || streamedText;
const errorText = existingErrorText || event.errorText;
state.output = {
...state.output,
text: resultText,
sessionId: state.sessionId,
usage: state.usage,
...(errorText ? { errorText } : {}),
};
}
export function projectCliTaggedReasoning(params: {
deltas: readonly ReasoningTagTextDelta[];
currentText: string;
hasNativeThinking: boolean;
onThinkingDelta?: (delta: CliThinkingDelta) => void;
onVisibleText: (text: string) => void;
}): string {
let text = params.currentText;
for (const delta of params.deltas) {
if (delta.kind === "text") {
params.onVisibleText(delta.text);
continue;
}
text += delta.text;
if (!params.hasNativeThinking) {
params.onThinkingDelta?.({
text,
delta: delta.text,
isReasoningSnapshot: true,
});
}
}
return text;
}
export function isClaudeToolUseBlockType(type: unknown): type is CliToolUseStartDelta["kind"] {
return type === "tool_use" || type === "server_tool_use" || type === "mcp_tool_use";
}
function isClaudeAssistantToolResultBlockType(type: unknown): boolean {
return typeof type === "string" && type.endsWith("_tool_result") && type !== "tool_result";
}
function isClaudeToolResultError(content: unknown): boolean {
return isRecord(content) && typeof content.type === "string" && content.type.endsWith("_error");
}
function parseToolInputJson(parts: string[]): Record<string, unknown> {
if (parts.length === 0) {
return {};
}
try {
const parsed: unknown = JSON.parse(parts.join(""));
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
export function dispatchClaudeCliStreamingToolEvent(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
tracker: ToolUseTracker;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
}): void {
if (!supportsCliJsonlToolEvents(params)) {
return;
}
const tracker = params.tracker;
if (params.parsed.type === "stream_event" && isRecord(params.parsed.event)) {
const event = params.parsed.event;
if (
event.type === "content_block_start" &&
typeof event.index === "number" &&
isRecord(event.content_block)
) {
const block = event.content_block;
if (isClaudeToolUseBlockType(block.type)) {
const toolCallId = typeof block.id === "string" ? block.id.trim() : "";
const name = typeof block.name === "string" ? block.name.trim() : "";
if (toolCallId && name) {
tracker.pendingByIndex.set(event.index, {
toolCallId,
name,
kind: block.type,
inputJsonParts: [],
});
}
} else if (isClaudeAssistantToolResultBlockType(block.type)) {
const toolCallId = typeof block.tool_use_id === "string" ? block.tool_use_id.trim() : "";
if (toolCallId) {
emitToolResultOnce(
tracker,
toolCallId,
block.is_error === true || isClaudeToolResultError(block.content),
block.content,
params.onToolResult,
);
}
}
return;
}
if (
event.type === "content_block_delta" &&
typeof event.index === "number" &&
isRecord(event.delta)
) {
if (event.delta.type === "input_json_delta" && typeof event.delta.partial_json === "string") {
tracker.pendingByIndex.get(event.index)?.inputJsonParts.push(event.delta.partial_json);
}
return;
}
if (event.type === "content_block_stop" && typeof event.index === "number") {
const pending = tracker.pendingByIndex.get(event.index);
tracker.pendingByIndex.delete(event.index);
if (pending) {
emitToolStartOnce(
tracker,
pending.toolCallId,
pending.name,
pending.kind,
parseToolInputJson(pending.inputJsonParts),
params.onToolUseStart,
);
}
return;
}
return;
}
if (params.parsed.type === "assistant" && isRecord(params.parsed.message)) {
const message = params.parsed.message;
const content = Array.isArray(message.content) ? message.content : [];
for (const block of content) {
if (!isRecord(block)) {
continue;
}
if (isClaudeToolUseBlockType(block.type)) {
const toolCallId = typeof block.id === "string" ? block.id.trim() : "";
const name = typeof block.name === "string" ? block.name.trim() : "";
if (!toolCallId || !name) {
continue;
}
const args: Record<string, unknown> = isRecord(block.input) ? block.input : {};
emitToolStartOnce(tracker, toolCallId, name, block.type, args, params.onToolUseStart);
} else if (isClaudeAssistantToolResultBlockType(block.type)) {
const toolCallId = typeof block.tool_use_id === "string" ? block.tool_use_id.trim() : "";
if (!toolCallId) {
continue;
}
emitToolResultOnce(
tracker,
toolCallId,
block.is_error === true || isClaudeToolResultError(block.content),
block.content,
params.onToolResult,
);
}
}
return;
}
if (params.parsed.type === "user" && isRecord(params.parsed.message)) {
const message = params.parsed.message;
const content = Array.isArray(message.content) ? message.content : [];
for (const block of content) {
if (!isRecord(block) || block.type !== "tool_result") {
continue;
}
const toolCallId = typeof block.tool_use_id === "string" ? block.tool_use_id.trim() : "";
if (!toolCallId) {
continue;
}
emitToolResultOnce(
tracker,
toolCallId,
block.is_error === true,
block.content,
params.onToolResult,
);
}
}
}
type ThinkingTracker = {
currentMessageId?: string;
// Thinking text already streamed via thinking_delta, keyed by the Anthropic
// content-block index. Snapshot frames repeat streamed thinking, so each block
// is deduped against its own index; a single global concatenation misfires
// once a message carries more than one thinking block (re-emits or reorders).
streamedByIndex: Map<number, string>;
// Full thinking already emitted for the message in block order. The callback
// contract exposes this as the running snapshot text for downstream coalescing,
// so it stays a message-level concatenation, not a per-index value.
emittedText: string;
currentSyntheticBlockIndex?: number;
nextSyntheticBlockIndex: number;
progressTokens: number;
};
export function createThinkingTracker(): ThinkingTracker {
return {
streamedByIndex: new Map(),
emittedText: "",
nextSyntheticBlockIndex: 0,
progressTokens: 0,
};
}
function resetThinkingBlockState(tracker: ThinkingTracker): void {
tracker.streamedByIndex.clear();
tracker.emittedText = "";
tracker.currentSyntheticBlockIndex = undefined;
tracker.nextSyntheticBlockIndex = 0;
tracker.progressTokens = 0;
}
function resetThinkingTrackerForMessage(
tracker: ThinkingTracker,
messageId: string | undefined,
): void {
if (messageId && messageId === tracker.currentMessageId) {
return;
}
if (messageId && tracker.currentMessageId === undefined) {
tracker.currentMessageId = messageId;
return;
}
// Anthropic content-block indexes restart at 0 for each message, so a prior
// tool-round message's per-index thinking must not bleed into the next one.
resetThinkingBlockState(tracker);
tracker.currentMessageId = messageId;
}
function beginClaudeContentBlock(tracker: ThinkingTracker, index: unknown): void {
if (typeof index === "number") {
tracker.currentSyntheticBlockIndex = index;
tracker.nextSyntheticBlockIndex = Math.max(tracker.nextSyntheticBlockIndex, index + 1);
return;
}
if (index !== undefined) {
tracker.currentSyntheticBlockIndex = undefined;
return;
}
tracker.currentSyntheticBlockIndex = tracker.nextSyntheticBlockIndex;
tracker.nextSyntheticBlockIndex += 1;
}
function stopClaudeContentBlock(tracker: ThinkingTracker): void {
tracker.currentSyntheticBlockIndex = undefined;
}
function resolveClaudeContentBlockIndex(tracker: ThinkingTracker, index: unknown): number | null {
if (typeof index === "number") {
tracker.nextSyntheticBlockIndex = Math.max(tracker.nextSyntheticBlockIndex, index + 1);
return index;
}
if (index !== undefined) {
return null;
}
return tracker.currentSyntheticBlockIndex ?? null;
}
function assembleThinkingTextByIndex(streamedByIndex: Map<number, string>): string {
return [...streamedByIndex.entries()]
.toSorted(([left], [right]) => left - right)
.map(([, text]) => text)
.join("");
}
function emitClaudeThinking(
tracker: ThinkingTracker,
index: number,
streamed: string,
delta: string,
onThinkingDelta: (delta: CliThinkingDelta) => void,
): void {
tracker.streamedByIndex.set(index, `${streamed}${delta}`);
tracker.emittedText = assembleThinkingTextByIndex(tracker.streamedByIndex);
onThinkingDelta({ text: tracker.emittedText, delta, isReasoningSnapshot: true });
}
function readThinkingProgressTokens(delta: Record<string, unknown>): number | undefined {
if (delta.type !== "thinking_delta" || delta.thinking !== "") {
return undefined;
}
const estimatedTokens = delta.estimated_tokens;
if (typeof estimatedTokens !== "number" || !Number.isFinite(estimatedTokens)) {
return undefined;
}
return estimatedTokens > 0 ? estimatedTokens : undefined;
}
function emitClaudeThinkingProgress(
tracker: ThinkingTracker,
progressTokensDelta: number,
onThinkingProgress: (progress: CliThinkingProgress) => void,
): void {
tracker.progressTokens += progressTokensDelta;
onThinkingProgress({ progressTokens: tracker.progressTokens });
}
export function dispatchClaudeCliThinking(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
tracker: ThinkingTracker;
onThinkingDelta?: (delta: CliThinkingDelta) => void;
onThinkingProgress?: (progress: CliThinkingProgress) => void;
}): void {
if (!supportsCliJsonlToolEvents(params)) {
return;
}
const tracker = params.tracker;
if (params.parsed.type === "stream_event" && isRecord(params.parsed.event)) {
const event = params.parsed.event;
if (event.type === "message_start") {
const message = isRecord(event.message) ? event.message : undefined;
resetThinkingTrackerForMessage(
tracker,
typeof message?.id === "string" ? message.id : undefined,
);
return;
}
if (event.type === "content_block_start") {
beginClaudeContentBlock(tracker, event.index);
return;
}
if (event.type === "content_block_stop") {
stopClaudeContentBlock(tracker);
return;
}
if (event.type !== "content_block_delta" || !isRecord(event.delta)) {
return;
}
// Thinking state is per content-block; when the CLI omits indexes, the
// surrounding block start/stop stream supplies the ordering slot.
const blockIndex = resolveClaudeContentBlockIndex(tracker, event.index);
if (blockIndex === null) {
return;
}
const progressTokensDelta = readThinkingProgressTokens(event.delta);
if (progressTokensDelta !== undefined && params.onThinkingProgress) {
emitClaudeThinkingProgress(tracker, progressTokensDelta, params.onThinkingProgress);
return;
}
// signature_delta carries opaque continuation material; the Claude CLI owns
// its own session transcript, so it never enters the thinking text lane.
if (event.delta.type !== "thinking_delta" || typeof event.delta.thinking !== "string") {
return;
}
if (!event.delta.thinking) {
return;
}
if (!params.onThinkingDelta) {
return;
}
const streamed = tracker.streamedByIndex.get(blockIndex) ?? "";
emitClaudeThinking(tracker, blockIndex, streamed, event.delta.thinking, params.onThinkingDelta);
return;
}
if (params.parsed.type === "assistant" && isRecord(params.parsed.message)) {
resetThinkingTrackerForMessage(
tracker,
typeof params.parsed.message.id === "string" ? params.parsed.message.id : undefined,
);
const content = Array.isArray(params.parsed.message.content)
? params.parsed.message.content
: [];
for (const [index, block] of content.entries()) {
// redacted_thinking blocks are opaque provider material with no text lane.
if (!isRecord(block) || block.type !== "thinking" || typeof block.thinking !== "string") {
continue;
}
if (!params.onThinkingDelta) {
continue;
}
tracker.streamedByIndex.set(index, block.thinking);
const text = assembleThinkingTextByIndex(tracker.streamedByIndex);
if (text === tracker.emittedText) {
continue;
}
tracker.emittedText = text;
params.onThinkingDelta({ text, delta: block.thinking, isReasoningSnapshot: true });
}
}
}
export function dispatchGeminiCliStreamingToolEvent(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
tracker: ToolUseTracker;
onToolUseStart?: (delta: CliToolUseStartDelta) => void;
onToolResult?: (delta: CliToolResultDelta) => void;
}): void {
if (!isGeminiStreamJsonDialect(params)) {
return;
}
if (params.parsed.type === "tool_use") {
const toolCallId =
typeof params.parsed.tool_id === "string" ? params.parsed.tool_id.trim() : "";
const name = typeof params.parsed.tool_name === "string" ? params.parsed.tool_name.trim() : "";
if (!toolCallId || !name) {
return;
}
const args = isRecord(params.parsed.parameters) ? params.parsed.parameters : {};
emitToolStartOnce(params.tracker, toolCallId, name, "tool_use", args, params.onToolUseStart);
return;
}
if (params.parsed.type === "tool_result") {
const toolCallId =
typeof params.parsed.tool_id === "string" ? params.parsed.tool_id.trim() : "";
if (!toolCallId) {
return;
}
const result =
params.parsed.status === "error" && isRecord(params.parsed.error)
? params.parsed.error
: params.parsed.output;
emitToolResultOnce(
params.tracker,
toolCallId,
params.parsed.status === "error",
result,
params.onToolResult,
);
}
}
export function partitionLeadingTaggedReasoning(
text: string,
final: boolean,
): { pending: true } | { pending: false; reasoningText: string; visibleText: string } {
const first = text.search(/\S/u);
if (first === -1) {
return final ? { pending: false, reasoningText: "", visibleText: text } : { pending: true };
}
if (text.charAt(first) !== "<") {
return { pending: false, reasoningText: "", visibleText: text };
}
const scan = scanReasoningTags(text, final);
let depth = 0;
let end = -1;
for (const tag of scan.tags) {
if (depth === 0) {
const expectedStart = end === -1 ? first : end;
if (text.slice(expectedStart, tag.index).trim() || tag.isClose || tag.isSelfClosing) {
break;
}
}
depth += tag.isClose ? -1 : tag.isSelfClosing ? 0 : 1;
if (depth === 0 && tag.isClose) {
end = tag.index + tag.text.length;
}
}
const pendingTagAfterBlock =
end !== -1 && scan.pendingStart !== undefined && !text.slice(end, scan.pendingStart).trim();
if (end === -1) {
const pendingLeadingTag =
scan.pendingStart !== undefined && !text.slice(first, scan.pendingStart).trim();
return !final && (depth > 0 || pendingLeadingTag)
? { pending: true }
: { pending: false, reasoningText: "", visibleText: text };
}
if (!final && (depth > 0 || pendingTagAfterBlock || !text.slice(end).trim())) {
return { pending: true };
}
const partitioner = createReasoningTagTextPartitioner();
const deltas = [...partitioner.pushVisible(text.slice(0, end)), ...partitioner.flush()];
const reasoningText = deltas
.filter((delta) => delta.kind === "thinking")
.map((delta) => delta.text)
.join("");
return reasoningText
? { pending: false, reasoningText, visibleText: text.slice(end) }
: { pending: false, reasoningText: "", visibleText: text };
}
export function createLeadingTaggedReasoningRouter() {
let pending = "";
let settled = false;
const consume = (chunk: string, final: boolean): ReasoningTagTextDelta[] => {
if (settled) {
return chunk ? [{ kind: "text", text: chunk }] : [];
}
pending += chunk;
const result = partitionLeadingTaggedReasoning(pending, final);
if (result.pending) {
return [];
}
settled = true;
pending = "";
return [
...(result.reasoningText
? ([{ kind: "thinking", text: result.reasoningText }] as const)
: []),
...(result.visibleText ? ([{ kind: "text", text: result.visibleText }] as const) : []),
];
};
return {
push: (chunk: string) => consume(chunk, false),
finish: () => consume("", true),
};
}
/** Creates a stateful parser for streaming JSONL CLI backend output. */
+425
View File
@@ -0,0 +1,425 @@
import { describe, expect, it, vi } from "vitest";
import type { CliToolResultDelta, CliToolUseStartDelta } from "./cli-output-contracts.js";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
function joinJsonlFrames(...frames: unknown[]) {
return frames
.map((frame) => (typeof frame === "string" ? frame : JSON.stringify(frame)))
.join("\n");
}
function claudeStreamEvent(event: Record<string, unknown>) {
return { type: "stream_event", event };
}
function claudeBlockStart(contentBlock: Record<string, unknown>, index?: number) {
return claudeStreamEvent({
type: "content_block_start",
...(index === undefined ? {} : { index }),
content_block: contentBlock,
});
}
function claudeBlockStop(index?: number) {
return claudeStreamEvent({
type: "content_block_stop",
...(index === undefined ? {} : { index }),
});
}
function claudeInputJsonDelta(partialJson: string, index?: number) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "input_json_delta", partial_json: partialJson },
});
}
describe("createCliJsonlStreamingParser framing", () => {
it("frames coalesced Claude image and PDF lines before omitting retained binary bytes", () => {
const results: CliToolResultDelta[] = [];
const pluginLines: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "claude", output: "jsonl", jsonlDialect: "claude-stream-json" },
providerId: "claude-cli",
parseJsonlEvent: (line) => {
pluginLines.push(line);
return null;
},
onAssistantDelta: () => {},
onToolResult: (result) => results.push(result),
});
const base64 = "a".repeat(4_300_000);
const rawLines: string[] = [];
for (const [type, mediaType] of [
["image", "image/png"],
["document", "application/pdf"],
] as const) {
rawLines.push(
JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: `read-${type}`,
is_error: type === "document",
content: [
{ type: "text", text: `Read ${type}` },
{
type,
title: `${type} attachment`,
source: { type: "base64", media_type: mediaType, data: base64 },
},
{
type: "image",
source: { type: "url", url: "https://example.test/keep.png" },
},
{
type: "document",
source: { type: "text", media_type: "text/plain", data: "keep text" },
},
],
},
],
},
}),
);
}
const resultLine = JSON.stringify({ type: "result", result: "both attachments read" });
parser.push(`${[...rawLines, resultLine].join("\n")}\n`);
parser.finish();
expect(parser.getErrorText()).toBeNull();
expect(parser.getOutput()?.text).toBe("both attachments read");
expect(results).toHaveLength(2);
expect(pluginLines).toEqual([...rawLines, resultLine]);
for (const [index, type, mediaType] of [
[0, "image", "image/png"],
[1, "document", "application/pdf"],
] as const) {
expect(results[index]).toEqual({
toolCallId: `read-${type}`,
name: "",
isError: type === "document",
result: [
{ type: "text", text: `Read ${type}` },
{
type,
title: `${type} attachment`,
source: { type: "base64", media_type: mediaType },
omitted: true,
bytes: 3_225_000,
},
{ type: "image", source: { type: "url", url: "https://example.test/keep.png" } },
{
type: "document",
source: { type: "text", media_type: "text/plain", data: "keep text" },
},
],
});
}
});
it.each([
{ name: "echoed media bytes", padded: false },
{ name: "surrounding raw whitespace", padded: true },
])("counts $name claimed by Claude plugin parsers before dispatch", ({ padded }) => {
const pluginLines: string[] = [];
const assistantDeltas: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "claude", output: "jsonl", jsonlDialect: "claude-stream-json" },
providerId: "claude-cli",
parseJsonlEvent: (line) => {
pluginLines.push(line);
return { kind: "text", text: "claimed" };
},
onAssistantDelta: (delta) => assistantDeltas.push(delta.delta),
});
const semanticLine = JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "claimed-image",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: padded ? "YQ==" : "a".repeat(4_300_000),
},
},
],
},
],
},
});
const rawLine = padded ? `${" ".repeat(4_300_000)}${semanticLine}` : semanticLine;
parser.push(`${rawLine}\n${rawLine}\n`);
expect(pluginLines).toEqual([semanticLine, semanticLine]);
expect(assistantDeltas).toEqual(["claimed"]);
expect(parser.getErrorText()).toContain("JSONL output exceeded");
});
it("counts actual blank Claude frames without invoking hooks or inventing a finish frame", () => {
const parseJsonlEvent = vi.fn(() => null);
const createParser = () =>
createCliJsonlStreamingParser({
backend: { command: "claude", output: "jsonl", jsonlDialect: "claude-stream-json" },
providerId: "claude-cli",
parseJsonlEvent,
onAssistantDelta: () => {},
});
const completeParser = createParser();
completeParser.push("\r\n".repeat(20_000));
completeParser.finish();
expect(completeParser.getErrorText()).toBeNull();
expect(parseJsonlEvent).not.toHaveBeenCalled();
const overflowParser = createParser();
overflowParser.push("\n".repeat(20_001));
expect(overflowParser.getErrorText()).toContain("exceeded 20000 lines");
expect(parseJsonlEvent).not.toHaveBeenCalled();
});
it.each([
{
name: "whitespace-only records",
createLine: () => " ".repeat(4_300_000),
},
{
name: "padding around valid JSON",
createLine: () => `${" ".repeat(4_300_000)}{}`,
},
{
name: "formatting inside a compacted media record",
createLine: () =>
JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "padded-image",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "YQ==" },
},
],
},
],
},
}).replace('"message":', `"message":${" ".repeat(4_300_000)}`),
},
])("charges $name against the Claude raw-output budget", ({ createLine }) => {
const parser = createCliJsonlStreamingParser({
backend: { command: "claude", output: "jsonl", jsonlDialect: "claude-stream-json" },
providerId: "claude-cli",
onAssistantDelta: () => {},
});
const line = createLine();
parser.push(`${line}\n${line}\n`);
expect(parser.getErrorText()).toContain("JSONL output exceeded 8388608 characters");
});
it("normalizes empty Claude image data without treating zero omitted bytes as unchanged", () => {
const results: CliToolResultDelta[] = [];
const parser = createCliJsonlStreamingParser({
backend: { command: "claude", output: "jsonl", jsonlDialect: "claude-stream-json" },
providerId: "claude-cli",
onAssistantDelta: () => {},
onToolResult: (result) => results.push(result),
});
parser.push(
`${JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "empty-image",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "" },
},
],
},
],
},
})}\n`,
);
expect(results[0]?.result).toEqual([
{
type: "image",
source: { type: "base64", media_type: "image/png" },
omitted: true,
bytes: 0,
},
]);
});
it("still enforces raw Claude line and retained-text limits", () => {
const createParser = () =>
createCliJsonlStreamingParser({
backend: { command: "claude", output: "jsonl", jsonlDialect: "claude-stream-json" },
providerId: "claude-cli",
onAssistantDelta: () => {},
});
const oversizedLineParser = createParser();
oversizedLineParser.push(
`${JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "oversized-image",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "a".repeat(8 * 1024 * 1024),
},
},
],
},
],
},
})}\n`,
);
expect(oversizedLineParser.getErrorText()).toContain("JSONL line exceeded");
const growingPartialLineParser = createParser();
growingPartialLineParser.push("a".repeat(4_300_000));
expect(growingPartialLineParser.getErrorText()).toBeNull();
growingPartialLineParser.push("a".repeat(4_300_000));
expect(growingPartialLineParser.getErrorText()).toContain("JSONL line exceeded");
const oversizedTextParser = createParser();
for (const toolCallId of ["first", "second"]) {
oversizedTextParser.push(
`${JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: toolCallId,
content: [{ type: "text", text: "a".repeat(4_300_000) }],
},
],
},
})}\n`,
);
}
expect(oversizedTextParser.getErrorText()).toContain("JSONL output exceeded");
const excessiveLinesParser = createParser();
excessiveLinesParser.push("{}\n".repeat(20_001));
expect(excessiveLinesParser.getErrorText()).toContain("exceeded 20000 lines");
});
it.each([
{ providerId: "codex-cli", jsonlDialect: undefined },
{ providerId: "pi-cli", jsonlDialect: undefined },
{ providerId: "google-gemini-cli", jsonlDialect: "gemini-stream-json" as const },
])("preserves $providerId binary tool payloads byte-for-byte", ({ providerId, jsonlDialect }) => {
const observedLines: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: { command: providerId, output: "jsonl", ...(jsonlDialect ? { jsonlDialect } : {}) },
providerId,
parseJsonlEvent: (line) => {
observedLines.push(line);
return null;
},
onAssistantDelta: () => {},
});
const rawLine = JSON.stringify({
type: "user",
item: {
type: "mcp_tool_call",
result: { content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }] },
},
message: {
content: [
{
type: "tool_result",
tool_use_id: "keep-binary",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "aGVsbG8=" },
},
],
},
],
},
});
parser.push(`\n \t\r\n${rawLine}\n`);
expect(observedLines).toEqual([rawLine]);
});
it.each([
{
name: "reassembles streamed tool args from input_json_delta chunks",
frames: [
claudeBlockStart({ type: "tool_use", id: "toolu_chunked", name: "Bash", input: {} }, 0),
claudeInputJsonDelta('{"command":', 0),
claudeInputJsonDelta(' "echo hi"}', 0),
claudeBlockStop(0),
],
expected: [
{
toolCallId: "toolu_chunked",
name: "Bash",
kind: "tool_use",
args: { command: "echo hi" },
},
],
},
{
name: "emits empty args when streamed tool args are malformed",
frames: [
claudeBlockStart({ type: "tool_use", id: "toolu_bad", name: "Bash", input: {} }, 0),
claudeInputJsonDelta('{"command": "ls', 0),
claudeBlockStop(0),
],
expected: [{ toolCallId: "toolu_bad", name: "Bash", kind: "tool_use", args: {} }],
},
])("$name", ({ frames, expected }) => {
const starts: CliToolUseStartDelta[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onToolUseStart: (delta) => starts.push(delta),
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(starts).toEqual(expected);
});
});
+663
View File
@@ -0,0 +1,663 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
import { parseCliOutput } from "./cli-output.js";
type ParseCliOutputParams = Parameters<typeof parseCliOutput>[0];
function parseCliJsonl(raw: string, backend: ParseCliOutputParams["backend"], providerId: string) {
return parseCliOutput({ raw, backend, providerId, outputMode: "jsonl" });
}
function joinJsonlFrames(...frames: unknown[]) {
return frames
.map((frame) => (typeof frame === "string" ? frame : JSON.stringify(frame)))
.join("\n");
}
function claudeStreamEvent(event: Record<string, unknown>) {
return { type: "stream_event", event };
}
function claudeMessageStart(id?: string) {
return claudeStreamEvent({ type: "message_start", ...(id ? { message: { id } } : {}) });
}
function claudeTextDelta(text: string, index?: number | string) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "text_delta", text },
});
}
function claudeThinkingDelta(thinking: string, index?: number | string) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "thinking_delta", thinking },
});
}
function claudeAssistantSnapshot(id: string, content: unknown[]) {
return { type: "assistant", message: { id, content } };
}
function normalizedUsage(values: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
}) {
return {
input: values.input,
output: values.output,
cacheRead: values.cacheRead,
cacheWrite: values.cacheWrite,
total: values.total,
};
}
describe("parseCliJsonl", () => {
it.each([
{
name: "parses Claude stream-json result events",
command: "claude",
jsonlDialect: undefined,
providerId: "claude-cli",
frames: [
{ type: "init", session_id: "session-123" },
{
type: "result",
session_id: "session-123",
result: "Claude says hello",
usage: { input_tokens: 12, output_tokens: 3, cache_read_input_tokens: 4 },
},
],
expected: {
text: "Claude says hello",
sessionId: "session-123",
usage: normalizedUsage({ input: 12, output: 3, cacheRead: 4 }),
},
},
{
name: "parses Claude stream-json result events for an explicit backend dialect",
command: "local-cli",
jsonlDialect: "claude-stream-json" as const,
providerId: "local-cli",
frames: [
{ type: "init", session_id: "session-dialect" },
{
type: "result",
session_id: "session-dialect",
result: "dialect says hello",
usage: { input_tokens: 5, output_tokens: 2 },
},
],
expected: {
text: "dialect says hello",
sessionId: "session-dialect",
usage: normalizedUsage({ input: 5, output: 2 }),
},
},
])("$name", ({ command, jsonlDialect, providerId, frames, expected }) => {
const result = parseCliJsonl(
joinJsonlFrames(...frames),
{
command,
output: "jsonl",
...(jsonlDialect ? { jsonlDialect } : {}),
sessionIdFields: ["session_id"],
},
providerId,
);
expect(result).toEqual(expected);
});
it("keeps streamed pre-tool text over the final-message result in transcript reparses", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-reparse" }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Marker caribou-lampion-473 explanation." },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
content_block: { type: "tool_use", id: "tool-1", name: "session_status" },
},
}),
JSON.stringify({ type: "stream_event", event: { type: "message_stop" } }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "TEST DONE" },
},
}),
JSON.stringify({ type: "result", session_id: "session-reparse", result: "TEST DONE" }),
].join("\n"),
{
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
"local-cli",
);
expect(result).toEqual({
text: "Marker caribou-lampion-473 explanation.\n\nTEST DONE",
sessionId: "session-reparse",
usage: undefined,
});
});
it("continues transcript reparses past an interim result", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-interim-reparse" }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Interim answer." },
},
}),
JSON.stringify({
type: "result",
session_id: "session-interim-reparse",
result: "Interim answer.",
}),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Pre-tool follow-up." },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
content_block: { type: "tool_use", id: "tool-2", name: "session_status" },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "DONE" },
},
}),
JSON.stringify({
type: "result",
session_id: "session-interim-reparse",
result: "DONE",
}),
].join("\n"),
{
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
"local-cli",
);
expect(result?.text).toBe("Interim answer.\nPre-tool follow-up.\n\nDONE");
});
it.each([
{
name: "parses Gemini stream-json message and result events",
frames: [
{
type: "init",
timestamp: "2026-06-16T19:36:46.000Z",
session_id: "gemini-session-123",
model: "gemini-3.1-pro-preview",
},
{
type: "message",
timestamp: "2026-06-16T19:36:47.000Z",
role: "assistant",
content: "Gemini says ",
delta: true,
},
{
type: "message",
timestamp: "2026-06-16T19:36:48.000Z",
role: "assistant",
content: "hello",
delta: true,
},
{
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "success",
stats: { total_tokens: 21, input_tokens: 13, output_tokens: 5, cached: 8, input: 5 },
},
],
sessionIdFields: ["session_id"],
expected: {
text: "Gemini says hello",
sessionId: "gemini-session-123",
usage: normalizedUsage({ input: 5, output: 5, cacheRead: 8, total: 21 }),
},
},
{
name: "keeps Gemini tool-only stream-json output structured instead of raw JSONL",
frames: [
{
type: "init",
timestamp: "2026-06-16T19:36:46.000Z",
session_id: "gemini-session-123",
model: "gemini-3.1-pro-preview",
},
{
type: "tool_use",
timestamp: "2026-06-16T19:36:47.000Z",
tool_name: "mcp_openclaw_create_goal",
tool_id: "tool-1",
parameters: { objective: "Update files" },
},
{
type: "tool_result",
timestamp: "2026-06-16T19:36:48.000Z",
tool_id: "tool-1",
status: "success",
output: "created",
},
{
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "success",
stats: { total_tokens: 2, input_tokens: 1, output_tokens: 1 },
},
],
sessionIdFields: ["session_id"],
expected: {
text: "",
sessionId: "gemini-session-123",
usage: normalizedUsage({ input: 1, output: 1, total: 2 }),
},
},
{
name: "parses Gemini stream-json result errors as provider errors",
frames: [
{
type: "message",
timestamp: "2026-06-16T19:36:47.000Z",
role: "assistant",
content: "partial output",
delta: true,
},
{
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "error",
error: { message: "Gemini stream failed" },
},
],
sessionIdFields: undefined,
expected: {
text: "",
sessionId: undefined,
usage: undefined,
errorText: "Gemini stream failed",
},
},
{
name: "keeps detailed Gemini stream-json error events over generic result errors",
frames: [
{
type: "error",
timestamp: "2026-06-16T19:36:48.000Z",
severity: "error",
message: "Invalid stream payload",
},
{
type: "result",
timestamp: "2026-06-16T19:36:49.000Z",
status: "error",
stats: { total_tokens: 1 },
},
],
sessionIdFields: undefined,
expected: {
text: "",
sessionId: undefined,
usage: normalizedUsage({ total: 1 }),
errorText: "Invalid stream payload",
},
},
])("$name", ({ frames, sessionIdFields, expected }) => {
const result = parseCliJsonl(
joinJsonlFrames(...frames),
{
command: "gemini",
output: "jsonl",
jsonlDialect: "gemini-stream-json",
...(sessionIdFields ? { sessionIdFields } : {}),
},
"google-gemini-cli",
);
expect(result).toEqual(expected);
});
it("preserves Claude cache creation tokens instead of flattening them to zero", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-cache-123" }),
JSON.stringify({
type: "result",
session_id: "session-cache-123",
result: "Claude says hello",
usage: {
input_tokens: 12,
output_tokens: 3,
cache_read_input_tokens: 4,
cache_creation_input_tokens: 7,
},
}),
].join("\n"),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result).toEqual({
text: "Claude says hello",
sessionId: "session-cache-123",
usage: {
input: 12,
output: 3,
cacheRead: 4,
cacheWrite: 7,
total: undefined,
},
});
});
it("does not let cumulative Claude result usage overwrite assistant usage", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "session-stream" }),
JSON.stringify({
type: "assistant",
message: {
id: "msg-1",
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 },
},
}),
JSON.stringify({
type: "assistant",
message: {
id: "msg-2",
usage: { input_tokens: 11, output_tokens: 6, cache_read_input_tokens: 125 },
},
}),
JSON.stringify({
type: "result",
session_id: "session-stream",
result: "done",
usage: { input_tokens: 30, output_tokens: 15, cache_read_input_tokens: 300 },
}),
].join("\n"),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result?.usage).toEqual({
input: 11,
output: 6,
cacheRead: 125,
cacheWrite: undefined,
total: undefined,
});
});
it("captures the last Claude assistant transcript UUID as a resume checkpoint", () => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "system", subtype: "init", session_id: "session-checkpoint" }),
JSON.stringify({
type: "assistant",
uuid: "assistant-checkpoint-1",
message: {
id: "provider-message-1",
role: "assistant",
content: [{ type: "text", text: "first" }],
},
}),
JSON.stringify({
type: "assistant",
uuid: "assistant-checkpoint-2",
message: {
id: "provider-message-2",
role: "assistant",
content: [{ type: "text", text: "done" }],
},
}),
JSON.stringify({
type: "assistant",
uuid: "subagent-checkpoint",
parent_tool_use_id: "tool-use-1",
message: {
id: "provider-subagent-message",
role: "assistant",
content: [{ type: "text", text: "nested" }],
},
}),
JSON.stringify({
type: "result",
session_id: "session-checkpoint",
result: "done",
}),
].join("\n"),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result?.resumeCheckpointId).toBe("assistant-checkpoint-2");
});
it.each([
{
name: "preserves Claude session metadata even when the final result text is empty",
raw: joinJsonlFrames(
{ type: "init", session_id: "session-456" },
{
type: "result",
session_id: "session-456",
result: " ",
usage: { input_tokens: 18, output_tokens: 0 },
},
),
expected: {
text: "",
sessionId: "session-456",
usage: normalizedUsage({ input: 18 }),
},
},
{
name: "preserves streamed Claude text when the final result text is empty",
raw: joinJsonlFrames(
{ type: "init", session_id: "session-456" },
claudeTextDelta("Hello"),
claudeTextDelta(" world"),
{
type: "result",
session_id: "session-456",
result: "",
usage: { input_tokens: 18, output_tokens: 4 },
},
),
expected: {
text: "Hello world",
sessionId: "session-456",
usage: normalizedUsage({ input: 18, output: 4 }),
},
},
{
name: "unwraps nested Claude agent result JSON from stream-json output",
raw: joinJsonlFrames(
{ type: "init", session_id: "session-nested-jsonl" },
{
type: "result",
session_id: "session-nested-jsonl",
result: JSON.stringify({
type: "result",
result: JSON.stringify({
type: "result",
subtype: "success",
result: "actual response text",
}),
}),
},
),
expected: {
text: "actual response text",
sessionId: "session-nested-jsonl",
usage: undefined,
},
},
{
name: "parses multiple JSON objects embedded on the same line",
raw: '{"type":"init","session_id":"session-999"} {"type":"result","session_id":"session-999","result":"done"}',
expected: { text: "done", sessionId: "session-999", usage: undefined },
},
])("$name", ({ raw, expected }) => {
const result = parseCliJsonl(
raw,
{ command: "claude", output: "jsonl", sessionIdFields: ["session_id"] },
"claude-cli",
);
expect(result).toEqual(expected);
});
it("captures the last Claude session_id when an ephemeral id precedes the canonical one", () => {
// claude-cli emits ephemeral session_ids from SessionStart hooks before the
// canonical resumed session_id surfaces in the init event and the terminal
// result event. First-wins capture would bind to the ephemeral id whose
// transcript JSONL never lands on disk; last-wins captures the canonical id.
const result = parseCliJsonl(
[
JSON.stringify({ type: "system", subtype: "init", session_id: "session-ephemeral" }),
JSON.stringify({ type: "system", subtype: "init", session_id: "session-canonical" }),
JSON.stringify({
type: "result",
session_id: "session-canonical",
result: "rotated reply",
}),
].join("\n"),
{
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result?.sessionId).toBe("session-canonical");
expect(result?.text).toBe("rotated reply");
});
it("preserves terminal cumulative usage when reparsing completed Claude JSONL", () => {
const output = parseCliJsonl(
readFileSync("test/fixtures/cli/claude-2.1-thinking-progress.jsonl", "utf8"),
{
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(output.usage).toEqual({
input: 4418,
output: 5,
cacheRead: undefined,
cacheWrite: 36955,
total: undefined,
});
expect(output.diagnosticUsage).toEqual({
input: 4418,
output: 534,
cacheRead: undefined,
cacheWrite: 36955,
total: undefined,
});
});
it.each([
{
name: "resets per-index thinking state on a new message within the same turn (tool round-trip)",
frames: [
claudeMessageStart("msg-A"),
claudeThinkingDelta("Hello ", 0),
claudeThinkingDelta("world", 0),
claudeAssistantSnapshot("msg-A", [{ type: "thinking", thinking: "Hello world" }]),
claudeMessageStart("msg-B"),
claudeThinkingDelta("New ", 0),
claudeThinkingDelta("thought", 0),
claudeAssistantSnapshot("msg-B", [{ type: "thinking", thinking: "New thought" }]),
],
expected: [
{ text: "Hello ", delta: "Hello ", isReasoningSnapshot: true },
{ text: "Hello world", delta: "world", isReasoningSnapshot: true },
{ text: "New ", delta: "New ", isReasoningSnapshot: true },
{ text: "New thought", delta: "thought", isReasoningSnapshot: true },
],
},
{
name: "ignores indexless thinking deltas without content block framing",
frames: [claudeThinkingDelta("orphaned"), claudeThinkingDelta("also orphaned", "0")],
expected: [],
},
])("$name", ({ frames, expected }) => {
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: () => {},
onThinkingDelta: (delta) => thinking.push(delta),
});
parser.push(joinJsonlFrames(...frames));
parser.finish();
expect(thinking).toEqual(expected);
});
});
+462
View File
@@ -0,0 +1,462 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import type { CliThinkingProgress } from "./cli-output-contracts.js";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
function joinJsonlFrames(...frames: unknown[]) {
return frames
.map((frame) => (typeof frame === "string" ? frame : JSON.stringify(frame)))
.join("\n");
}
function claudeStreamEvent(event: Record<string, unknown>) {
return { type: "stream_event", event };
}
function claudeMessageStart(id?: string) {
return claudeStreamEvent({ type: "message_start", ...(id ? { message: { id } } : {}) });
}
function claudeBlockStart(contentBlock: Record<string, unknown>, index?: number) {
return claudeStreamEvent({
type: "content_block_start",
...(index === undefined ? {} : { index }),
content_block: contentBlock,
});
}
function claudeBlockStop(index?: number) {
return claudeStreamEvent({
type: "content_block_stop",
...(index === undefined ? {} : { index }),
});
}
function claudeThinkingDelta(thinking: string, index?: number | string) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "thinking_delta", thinking },
});
}
function claudeInputJsonDelta(partialJson: string, index?: number) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "input_json_delta", partial_json: partialJson },
});
}
function claudeAssistantSnapshot(id: string, content: unknown[]) {
return { type: "assistant", message: { id, content } };
}
describe("createCliJsonlStreamingParser reasoning", () => {
function createClaudeTaggedReasoningHarness() {
const assistant: Array<{ text: string; delta: string }> = [];
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: (delta) => assistant.push(delta),
onThinkingDelta: (delta) => thinking.push(delta),
});
return { assistant, parser, thinking };
}
it("promotes complete leading tagged Claude reasoning and keeps only the answer visible", () => {
const { assistant, parser, thinking } = createClaudeTaggedReasoningHarness();
parser.push(
[
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: {
type: "text_delta",
text: "<thinking>Private analysis.</thinking>Visible answer.",
},
},
}),
JSON.stringify({
type: "result",
session_id: "session-tagged",
result: "<thinking>Private analysis.</thinking>Visible answer.",
}),
"",
].join("\n"),
);
parser.finish();
expect(thinking).toEqual([
{
text: "Private analysis.",
delta: "Private analysis.",
isReasoningSnapshot: true,
},
]);
expect(assistant).toEqual([
{
text: "Visible answer.",
delta: "Visible answer.",
sessionId: undefined,
usage: undefined,
},
]);
expect(parser.getOutput()).toEqual({
text: "Visible answer.",
sessionId: "session-tagged",
usage: undefined,
});
});
it("holds chunk-split tagged reasoning until its close tag is complete", () => {
const { assistant, parser, thinking } = createClaudeTaggedReasoningHarness();
const pushText = (text: string) =>
parser.push(
`${JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text },
},
})}\n`,
);
pushText("<thi");
pushText("nking>Private ");
expect(assistant).toEqual([]);
expect(thinking).toEqual([]);
pushText("analysis.</think");
expect(assistant).toEqual([]);
expect(thinking).toEqual([]);
pushText("ing>Visible answer.");
parser.finish();
expect(thinking.at(-1)?.text).toBe("Private analysis.");
expect(assistant.at(-1)?.text).toBe("Visible answer.");
expect(parser.getOutput()?.text).toBe("Visible answer.");
});
it("streams rejected angle prefixes while valid split reasoning stays buffered", () => {
const visible = createClaudeTaggedReasoningHarness();
const mixed = createClaudeTaggedReasoningHarness();
const tagged = createClaudeTaggedReasoningHarness();
const pushText = (parser: ReturnType<typeof createCliJsonlStreamingParser>, text: string) =>
parser.push(
`${JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text },
},
})}\n`,
);
pushText(visible.parser, "<div>Visible prefix <thi");
expect(visible.assistant.at(-1)?.text).toBe("<div>Visible prefix <thi");
expect(visible.parser.getOutput()?.text).toBe("<div>Visible prefix <thi");
pushText(mixed.parser, "<div>Visible prefix ");
pushText(mixed.parser, "<thi");
expect(mixed.assistant.at(-1)?.text).toBe("<div>Visible prefix <thi");
expect(mixed.parser.getOutput()?.text).toBe("<div>Visible prefix <thi");
pushText(tagged.parser, "<thi");
pushText(tagged.parser, "nking>Private analysis.");
expect(tagged.assistant).toEqual([]);
expect(tagged.thinking).toEqual([]);
pushText(tagged.parser, "</thinking>Visible answer.");
expect(tagged.thinking.at(-1)?.text).toBe("Private analysis.");
expect(tagged.assistant.at(-1)?.text).toBe("Visible answer.");
});
it("promotes consecutive leading blocks but preserves later literal tags", () => {
const { assistant, parser, thinking } = createClaudeTaggedReasoningHarness();
parser.push(
`${JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: {
type: "text_delta",
text: [
"<think>First.</think>",
"<reasoning>Second.</reasoning>",
"Answer with <think>literal</think> markup.",
].join("\n"),
},
},
})}\n`,
);
parser.finish();
expect(thinking.map((entry) => entry.text)).toEqual(["First.Second."]);
expect(assistant.at(-1)?.text).toBe("\nAnswer with <think>literal</think> markup.");
expect(parser.getOutput()?.text).toBe("Answer with <think>literal</think> markup.");
});
it("prefers native Claude thinking over a mirrored leading tagged block", () => {
const { assistant, parser, thinking } = createClaudeTaggedReasoningHarness();
parser.push(
[
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
delta: { type: "thinking_delta", thinking: "Native analysis." },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
index: 1,
delta: {
type: "text_delta",
text: "<thinking>Native analysis.</thinking>Visible answer.",
},
},
}),
JSON.stringify({
type: "result",
result: "<thinking>Native analysis.</thinking>Visible answer.",
}),
"",
].join("\n"),
);
parser.finish();
expect(thinking).toEqual([
{ text: "Native analysis.", delta: "Native analysis.", isReasoningSnapshot: true },
]);
expect(assistant.at(-1)?.text).toBe("Visible answer.");
expect(parser.getOutput()?.text).toBe("Visible answer.");
});
it.each([
{
name: "fenced code example",
text: "```xml\n<thinking>literal example</thinking>\n```",
},
{ name: "incomplete leading tag", text: "<thinking>unfinished visible text" },
{ name: "malformed leading tag", text: "<thinking broken visible text" },
])("preserves $name on the visible path", ({ text }) => {
const { assistant, parser, thinking } = createClaudeTaggedReasoningHarness();
parser.push(
`${JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text },
},
})}\n`,
);
parser.finish();
expect(thinking).toEqual([]);
expect(assistant.map((entry) => entry.delta).join("")).toBe(text);
expect(parser.getOutput()?.text).toBe(text);
});
it("resets tagged reasoning across Claude tool-round assistant messages", () => {
const { assistant, parser, thinking } = createClaudeTaggedReasoningHarness();
const textEvent = (text: string) =>
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text },
},
});
parser.push(
[
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
textEvent("<think>First thought.</think>Before tool."),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
content_block: { type: "tool_use", id: "tool-1", name: "Read" },
},
}),
JSON.stringify({ type: "stream_event", event: { type: "message_stop" } }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
textEvent("<reasoning>Second thought.</reasoning>Final answer."),
JSON.stringify({ type: "result", result: "Final answer." }),
"",
].join("\n"),
);
parser.finish();
expect(thinking.map((entry) => entry.text)).toEqual(["First thought.", "Second thought."]);
expect(assistant.at(-1)?.text).toBe("Before tool.\n\nFinal answer.");
expect(parser.getOutput()?.text).toBe("Before tool.\n\nFinal answer.");
});
it.each([
{
name: "streams thinking deltas, skips signature deltas, and dedupes the snapshot",
frames: [
claudeThinkingDelta("Let me think", 0),
claudeThinkingDelta(" harder.", 0),
claudeStreamEvent({
type: "content_block_delta",
index: 0,
delta: { type: "signature_delta", signature: "opaque-signature" },
}),
claudeAssistantSnapshot("msg-1", [
{ type: "thinking", thinking: "Let me think harder.", signature: "opaque-signature" },
{ type: "text", text: "Answer." },
]),
],
expected: [
{ text: "Let me think", delta: "Let me think", isReasoningSnapshot: true },
{ text: "Let me think harder.", delta: " harder.", isReasoningSnapshot: true },
],
},
{
name: "emits snapshot thinking blocks when no thinking deltas streamed",
frames: [
claudeAssistantSnapshot("msg-1", [
{ type: "thinking", thinking: "Snapshot-only reasoning.", signature: "sig" },
{ type: "redacted_thinking", data: "opaque-blob" },
{ type: "text", text: "Answer." },
]),
],
expected: [
{
text: "Snapshot-only reasoning.",
delta: "Snapshot-only reasoning.",
isReasoningSnapshot: true,
},
],
},
{
name: "replaces per-index thinking when assistant snapshots revise non-prefix text",
frames: [
claudeThinkingDelta("rough draft", 0),
claudeAssistantSnapshot("msg-1", [
{ type: "thinking", thinking: "revised thought", signature: "sig" },
{ type: "text", text: "Answer." },
]),
],
expected: [
{ text: "rough draft", delta: "rough draft", isReasoningSnapshot: true },
{ text: "revised thought", delta: "revised thought", isReasoningSnapshot: true },
],
},
{
name: "dedupes per content-block index across multiple thinking blocks",
frames: [
claudeThinkingDelta("A", 0),
claudeThinkingDelta("B", 1),
claudeAssistantSnapshot("msg-1", [
{ type: "thinking", thinking: "A", signature: "sig-a" },
{ type: "thinking", thinking: "B", signature: "sig-b" },
]),
],
expected: [
{ text: "A", delta: "A", isReasoningSnapshot: true },
{ text: "AB", delta: "B", isReasoningSnapshot: true },
],
},
{
name: "dedupes snapshot thinking after tool-interleaved multi-block streaming",
frames: [
claudeThinkingDelta("A", 0),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "Read" }, 1),
claudeInputJsonDelta('{"file_path":"x"}', 1),
claudeBlockStop(1),
claudeThinkingDelta("B", 2),
claudeAssistantSnapshot("msg-1", [
{ type: "thinking", thinking: "A", signature: "sig-a" },
{ type: "tool_use", id: "tool-1", name: "Read", input: { file_path: "x" } },
{ type: "thinking", thinking: "B", signature: "sig-b" },
]),
],
expected: [
{ text: "A", delta: "A", isReasoningSnapshot: true },
{ text: "AB", delta: "B", isReasoningSnapshot: true },
],
},
{
name: "streams indexless thinking deltas from content block framing",
frames: [
claudeMessageStart("msg-1"),
claudeBlockStart({ type: "thinking" }),
claudeThinkingDelta("A"),
claudeBlockStop(),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "Read" }),
claudeInputJsonDelta('{"file_path":"x"}'),
claudeBlockStop(),
claudeBlockStart({ type: "thinking" }),
claudeThinkingDelta("B"),
claudeBlockStop(),
claudeAssistantSnapshot("msg-1", [{ type: "text", text: "Answer." }]),
],
expected: [
{ text: "A", delta: "A", isReasoningSnapshot: true },
{ text: "AB", delta: "B", isReasoningSnapshot: true },
],
},
])("$name", ({ frames, expected }) => {
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: () => {},
onThinkingDelta: (delta) => thinking.push(delta),
});
parser.push(joinJsonlFrames(...frames));
parser.finish();
expect(thinking).toEqual(expected);
});
it("emits token progress for Claude CLI 2.1 empty thinking deltas", () => {
const thinking: Array<{ text: string; delta: string; isReasoningSnapshot?: boolean }> = [];
const progress: CliThinkingProgress[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => {},
onThinkingDelta: (delta) => thinking.push(delta),
onThinkingProgress: (payload) => progress.push(payload),
});
parser.push(readFileSync("test/fixtures/cli/claude-2.1-thinking-progress.jsonl", "utf8"));
parser.finish();
expect(thinking).toEqual([]);
expect(progress).toEqual([
{ progressTokens: 50 },
{ progressTokens: 200 },
{ progressTokens: 300 },
]);
});
});
+569
View File
@@ -0,0 +1,569 @@
import { describe, expect, it, vi } from "vitest";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
import { parseCliOutput } from "./cli-output.js";
type ParseCliOutputParams = Parameters<typeof parseCliOutput>[0];
const OPENAI_COMPATIBLE_CLI_USAGE_CASES = [
{
name: "standard OpenAI snake_case token fields",
raw: {
prompt_tokens: 17,
completion_tokens: 5,
total_tokens: 22,
prompt_tokens_details: { cached_tokens: 6 },
},
normalized: { input: 11, output: 5, cacheRead: 6, cacheWrite: undefined, total: 22 },
},
{
name: "camelCase OpenAI-compatible token fields",
raw: {
promptTokens: 17,
completionTokens: 5,
total_tokens: 22,
prompt_tokens_details: { cached_tokens: 6 },
},
normalized: { input: 11, output: 5, cacheRead: 6, cacheWrite: undefined, total: 22 },
},
{
name: "existing input/output field precedence",
raw: {
input_tokens: 19,
prompt_tokens: 99,
output_tokens: 7,
completion_tokens: 77,
total_tokens: 26,
prompt_tokens_details: { cached_tokens: 4 },
},
normalized: { input: 15, output: 7, cacheRead: 4, cacheWrite: undefined, total: 26 },
},
{
name: "flat Codex cached input is included in input_tokens",
raw: {
input_tokens: 15,
output_tokens: 4,
cached_input_tokens: 6,
},
normalized: { input: 9, output: 4, cacheRead: 6, cacheWrite: undefined, total: undefined },
},
{
name: "flat Codex input includes both cached reads and cache writes",
raw: {
input_tokens: 100,
output_tokens: 10,
cached_input_tokens: 40,
cache_write_input_tokens: 60,
},
normalized: { input: 0, output: 10, cacheRead: 40, cacheWrite: 60, total: undefined },
},
{
name: "nested Codex input includes both cached reads and cache writes",
raw: {
input_tokens: 100,
output_tokens: 10,
input_tokens_details: { cached_tokens: 40, cache_write_tokens: 60 },
},
normalized: { input: 0, output: 10, cacheRead: 40, cacheWrite: 60, total: undefined },
},
] as const;
function parseCliJson(raw: string, backend: ParseCliOutputParams["backend"], providerId = "") {
return parseCliOutput({ raw, backend, providerId, outputMode: "json" });
}
function parseCliJsonl(raw: string, backend: ParseCliOutputParams["backend"], providerId: string) {
return parseCliOutput({ raw, backend, providerId, outputMode: "jsonl" });
}
function joinJsonlFrames(...frames: unknown[]) {
return frames
.map((frame) => (typeof frame === "string" ? frame : JSON.stringify(frame)))
.join("\n");
}
function claudeStreamEvent(event: Record<string, unknown>) {
return { type: "stream_event", event };
}
function claudeTextDelta(text: string, index?: number | string) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "text_delta", text },
});
}
function normalizedUsage(values: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
}) {
return {
input: values.input,
output: values.output,
cacheRead: values.cacheRead,
cacheWrite: values.cacheWrite,
total: values.total,
};
}
describe("parseCliJson", () => {
it.each([
{
name: "preserves Claude max-turn terminal context in JSON mode",
input: {
type: "result",
subtype: "error_max_turns",
session_id: "session-json-max-turns",
terminal_reason: "max_turns",
errors: ["Reached maximum number of turns (3)"],
},
command: "claude",
sessionIdFields: ["session_id"],
providerId: "claude-cli",
expected: {
text: "",
sessionId: "session-json-max-turns",
usage: undefined,
errorText: "Reached maximum number of turns (3)",
terminalFailure: { reason: "max_turns", limit: 3 },
},
},
{
name: "classifies Claude is_error JSON results as provider errors",
input: {
type: "result",
subtype: "success",
is_error: true,
result: 'API Error: 400 {"error":{"message":"Bad request"}}',
},
command: "claude",
sessionIdFields: ["session_id"],
providerId: "claude-cli",
expected: {
text: "",
sessionId: undefined,
usage: undefined,
errorText: "Bad request",
},
},
{
name: "classifies generic is_error JSON results as provider errors",
input: { is_error: true, result: "429 rate limit exceeded" },
command: "custom",
sessionIdFields: undefined,
providerId: "custom-cli",
expected: {
text: "",
sessionId: undefined,
usage: undefined,
errorText: "429 rate limit exceeded",
},
},
{
name: "keeps successful JSON result message payloads as assistant text",
input: { type: "result", message: "done" },
command: "custom",
sessionIdFields: undefined,
providerId: "custom-cli",
expected: { text: "done", sessionId: undefined, usage: undefined },
},
{
name: "does not classify null JSON result error fields as provider errors",
input: { type: "result", error: null, message: "done" },
command: "custom",
sessionIdFields: undefined,
providerId: "custom-cli",
expected: { text: "done", sessionId: undefined, usage: undefined },
},
{
name: "classifies JSON status error result payloads as provider errors",
input: { type: "result", status: "error", result: "rate limit" },
command: "custom",
sessionIdFields: undefined,
providerId: "custom-cli",
expected: {
text: "",
sessionId: undefined,
usage: undefined,
errorText: "rate limit",
},
},
])("$name", ({ input, command, sessionIdFields, providerId, expected }) => {
const result = parseCliJson(
JSON.stringify(input),
{ command, output: "json", ...(sessionIdFields ? { sessionIdFields } : {}) },
providerId,
);
expect(result).toEqual(expected);
});
it("recovers mixed-output Claude session metadata from embedded JSON objects", () => {
const result = parseCliJson(
[
"Claude Code starting...",
'{"type":"init","session_id":"session-789"}',
'{"type":"result","result":"Claude says hi","usage":{"input_tokens":9,"output_tokens":4}}',
].join("\n"),
{
command: "claude",
output: "json",
sessionIdFields: ["session_id"],
},
);
expect(result).toEqual({
text: "Claude says hi",
sessionId: "session-789",
usage: {
input: 9,
output: 4,
cacheRead: undefined,
cacheWrite: undefined,
total: undefined,
},
});
});
it.each([
{
name: "parses Gemini CLI response text and stats payloads",
input: {
session_id: "gemini-session-123",
response: "Gemini says hello",
stats: {
total_tokens: 21,
input_tokens: 13,
output_tokens: 5,
cached: 8,
input: 5,
},
},
expected: {
text: "Gemini says hello",
sessionId: "gemini-session-123",
usage: normalizedUsage({ input: 5, output: 5, cacheRead: 8, total: 21 }),
},
},
{
name: "falls back to Gemini stats when usage exists without token fields",
input: {
session_id: "gemini-session-789",
response: "Gemini says hello",
usage: {},
stats: {
total_tokens: 21,
input_tokens: 13,
output_tokens: 5,
cached: 8,
input: 5,
},
},
expected: {
text: "Gemini says hello",
sessionId: "gemini-session-789",
usage: normalizedUsage({ input: 5, output: 5, cacheRead: 8, total: 21 }),
},
},
])("$name", ({ input, expected }) => {
const result = parseCliJson(JSON.stringify(input), {
command: "gemini",
output: "json",
sessionIdFields: ["session_id"],
});
expect(result).toEqual(expected);
});
it("falls back to input_tokens minus cached when Gemini stats omit input", () => {
const result = parseCliJson(
JSON.stringify({
session_id: "gemini-session-456",
response: "Hello",
stats: {
total_tokens: 21,
input_tokens: 13,
output_tokens: 5,
cached: 8,
},
}),
{
command: "gemini",
output: "json",
sessionIdFields: ["session_id"],
},
);
expect(result?.usage?.input).toBe(5);
expect(result?.usage?.cacheRead).toBe(8);
});
it("unwraps nested Claude result JSON from JSON output", () => {
const result = parseCliJson(
JSON.stringify({
session_id: "session-nested-json",
result: JSON.stringify({
type: "result",
result: JSON.stringify({
type: "result",
subtype: "success",
result: "actual response text",
}),
}),
}),
{
command: "claude",
output: "json",
sessionIdFields: ["session_id"],
},
"claude-cli",
);
expect(result).toEqual({
text: "actual response text",
sessionId: "session-nested-json",
usage: undefined,
});
});
it("does not unwrap nested result-shaped JSON for non-claude json backends", () => {
const nestedResult = JSON.stringify({
type: "result",
result: JSON.stringify({
type: "result",
result: "actual response text",
}),
});
const result = parseCliJson(
JSON.stringify({
session_id: "gemini-session-nested-json",
result: nestedResult,
}),
{
command: "gemini",
output: "json",
sessionIdFields: ["session_id"],
},
"gemini",
);
expect(result).toEqual({
text: nestedResult,
sessionId: "gemini-session-nested-json",
usage: undefined,
});
});
it("parses nested OpenAI-style cached token details from CLI json payloads", () => {
const result = parseCliJson(
JSON.stringify({
session_id: "openai-session-123",
response: "OpenAI says hello",
usage: {
input_tokens: 15,
output_tokens: 4,
input_tokens_details: {
cached_tokens: 6,
},
},
}),
{
command: "codex",
output: "json",
sessionIdFields: ["session_id"],
},
);
expect(result).toEqual({
text: "OpenAI says hello",
sessionId: "openai-session-123",
usage: {
input: 9,
output: 4,
cacheRead: 6,
cacheWrite: undefined,
total: undefined,
},
});
});
it.each(OPENAI_COMPATIBLE_CLI_USAGE_CASES)(
"normalizes $name from CLI JSON output",
({ raw, normalized }) => {
const result = parseCliJson(
JSON.stringify({
session_id: "openai-compatible-session",
response: "OpenAI-compatible response",
usage: raw,
}),
{
command: "openai-compatible",
output: "json",
sessionIdFields: ["session_id"],
},
"openai-compatible-cli",
);
expect(result).toEqual({
text: "OpenAI-compatible response",
sessionId: "openai-compatible-session",
usage: normalized,
});
},
);
});
describe("parseCliJsonl", () => {
it.each(OPENAI_COMPATIBLE_CLI_USAGE_CASES)(
"normalizes $name from CLI JSONL output",
({ raw, normalized }) => {
const result = parseCliJsonl(
[
JSON.stringify({ type: "init", session_id: "openai-compatible-session" }),
JSON.stringify({
type: "result",
session_id: "openai-compatible-session",
result: "OpenAI-compatible response",
usage: raw,
}),
].join("\n"),
{
command: "openai-compatible",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
"openai-compatible-cli",
);
expect(result).toEqual({
text: "OpenAI-compatible response",
sessionId: "openai-compatible-session",
usage: normalized,
});
},
);
});
describe("parseCliOutput", () => {
it("applies a backend JSONL hook when reparsing complete output", () => {
const parseJsonlEvent = vi.fn(() => ({
kind: "result" as const,
errorText: "invalid request format: malformed backend result",
}));
expect(
parseCliOutput({
raw: JSON.stringify({ type: "result", result: "malformed" }),
backend: { command: "acme", output: "jsonl" },
providerId: "acme-cli",
parseJsonlEvent,
outputMode: "jsonl",
}),
).toEqual({
text: "",
sessionId: undefined,
usage: undefined,
errorText: "invalid request format: malformed backend result",
});
expect(parseJsonlEvent).toHaveBeenCalledOnce();
});
it.each([
{
name: "uses streamed Claude assistant text when the result envelope is missing",
raw: joinJsonlFrames(
{ type: "init", session_id: "session-stream-missing-result" },
claudeTextDelta("partial answer"),
),
expected: {
text: "partial answer",
sessionId: "session-stream-missing-result",
usage: undefined,
},
},
{
name: "fails stream-json output without result or assistant text instead of returning raw JSONL",
raw: JSON.stringify({ type: "init", session_id: "session-empty" }),
expected: {
text: "",
sessionId: "session-empty",
usage: undefined,
errorText: "CLI stream-json output ended without a result event.",
},
},
])("$name", ({ raw, expected }) => {
const result = parseCliOutput({
raw,
backend: {
command: "claude",
output: "jsonl",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
outputMode: "jsonl",
});
expect(result).toEqual(expected);
});
});
describe("parseCliJsonl record usage", () => {
it("ignores cumulative usage from result events to avoid cache_read inflation", () => {
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" }),
JSON.stringify({
type: "assistant",
message: {
id: "msg-1",
usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 },
},
}),
JSON.stringify({
type: "assistant",
message: {
id: "msg-2",
usage: { input_tokens: 11, output_tokens: 6, cache_read_input_tokens: 125 },
},
}),
JSON.stringify({
type: "result",
result: "done",
usage: { input_tokens: 30, output_tokens: 15, cache_read_input_tokens: 300 },
}),
].join("\n"),
);
parser.finish();
const output = parser.getOutput();
expect(output?.usage).toEqual({
input: 11,
output: 6,
cacheRead: 125,
cacheWrite: undefined,
total: undefined,
});
expect(output?.diagnosticUsage).toEqual({
input: 30,
output: 15,
cacheRead: 300,
cacheWrite: undefined,
total: undefined,
});
});
});
+613
View File
@@ -0,0 +1,613 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { CliBackendConfig } from "../plugins/cli-backend.types.js";
import { extractBalancedJsonFragments } from "../shared/balanced-json.js";
import type { CliOutput, CliTerminalFailure, CliUsage } from "./cli-output-contracts.js";
function isClaudeCliProvider(providerId: string): boolean {
return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli";
}
function isGeminiCliProvider(providerId: string): boolean {
return normalizeLowercaseStringOrEmpty(providerId) === "google-gemini-cli";
}
export function isGeminiStreamJsonDialect(params: {
backend: CliBackendConfig;
providerId: string;
}): boolean {
return (
params.backend.jsonlDialect === "gemini-stream-json" || isGeminiCliProvider(params.providerId)
);
}
export function isClaudeStreamJsonDialect(params: {
backend: CliBackendConfig;
providerId: string;
}): boolean {
if (params.backend.jsonlDialect) {
return params.backend.jsonlDialect === "claude-stream-json";
}
return isClaudeCliProvider(params.providerId);
}
export 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;
providerId: string;
}): boolean {
return (
params.backend.jsonlDialect === "claude-stream-json" ||
isClaudeCliProvider(params.providerId) ||
isGeminiStreamJsonDialect(params)
);
}
export function isClaudeStreamJsonResult(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
}): boolean {
return supportsCliJsonlToolEvents(params) && params.parsed.type === "result";
}
function extractJsonObjectCandidates(raw: string): string[] {
return extractBalancedJsonFragments(raw, { openers: ["{"] }).map((fragment) => fragment.json);
}
export function decodeCliRecords(raw: string): Record<string, unknown>[] {
const parsedRecords: Record<string, unknown>[] = [];
const trimmed = raw.trim();
if (!trimmed) {
return parsedRecords;
}
try {
const parsed = JSON.parse(trimmed);
if (isRecord(parsed)) {
parsedRecords.push(parsed);
return parsedRecords;
}
} catch {
// Fall back to scanning for top-level JSON objects embedded in mixed output.
}
// Some CLIs prefix JSON with banners/logs; balanced scanning recovers structured records.
for (const candidate of extractJsonObjectCandidates(trimmed)) {
try {
const parsed = JSON.parse(candidate);
if (isRecord(parsed)) {
parsedRecords.push(parsed);
}
} catch {
// Ignore malformed fragments and keep scanning remaining objects.
}
}
return parsedRecords;
}
function readNestedErrorMessage(parsed: Record<string, unknown>): string | undefined {
if (isRecord(parsed.error)) {
const errorMessage = readNestedErrorMessage(parsed.error);
if (errorMessage) {
return errorMessage;
}
}
if (typeof parsed.message === "string") {
const trimmed = parsed.message.trim();
if (trimmed) {
return trimmed;
}
}
if (typeof parsed.error === "string") {
const trimmed = parsed.error.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
function unwrapCliErrorText(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
for (const parsed of decodeCliRecords(trimmed)) {
const nested = readNestedErrorMessage(parsed);
if (nested) {
return nested;
}
}
return trimmed;
}
function toCliUsage(raw: Record<string, unknown>): CliUsage | undefined {
const readNestedCached = (
key: "input_tokens_details" | "prompt_tokens_details",
field: "cached_tokens" | "cache_write_tokens" = "cached_tokens",
) => {
const nested = raw[key];
if (!isRecord(nested)) {
return undefined;
}
return typeof nested[field] === "number" && nested[field] > 0 ? nested[field] : undefined;
};
const pick = (key: string) =>
typeof raw[key] === "number" && raw[key] > 0 ? raw[key] : undefined;
// Chat Completions calls these prompt/completion tokens; preserve existing CLI-field precedence.
const totalInput =
pick("input_tokens") ?? pick("inputTokens") ?? pick("prompt_tokens") ?? pick("promptTokens");
const output =
pick("output_tokens") ??
pick("outputTokens") ??
pick("completion_tokens") ??
pick("completionTokens");
const nestedCached =
readNestedCached("input_tokens_details") ?? readNestedCached("prompt_tokens_details");
const cacheRead =
pick("cache_read_input_tokens") ??
pick("cached_input_tokens") ??
pick("cacheRead") ??
pick("cached") ??
nestedCached;
const nestedCacheWrite =
readNestedCached("input_tokens_details", "cache_write_tokens") ??
readNestedCached("prompt_tokens_details", "cache_write_tokens");
const cacheWrite =
pick("cache_creation_input_tokens") ??
pick("cache_write_input_tokens") ??
pick("cacheWrite") ??
nestedCacheWrite;
const input =
pick("input") ??
((Object.hasOwn(raw, "cached") ||
Object.hasOwn(raw, "cached_input_tokens") ||
Object.hasOwn(raw, "cache_write_input_tokens") ||
nestedCached !== undefined ||
nestedCacheWrite !== undefined) &&
typeof totalInput === "number"
? Math.max(0, totalInput - (cacheRead ?? 0) - (cacheWrite ?? 0))
: totalInput);
const total = pick("total_tokens") ?? pick("total");
if (!input && !output && !cacheRead && !cacheWrite && !total) {
return undefined;
}
return { input, output, cacheRead, cacheWrite, total };
}
export function readCliUsage(parsed: Record<string, unknown>): CliUsage | undefined {
if (isRecord(parsed.message) && isRecord(parsed.message.usage)) {
const usage = toCliUsage(parsed.message.usage);
if (usage) {
return usage;
}
}
if (isRecord(parsed.usage)) {
const usage = toCliUsage(parsed.usage);
if (usage) {
return usage;
}
}
if (isRecord(parsed.stats)) {
return toCliUsage(parsed.stats);
}
return undefined;
}
function collectCliText(value: unknown): string {
if (!value) {
return "";
}
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
return value.map((entry) => collectCliText(entry)).join("");
}
if (!isRecord(value)) {
return "";
}
if (typeof value.response === "string") {
return value.response;
}
if (typeof value.text === "string") {
return value.text;
}
if (typeof value.result === "string") {
return value.result;
}
if (typeof value.content === "string") {
return value.content;
}
if (Array.isArray(value.content)) {
return value.content.map((entry) => collectCliText(entry)).join("");
}
if (isRecord(value.message)) {
return collectCliText(value.message);
}
return "";
}
function unwrapNestedCliResultText(raw: string): string {
let text = raw;
for (let depth = 0; depth < 8; depth += 1) {
const trimmed = text.trim();
if (!trimmed.startsWith("{")) {
return text;
}
try {
const parsed = JSON.parse(trimmed);
if (
!isRecord(parsed) ||
typeof parsed.type !== "string" ||
parsed.type !== "result" ||
typeof parsed.result !== "string"
) {
return text;
}
// Claude can wrap a result payload inside repeated JSON-string result envelopes.
text = parsed.result;
} catch {
return text;
}
}
return text;
}
export 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.type === "assistant") {
const text = collectCliText(parsed.message);
if (/^\s*API Error:/i.test(text)) {
return unwrapCliErrorText(text);
}
}
if (parsed.type === "error") {
const text =
collectCliText(parsed.message) ||
collectCliText(parsed.content) ||
collectCliText(parsed.result) ||
collectCliText(parsed);
return unwrapCliErrorText(text);
}
return "";
}
function readClaudeMaxTurnsFailure(
parsed: Record<string, unknown>,
): CliTerminalFailure | undefined {
const subtype = typeof parsed.subtype === "string" ? parsed.subtype.trim() : "";
const terminalReason =
typeof parsed.terminal_reason === "string" ? parsed.terminal_reason.trim() : "";
if (subtype !== "error_max_turns" && terminalReason !== "max_turns") {
return undefined;
}
const errors = Array.isArray(parsed.errors) ? parsed.errors : [];
for (const error of errors) {
if (typeof error !== "string") {
continue;
}
const match = error.match(/maximum number of turns\s*\((\d+)\)/i);
if (match) {
const limit = Number.parseInt(match[1] ?? "", 10);
if (Number.isSafeInteger(limit) && limit > 0) {
return {
reason: "max_turns",
limit,
};
}
}
}
return { reason: "max_turns" };
}
function readClaudeMaxTurnsErrorText(parsed: Record<string, unknown>): string | undefined {
if (!Array.isArray(parsed.errors)) {
return undefined;
}
for (const error of parsed.errors) {
if (typeof error === "string" && error.trim()) {
return error.trim();
}
}
return undefined;
}
function resolveCliTerminalErrorText(
parsed: Record<string, unknown>,
terminalFailure: CliTerminalFailure | undefined,
): string {
const explicitErrorText = collectExplicitCliErrorText(parsed);
return (
((terminalFailure ? readClaudeMaxTurnsErrorText(parsed) : undefined) ?? explicitErrorText) ||
(terminalFailure ? "Reached maximum number of turns." : "")
);
}
export function pickCliSessionId(
parsed: Record<string, unknown>,
backend: CliBackendConfig,
): string | undefined {
const fields = backend.sessionIdFields ?? [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
];
for (const field of fields) {
const value = parsed[field];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
}
export function pickCliResumeCheckpointId(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
}): string | undefined {
if (
!isClaudeStreamJsonDialect(params) ||
params.parsed.type !== "assistant" ||
params.parsed.parent_tool_use_id != null
) {
return undefined;
}
const checkpointId = typeof params.parsed.uuid === "string" ? params.parsed.uuid.trim() : "";
return checkpointId || undefined;
}
function shouldUnwrapNestedCliResultText(params: {
providerId?: string;
parsed: Record<string, unknown>;
}): boolean {
if (!params.providerId || !isClaudeCliProvider(params.providerId)) {
return false;
}
return !Object.hasOwn(params.parsed, "type") || params.parsed.type === "result";
}
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 a single JSON payload emitted by a CLI backend. */
export function parseCliJson(
raw: string,
backend: CliBackendConfig,
providerId?: string,
): CliOutput | null {
const parsedRecords = decodeCliRecords(raw);
if (parsedRecords.length === 0) {
return null;
}
let sessionId: string | undefined;
let usage: CliUsage | undefined;
let text = "";
let sawStructuredOutput = false;
for (const parsed of parsedRecords) {
sessionId = pickCliSessionId(parsed, backend) ?? sessionId;
usage = readCliUsage(parsed) ?? usage;
const terminalFailure = isClaudeStreamJsonDialect({
backend,
providerId: providerId ?? "",
})
? readClaudeMaxTurnsFailure(parsed)
: undefined;
if (terminalFailure) {
return {
text: "",
sessionId,
usage,
errorText: resolveCliTerminalErrorText(parsed, terminalFailure),
terminalFailure,
};
}
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) ||
collectCliText(parsed.result) ||
collectCliText(parsed.response) ||
collectCliText(parsed);
const trimmedText = (
shouldUnwrapNestedCliResultText({ providerId, parsed })
? unwrapNestedCliResultText(nextText)
: nextText
).trim();
if (trimmedText) {
text = trimmedText;
sawStructuredOutput = true;
continue;
}
if (sessionId || usage) {
sawStructuredOutput = true;
}
}
if (!text && !sawStructuredOutput) {
return null;
}
return { text, sessionId, usage };
}
export function parseClaudeCliJsonlResult(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
sessionId?: string;
usage?: CliUsage;
}): CliOutput | null {
if (!supportsCliJsonlToolEvents(params)) {
return null;
}
if (typeof params.parsed.type === "string" && params.parsed.type === "result") {
const terminalFailure = isClaudeStreamJsonDialect(params)
? readClaudeMaxTurnsFailure(params.parsed)
: undefined;
const errorText = resolveCliTerminalErrorText(params.parsed, terminalFailure);
if (errorText) {
return {
text: "",
sessionId: params.sessionId,
usage: params.usage,
errorText,
...(terminalFailure ? { terminalFailure } : {}),
};
}
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 };
}
// Claude may finish with an empty result after tool-only work. Keep the
// resolved session handle and usage instead of dropping them.
return { text: "", sessionId: params.sessionId, usage: params.usage };
}
return null;
}
// A tool-split turn streams pre-tool answer text the terminal result envelope
// omits (it carries only the final message). Prefer the fuller streamed text so
// final delivery cannot erase already-streamed content (#106760). The result
// must match the complete final streamed message: a bare suffix match inside a
// single divergent message defers to the authoritative result envelope.
export function preferStreamedClaudeTextOverResult(params: {
streamedText: string;
finalMessageText: string;
resultText: string;
}): boolean {
return (
Boolean(params.resultText) &&
params.streamedText !== params.resultText &&
params.finalMessageText === params.resultText
);
}
// Assistant-message boundaries join with one blank line; add only the missing
// newlines so messages that already end or start with breaks are not
// double-spaced.
export function missingMessageBoundarySeparator(previousText: string, nextDelta: string): string {
if (!previousText) {
return "";
}
const trailing = previousText.match(/\n*$/u)?.[0].length ?? 0;
const leading = nextDelta.match(/^\n*/u)?.[0].length ?? 0;
return "\n".repeat(Math.max(0, 2 - trailing - leading));
}
export function parseClaudeCliStreamingDelta(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
}): string | null {
if (!supportsCliJsonlToolEvents(params)) {
return null;
}
if (params.parsed.type !== "stream_event" || !isRecord(params.parsed.event)) {
return null;
}
const event = params.parsed.event;
if (event.type !== "content_block_delta" || !isRecord(event.delta)) {
return null;
}
const delta = event.delta;
if (delta.type !== "text_delta" || typeof delta.text !== "string") {
return null;
}
if (!delta.text) {
return null;
}
return delta.text;
}
const GEMINI_CLI_ERROR_EVENT_FALLBACK = "Gemini CLI emitted an error event.";
const GEMINI_CLI_RESULT_ERROR_FALLBACK = "Gemini CLI result status was error.";
function isFallbackGeminiCliStreamJsonError(errorText: string): boolean {
return (
errorText === GEMINI_CLI_ERROR_EVENT_FALLBACK || errorText === GEMINI_CLI_RESULT_ERROR_FALLBACK
);
}
export function preferGeminiCliStreamJsonError(current: string | undefined, next: string): string {
if (!current) {
return next;
}
if (isFallbackGeminiCliStreamJsonError(current) && !isFallbackGeminiCliStreamJsonError(next)) {
return next;
}
return current;
}
export function readGeminiCliStreamJsonError(parsed: Record<string, unknown>): string | undefined {
if (parsed.type === "error" && parsed.severity === "error") {
return collectExplicitCliErrorText(parsed) || GEMINI_CLI_ERROR_EVENT_FALLBACK;
}
if (parsed.type === "result" && parsed.status === "error") {
return collectExplicitCliErrorText(parsed) || GEMINI_CLI_RESULT_ERROR_FALLBACK;
}
return undefined;
}
// A possible leading block stays buffered until visible prose or the message
// boundary proves where private reasoning ends. Later tags remain literal.
+596
View File
@@ -0,0 +1,596 @@
import { describe, expect, it } from "vitest";
import { createCliJsonlStreamingParser } from "./cli-output-stream.js";
const OPENAI_COMPATIBLE_CLI_USAGE_CASES = [
{
name: "standard OpenAI snake_case token fields",
raw: {
prompt_tokens: 17,
completion_tokens: 5,
total_tokens: 22,
prompt_tokens_details: { cached_tokens: 6 },
},
normalized: { input: 11, output: 5, cacheRead: 6, cacheWrite: undefined, total: 22 },
},
{
name: "camelCase OpenAI-compatible token fields",
raw: {
promptTokens: 17,
completionTokens: 5,
total_tokens: 22,
prompt_tokens_details: { cached_tokens: 6 },
},
normalized: { input: 11, output: 5, cacheRead: 6, cacheWrite: undefined, total: 22 },
},
{
name: "existing input/output field precedence",
raw: {
input_tokens: 19,
prompt_tokens: 99,
output_tokens: 7,
completion_tokens: 77,
total_tokens: 26,
prompt_tokens_details: { cached_tokens: 4 },
},
normalized: { input: 15, output: 7, cacheRead: 4, cacheWrite: undefined, total: 26 },
},
{
name: "flat Codex cached input is included in input_tokens",
raw: {
input_tokens: 15,
output_tokens: 4,
cached_input_tokens: 6,
},
normalized: { input: 9, output: 4, cacheRead: 6, cacheWrite: undefined, total: undefined },
},
{
name: "flat Codex input includes both cached reads and cache writes",
raw: {
input_tokens: 100,
output_tokens: 10,
cached_input_tokens: 40,
cache_write_input_tokens: 60,
},
normalized: { input: 0, output: 10, cacheRead: 40, cacheWrite: 60, total: undefined },
},
{
name: "nested Codex input includes both cached reads and cache writes",
raw: {
input_tokens: 100,
output_tokens: 10,
input_tokens_details: { cached_tokens: 40, cache_write_tokens: 60 },
},
normalized: { input: 0, output: 10, cacheRead: 40, cacheWrite: 60, total: undefined },
},
] as const;
function joinJsonlFrames(...frames: unknown[]) {
return frames
.map((frame) => (typeof frame === "string" ? frame : JSON.stringify(frame)))
.join("\n");
}
function claudeStreamEvent(event: Record<string, unknown>) {
return { type: "stream_event", event };
}
function claudeMessageStart(id?: string) {
return claudeStreamEvent({ type: "message_start", ...(id ? { message: { id } } : {}) });
}
function claudeMessageStop() {
return claudeStreamEvent({ type: "message_stop" });
}
function claudeBlockStart(contentBlock: Record<string, unknown>, index?: number) {
return claudeStreamEvent({
type: "content_block_start",
...(index === undefined ? {} : { index }),
content_block: contentBlock,
});
}
function claudeTextDelta(text: string, index?: number | string) {
return claudeStreamEvent({
type: "content_block_delta",
...(index === undefined ? {} : { index }),
delta: { type: "text_delta", text },
});
}
describe("createCliJsonlStreamingParser", () => {
it.each(OPENAI_COMPATIBLE_CLI_USAGE_CASES)(
"normalizes $name while incrementally streaming CLI JSONL",
({ raw, normalized }) => {
const parser = createCliJsonlStreamingParser({
backend: {
command: "openai-compatible",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "openai-compatible-cli",
onAssistantDelta: () => {},
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "openai-compatible-session" }),
JSON.stringify({
type: "result",
session_id: "openai-compatible-session",
result: "OpenAI-compatible response",
usage: raw,
}),
"",
].join("\n"),
);
parser.finish();
expect(parser.getOutput()).toEqual({
text: "OpenAI-compatible response",
sessionId: "openai-compatible-session",
usage: normalized,
});
},
);
it("streams Claude stream-json deltas for an explicit backend dialect", () => {
const deltas: Array<{ text: string; delta: string; sessionId?: string }> = [];
const sessionIds: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: (delta) => deltas.push(delta),
onSessionId: (sessionId) => sessionIds.push(sessionId),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-stream" }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "hello" },
},
}),
].join("\n"),
);
parser.finish();
expect(deltas).toEqual([
{ text: "hello", delta: "hello", sessionId: "session-stream", usage: undefined },
]);
expect(sessionIds).toEqual(["session-stream"]);
});
it.each([
{
name: "uses streamed Claude assistant text when no result envelope arrives",
frames: [
{ type: "init", session_id: "session-stream-no-result" },
claudeTextDelta("streamed answer"),
],
expected: {
text: "streamed answer",
sessionId: "session-stream-no-result",
usage: undefined,
},
},
{
name: "preserves streamed Claude text when the final result event is empty",
frames: [
{ type: "init", session_id: "session-stream" },
claudeTextDelta("hello"),
claudeTextDelta(" world"),
{ type: "result", session_id: "session-stream", result: "" },
],
expected: {
text: "hello world",
sessionId: "session-stream",
usage: undefined,
},
},
])("$name", ({ frames, expected }) => {
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: () => {},
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(parser.getOutput()).toEqual(expected);
});
it("keeps streamed pre-tool text when the result envelope carries only the final message", () => {
const deltas: Array<{ text: string; delta?: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: (delta) => deltas.push(delta),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-tool-split" }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Marker caribou-lampion-473 explanation." },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
content_block: { type: "tool_use", id: "tool-1", name: "session_status" },
},
}),
JSON.stringify({ type: "stream_event", event: { type: "message_stop" } }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "TEST DONE" },
},
}),
JSON.stringify({ type: "result", session_id: "session-tool-split", result: "TEST DONE" }),
"",
].join("\n"),
);
parser.finish();
expect(parser.getOutput()).toEqual({
text: "Marker caribou-lampion-473 explanation.\n\nTEST DONE",
sessionId: "session-tool-split",
usage: undefined,
});
// Cumulative text must stay reconstructible from deltas for preview streams.
expect(deltas.map((entry) => entry.delta).join("")).toBe(
"Marker caribou-lampion-473 explanation.\n\nTEST DONE",
);
expect(deltas.at(-1)?.text).toBe("Marker caribou-lampion-473 explanation.\n\nTEST DONE");
});
it.each([
{
name: "keeps pre-tool text when text, tool_use, and text share one assistant message",
frames: [
{ type: "init", session_id: "session-single-message" },
claudeMessageStart(),
claudeTextDelta("Marker caribou-lampion-473 explanation."),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "session_status" }),
claudeTextDelta("TEST DONE"),
{ type: "result", session_id: "session-single-message", result: "TEST DONE" },
],
expectedText: "Marker caribou-lampion-473 explanation.\n\nTEST DONE",
},
{
name: "keeps pre-tool text when a toolless closer message follows a tool-using message",
frames: [
{ type: "init", session_id: "session-closer" },
claudeMessageStart(),
claudeTextDelta("Pre-tool analysis."),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "session_status" }),
claudeTextDelta("Post-tool summary."),
claudeMessageStop(),
claudeMessageStart(),
claudeTextDelta("DONE"),
{ type: "result", session_id: "session-closer", result: "DONE" },
],
expectedText: "Pre-tool analysis.\n\nPost-tool summary.\n\nDONE",
},
])("$name", ({ frames, expectedText }) => {
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: () => {},
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(parser.getOutput()?.text).toBe(expectedText);
});
it("judges post-interim-result segments on their own stream state", () => {
const deltas: Array<{ text: string; delta?: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: (delta) => deltas.push(delta),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-interim" }),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Interim answer." },
},
}),
JSON.stringify({
type: "result",
session_id: "session-interim",
result: "Interim answer.",
}),
JSON.stringify({ type: "stream_event", event: { type: "message_start" } }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Pre-tool follow-up." },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
content_block: { type: "tool_use", id: "tool-2", name: "session_status" },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "DONE" },
},
}),
JSON.stringify({ type: "result", session_id: "session-interim", result: "DONE" }),
"",
].join("\n"),
);
parser.finish();
expect(parser.getOutput()?.text).toBe("Interim answer.\nPre-tool follow-up.\n\nDONE");
// Preview snapshots stay cumulative across the interim result.
expect(deltas.at(-1)?.text).toBe("Interim answer.\n\nPre-tool follow-up.\n\nDONE");
expect(deltas.map((entry) => entry.delta).join("")).toBe(
"Interim answer.\n\nPre-tool follow-up.\n\nDONE",
);
});
it.each([
{
name: "does not duplicate existing newlines at message boundaries",
frames: [
{ type: "init", session_id: "session-newlines" },
claudeMessageStart(),
claudeTextDelta("Pre-tool explanation.\n\n"),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "session_status" }),
claudeMessageStart(),
claudeTextDelta("TEST DONE"),
{ type: "result", session_id: "session-newlines", result: "TEST DONE" },
],
expectedText: "Pre-tool explanation.\n\nTEST DONE",
},
{
name: "keeps a later tool split's pre-tool text after an earlier ordinary boundary",
frames: [
{ type: "init", session_id: "session-mixed" },
claudeMessageStart(),
claudeTextDelta("Superseded draft."),
claudeMessageStop(),
claudeMessageStart(),
claudeTextDelta("Important pre-tool text."),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "session_status" }),
claudeTextDelta("DONE"),
{ type: "result", session_id: "session-mixed", result: "DONE" },
],
expectedText: "Important pre-tool text.\n\nDONE",
},
{
name: "drops an earlier draft when a fresh message starts with a tool call",
frames: [
{ type: "init", session_id: "session-tool-first" },
claudeMessageStart(),
claudeTextDelta("Superseded draft."),
claudeMessageStop(),
claudeMessageStart(),
claudeBlockStart({ type: "tool_use", id: "tool-1", name: "session_status" }),
claudeTextDelta("Fresh answer."),
{ type: "result", session_id: "session-tool-first", result: "Fresh answer." },
],
expectedText: "Fresh answer.",
},
{
name: "defers to the result envelope across message boundaries without a tool split",
frames: [
{ type: "init", session_id: "session-draft" },
claudeMessageStart(),
claudeTextDelta("Superseded draft."),
claudeMessageStop(),
claudeMessageStart(),
claudeTextDelta("Final answer."),
{ type: "result", session_id: "session-draft", result: "Final answer." },
],
expectedText: "Final answer.",
},
])("$name", ({ frames, expectedText }) => {
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: () => {},
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(parser.getOutput()?.text).toBe(expectedText);
});
it.each([
{
name: "defers to the result envelope on a suffix match inside a single message",
frames: [
{ type: "init", session_id: "session-suffix" },
claudeMessageStart(),
claudeTextDelta("discarded draft authoritative result"),
{ type: "result", session_id: "session-suffix", result: "authoritative result" },
],
expected: {
text: "authoritative result",
sessionId: "session-suffix",
usage: undefined,
},
},
{
name: "prefers the result envelope when streamed text diverges from it",
frames: [
{ type: "init", session_id: "session-diverged" },
claudeTextDelta("draft wording"),
{ type: "result", session_id: "session-diverged", result: "authoritative result" },
],
expected: {
text: "authoritative result",
sessionId: "session-diverged",
usage: undefined,
},
},
])("$name", ({ frames, expected }) => {
const parser = createCliJsonlStreamingParser({
backend: {
command: "local-cli",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "local-cli",
onAssistantDelta: () => {},
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(parser.getOutput()).toEqual(expected);
});
it("keeps pre-tool text in assistant deltas when no commentary consumer is wired", () => {
const deltas: Array<{ text: string; delta: string }> = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: (delta) => deltas.push({ text: delta.text, delta: delta.delta }),
});
parser.push(
[
JSON.stringify({ type: "init", session_id: "session-drop-commentary" }),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_delta",
delta: { type: "text_delta", text: "Let me inspect the repo." },
},
}),
JSON.stringify({
type: "stream_event",
event: {
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_1", name: "Read", input: {} },
},
}),
].join("\n") + "\n",
);
parser.finish();
expect(deltas).toEqual([
{ text: "Let me inspect the repo.", delta: "Let me inspect the repo." },
]);
});
it.each([
{
name: "does not fire onCommentaryText when no text precedes tool_use",
frames: [
{ type: "init", session_id: "session-no-commentary" },
claudeBlockStart({ type: "tool_use", id: "toolu_1", name: "Bash", input: {} }, 0),
],
expectedCommentary: [],
},
{
name: "does not duplicate commentary when consecutive tool_use blocks have no new text",
frames: [
{ type: "init", session_id: "session-multi-commentary" },
claudeTextDelta("First, checking files."),
claudeBlockStart({ type: "tool_use", id: "toolu_1", name: "Read", input: {} }, 1),
claudeBlockStart({ type: "tool_use", id: "toolu_2", name: "Bash", input: {} }, 2),
],
expectedCommentary: ["First, checking files."],
},
{
name: "emits only the new segment on text-tool-text-tool sequences",
frames: [
{ type: "init", session_id: "session-segment" },
claudeTextDelta("Reading the file now."),
claudeBlockStart({ type: "tool_use", id: "toolu_a", name: "Read", input: {} }, 1),
claudeTextDelta(" Now searching."),
claudeBlockStart({ type: "tool_use", id: "toolu_b", name: "Grep", input: {} }, 3),
],
expectedCommentary: ["Reading the file now.", "Now searching."],
},
])("$name", ({ frames, expectedCommentary }) => {
const commentaryTexts: string[] = [];
const parser = createCliJsonlStreamingParser({
backend: {
command: "claude",
output: "jsonl",
jsonlDialect: "claude-stream-json",
sessionIdFields: ["session_id"],
},
providerId: "claude-cli",
onAssistantDelta: () => undefined,
onCommentaryText: (text) => commentaryTexts.push(text),
});
parser.push(joinJsonlFrames(...frames, ""));
parser.finish();
expect(commentaryTexts).toEqual(expectedCommentary);
});
});
+693
View File
@@ -0,0 +1,693 @@
import { estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { formatErrorMessage } from "../infra/errors.js";
import type {
CliBackendParseJsonlEvent,
CliBackendParsedJsonlEvent,
} from "../plugins/cli-backend.types.js";
import type {
CliJsonlStreamingParserOptions,
CliOutput,
CliStreamJsonOutputLimits,
CliUsage,
} from "./cli-output-contracts.js";
import type { CliEventProjectionState } from "./cli-output-events.js";
import {
createLeadingTaggedReasoningRouter,
createThinkingTracker,
createToolUseTracker,
dispatchClaudeCliStreamingToolEvent,
dispatchClaudeCliThinking,
dispatchGeminiCliStreamingToolEvent,
isClaudeToolUseBlockType,
partitionLeadingTaggedReasoning,
projectCliBackendEvent,
projectCliTaggedReasoning,
} from "./cli-output-events.js";
import {
decodeCliRecords,
isClaudeStreamJsonDialect,
isClaudeStreamJsonResult,
isGeminiStreamJsonDialect,
isStreamJsonDialect,
missingMessageBoundarySeparator,
parseClaudeCliJsonlResult,
parseClaudeCliStreamingDelta,
pickCliResumeCheckpointId,
pickCliSessionId,
preferGeminiCliStreamJsonError,
preferStreamedClaudeTextOverResult,
readCliUsage,
readGeminiCliStreamJsonError,
supportsCliJsonlToolEvents,
} from "./cli-output-records.js";
export const CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024;
const CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES = 20_000;
export const CLI_STREAM_JSON_MISSING_RESULT_ERROR =
"CLI stream-json output ended without a result event.";
export const CLI_STREAM_JSON_OUTPUT_LIMITS = Object.freeze({
maxTurnRawChars: CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS,
maxPendingLineChars: CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS,
maxTurnLines: CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES,
} satisfies CliStreamJsonOutputLimits);
/** Frames arbitrary stdout chunks while bounding each individual raw JSONL line. */
export function frameBoundedCliJsonlChunk(
state: { pending: string },
chunk: string,
maxLineChars: number,
onLine: (line: string) => boolean | void,
): boolean {
for (let offset = 0; offset < chunk.length;) {
const newlineIndex = chunk.indexOf("\n", offset);
const lineEnd = newlineIndex === -1 ? chunk.length : newlineIndex;
if (state.pending.length + (lineEnd - offset) > maxLineChars) {
state.pending = "";
return false;
}
state.pending += chunk.slice(offset, lineEnd);
if (newlineIndex === -1) {
return true;
}
const line = state.pending;
// Control-response writes can synchronously reenter stdout framing.
state.pending = "";
offset = newlineIndex + 1;
if (onLine(line) === false) {
return true;
}
}
return true;
}
/** Drops Claude's echoed binary bytes before they enter retained tool/transcript state. */
export function normalizeClaudeCliStreamJsonRecord(
parsed: Record<string, unknown>,
): { line: string; omittedRawChars: number } | undefined {
if (parsed.type !== "user" || !isRecord(parsed.message)) {
return undefined;
}
const content = Array.isArray(parsed.message.content) ? parsed.message.content : [];
let normalized = false;
let omittedRawChars = 0;
for (const result of content) {
if (!isRecord(result) || result.type !== "tool_result" || !Array.isArray(result.content)) {
continue;
}
for (const block of result.content) {
if (!isRecord(block) || !isRecord(block.source) || block.source.type !== "base64") {
continue;
}
if (
block.type !== "image" &&
!(block.type === "document" && block.source.media_type === "application/pdf")
) {
continue;
}
const { data, ...source } = block.source;
if (typeof data !== "string") {
continue;
}
block.source = source;
block.omitted = true;
block.bytes = estimateBase64DecodedBytes(data);
omittedRawChars += data.length;
normalized = true;
}
}
return normalized ? { line: JSON.stringify(parsed), omittedRawChars } : undefined;
}
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.`;
}
export function createCliJsonlStreamingParser(params: CliJsonlStreamingParserOptions) {
const lineBuffer = { pending: "" };
let assistantText = "";
let customThinkingText = "";
let pendingClaudeText = "";
let pendingMessageSeparator = false;
let currentMessageStart = 0;
let segmentStart = 0;
// Streamed text from this offset on is still a candidate to outrank the
// result envelope; every non-tool boundary or interim result restarts it.
let preserveFrom = 0;
let sawToolUseSinceText = false;
let currentMessageHadToolUse = false;
let previousMessageHadToolUse = false;
let sessionId: string | undefined;
let resumeCheckpointId: string | undefined;
let usage: CliUsage | undefined;
let diagnosticUsage: CliUsage | undefined;
let output: CliOutput | null = null;
let parseErrorText = "";
let rawChars = 0;
let rawLines = 0;
const texts: string[] = [];
let sawCustomJsonlEvent = false;
let sawGeminiStructuredOutput = false;
let sawTerminalResult = false;
const toolTracker = createToolUseTracker();
const outputLimits = CLI_STREAM_JSON_OUTPUT_LIMITS;
// 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 =
Boolean(params.onCommentaryText) && supportsCliJsonlToolEvents(params);
const thinkingTracker = createThinkingTracker();
const claudeStreamJson = isClaudeStreamJsonDialect(params);
let taggedReasoningRouter = createLeadingTaggedReasoningRouter();
let currentTaggedReasoningText = "";
const flushPendingClaudeAssistantText = () => {
if (!pendingClaudeText) {
return;
}
const delta = pendingClaudeText;
pendingClaudeText = "";
assistantText = `${assistantText}${delta}`;
params.onAssistantDelta({
text: assistantText,
delta,
sessionId,
usage,
});
};
const flushPendingClaudeCommentaryText = () => {
if (!pendingClaudeText) {
return;
}
const text = pendingClaudeText.trim();
pendingClaudeText = "";
if (text) {
params.onCommentaryText?.(text);
}
};
const emitClaudeVisibleText = (delta: string) => {
if (!delta) {
return;
}
if (classifyClaudeCommentary) {
pendingClaudeText = `${pendingClaudeText}${delta}`;
return;
}
// A tool_use block starts a new post-tool segment even inside one assistant
// message; only tool-split boundaries may later outrank the result envelope.
// A message boundary is a tool split only when the PREVIOUS message used a
// tool: a tool-first fresh message must not connect an earlier draft, while
// a tool-using message keeps its text connected across its own boundary.
const boundaryPending = pendingMessageSeparator || sawToolUseSinceText;
const isToolSplitBoundary = pendingMessageSeparator
? previousMessageHadToolUse
: sawToolUseSinceText;
const separator =
boundaryPending && assistantText ? missingMessageBoundarySeparator(assistantText, delta) : "";
if (boundaryPending && assistantText) {
currentMessageStart = assistantText.length + separator.length;
// Text before a non-tool boundary may be a superseded draft; only text
// connected to the result through tool splits stays a candidate.
if (!isToolSplitBoundary) {
preserveFrom = currentMessageStart;
}
}
pendingMessageSeparator = false;
sawToolUseSinceText = false;
const deltaText = `${separator}${delta}`;
assistantText = `${assistantText}${deltaText}`;
params.onAssistantDelta({ text: assistantText, delta: deltaText, sessionId, usage });
};
const routeTaggedReasoningDeltas = (
deltas: Parameters<typeof projectCliTaggedReasoning>[0]["deltas"],
) => {
currentTaggedReasoningText = projectCliTaggedReasoning({
deltas,
currentText: currentTaggedReasoningText,
hasNativeThinking: Boolean(thinkingTracker.emittedText),
onThinkingDelta: params.onThinkingDelta,
onVisibleText: emitClaudeVisibleText,
});
};
const finishTaggedReasoningMessage = () => {
if (claudeStreamJson) {
routeTaggedReasoningDeltas(taggedReasoningRouter.finish());
}
};
const beginTaggedReasoningMessage = () => {
finishTaggedReasoningMessage();
taggedReasoningRouter = createLeadingTaggedReasoningRouter();
currentTaggedReasoningText = "";
};
const handleCustomJsonlEvent = (event: CliBackendParsedJsonlEvent) => {
const state: CliEventProjectionState = {
assistantText,
customThinkingText,
sessionId,
usage,
output,
sawCustomJsonlEvent,
};
projectCliBackendEvent({
...params,
event,
state,
texts,
toolTracker,
});
({ assistantText, customThinkingText, sessionId, usage, output, sawCustomJsonlEvent } = state);
};
const accountClaudeJsonlLine = (lineChars: number): boolean => {
rawChars += lineChars + 1;
if (rawChars <= outputLimits.maxTurnRawChars) {
return true;
}
parseErrorText = streamJsonOutputLimitErrorText("raw", outputLimits.maxTurnRawChars);
lineBuffer.pending = "";
return false;
};
const handleCustomJsonlLine = (line: string, rawLine: string): boolean => {
if (parseErrorText) {
return true;
}
if (!params.parseJsonlEvent) {
return false;
}
let parsed: ReturnType<CliBackendParseJsonlEvent>;
try {
parsed = params.parseJsonlEvent(line, {
backendId: params.providerId,
backend: params.backend,
});
} catch (error) {
parseErrorText = truncateUtf16Safe(
`CLI backend ${params.providerId} JSONL parser failed: ${formatErrorMessage(error)}`,
500,
);
return true;
}
if (parsed == null) {
return false;
}
if (claudeStreamJson && !accountClaudeJsonlLine(rawLine.length)) {
return true;
}
for (const event of Array.isArray(parsed) ? parsed : [parsed]) {
if (event.kind === "result") {
sawTerminalResult = true;
}
handleCustomJsonlEvent(event);
}
return true;
};
const handleParsedRecord = (parsed: Record<string, unknown>) => {
if (parseErrorText) {
return;
}
const parsedSessionId = pickCliSessionId(parsed, params.backend);
if (parsed.type === "result" && isStreamJsonDialect(params)) {
sawTerminalResult = true;
}
if (parsedSessionId && parsedSessionId !== sessionId) {
sessionId = parsedSessionId;
params.onSessionId?.(parsedSessionId);
}
const nextUsage = readCliUsage(parsed);
const isClaudeTerminalResult =
isClaudeStreamJsonDialect({
backend: params.backend,
providerId: params.providerId,
}) && parsed.type === "result";
if (isClaudeTerminalResult && nextUsage && usage) {
diagnosticUsage = nextUsage;
}
if (nextUsage) {
params.onUsage?.(nextUsage, isClaudeTerminalResult);
}
const shouldUseUsage =
!isClaudeStreamJsonResult({
backend: params.backend,
providerId: params.providerId,
parsed,
}) || !usage;
if (shouldUseUsage) {
usage = nextUsage ?? usage;
}
if (parsed.type === "assistant" && isRecord(parsed.message)) {
resumeCheckpointId = pickCliResumeCheckpointId({ ...params, parsed }) ?? resumeCheckpointId;
params.onAssistantMessage?.(parsed.message);
}
const geminiErrorText = isGeminiStreamJsonDialect(params)
? readGeminiCliStreamJsonError(parsed)
: undefined;
if (
isGeminiStreamJsonDialect(params) &&
(parsed.type === "tool_use" || parsed.type === "tool_result" || parsed.type === "result")
) {
sawGeminiStructuredOutput = true;
}
if (geminiErrorText) {
output = {
text: "",
sessionId,
usage,
errorText: preferGeminiCliStreamJsonError(output?.errorText, geminiErrorText),
};
return;
}
if (classifyClaudeCommentary && parsed.type === "result") {
finishTaggedReasoningMessage();
flushPendingClaudeAssistantText();
} else if (parsed.type === "result") {
finishTaggedReasoningMessage();
}
let result = parseClaudeCliJsonlResult({
backend: params.backend,
providerId: params.providerId,
parsed,
sessionId,
usage,
});
if (result) {
if (result.errorText) {
output = result;
return;
}
if (claudeStreamJson && result.text) {
const taggedResult = partitionLeadingTaggedReasoning(result.text, true);
if (!taggedResult.pending && taggedResult.reasoningText) {
if (
!thinkingTracker.emittedText &&
taggedResult.reasoningText !== currentTaggedReasoningText
) {
currentTaggedReasoningText = projectCliTaggedReasoning({
deltas: [{ kind: "thinking", text: taggedResult.reasoningText }],
currentText: "",
hasNativeThinking: false,
onThinkingDelta: params.onThinkingDelta,
onVisibleText: emitClaudeVisibleText,
});
}
result = { ...result, text: taggedResult.visibleText.trim() };
}
}
// Empty terminal result can follow already-streamed text; keep that text.
const streamedText = assistantText.slice(segmentStart).trim();
const preservedCandidate = assistantText.slice(preserveFrom).trim();
const keepStreamed = preferStreamedClaudeTextOverResult({
streamedText: preservedCandidate,
finalMessageText: assistantText.slice(currentMessageStart).trim(),
resultText: result.text,
});
const nextText = (
keepStreamed ? preservedCandidate : result.text || streamedText || texts.join("\n").trim()
).trim();
const previousText = output?.text?.trim() ?? "";
// Claude Code may emit an interim result while background agents run, then
// a second result after task-notification. Preserve earlier result text
// when the later envelope does not already include it.
let text = nextText;
if (
previousText &&
nextText &&
previousText !== nextText &&
!nextText.startsWith(previousText)
) {
text = `${previousText}\n${nextText}`;
} else if (!nextText) {
text = previousText;
}
output = {
...result,
text,
...(resumeCheckpointId ? { resumeCheckpointId } : {}),
...(diagnosticUsage ? { diagnosticUsage } : {}),
};
// An interim result commits its segment. Rebase boundary state so later
// text is judged on its own, while delta snapshots stay cumulative.
segmentStart = assistantText.length;
currentMessageStart = segmentStart;
preserveFrom = segmentStart;
pendingMessageSeparator = false;
sawToolUseSinceText = false;
currentMessageHadToolUse = false;
previousMessageHadToolUse = false;
return;
}
const item = isRecord(parsed.item) ? parsed.item : null;
if (item && typeof item.text === "string") {
const type = normalizeLowercaseStringOrEmpty(item.type);
if (!type || type.includes("message")) {
texts.push(item.text);
}
}
if (parsed.type === "stream_event" && isRecord(parsed.event)) {
const evt = parsed.event;
// Tool-split turns stream as separate assistant messages. Mark the
// boundary so accumulated text joins with a paragraph break instead of
// gluing the pre-tool text to the next message's first delta.
if (evt.type === "message_start") {
beginTaggedReasoningMessage();
pendingMessageSeparator = true;
previousMessageHadToolUse = currentMessageHadToolUse;
currentMessageHadToolUse = false;
} else if (evt.type === "message_stop") {
finishTaggedReasoningMessage();
}
const isToolUseBlockStart =
evt.type === "content_block_start" &&
isRecord(evt.content_block) &&
isClaudeToolUseBlockType(evt.content_block.type);
if (isToolUseBlockStart) {
sawToolUseSinceText = true;
currentMessageHadToolUse = true;
}
if (classifyClaudeCommentary) {
if (isToolUseBlockStart) {
flushPendingClaudeCommentaryText();
} else if (evt.type === "content_block_start" || evt.type === "message_stop") {
flushPendingClaudeAssistantText();
}
}
}
if (params.onThinkingDelta || params.onThinkingProgress) {
dispatchClaudeCliThinking({
backend: params.backend,
providerId: params.providerId,
parsed,
tracker: thinkingTracker,
onThinkingDelta: params.onThinkingDelta,
onThinkingProgress: params.onThinkingProgress,
});
}
if (params.onToolUseStart || params.onToolResult) {
dispatchGeminiCliStreamingToolEvent({
backend: params.backend,
providerId: params.providerId,
parsed,
tracker: toolTracker,
onToolUseStart: params.onToolUseStart,
onToolResult: params.onToolResult,
});
dispatchClaudeCliStreamingToolEvent({
backend: params.backend,
providerId: params.providerId,
parsed,
tracker: toolTracker,
onToolUseStart: params.onToolUseStart,
onToolResult: params.onToolResult,
});
}
const delta = parseClaudeCliStreamingDelta({
backend: params.backend,
providerId: params.providerId,
parsed,
});
if (!delta) {
if (
isGeminiStreamJsonDialect(params) &&
parsed.type === "message" &&
parsed.role === "assistant" &&
typeof parsed.content === "string"
) {
const deltaText = parsed.content;
if (deltaText) {
assistantText = `${assistantText}${deltaText}`;
params.onAssistantDelta({
text: assistantText,
delta: deltaText,
sessionId,
usage,
});
}
} else if (
isGeminiStreamJsonDialect(params) &&
parsed.type === "result" &&
parsed.status === "success"
) {
output = {
text: assistantText.trim(),
sessionId,
usage,
};
}
return;
}
if (claudeStreamJson) {
routeTaggedReasoningDeltas(taggedReasoningRouter.push(delta));
return;
}
emitClaudeVisibleText(delta);
};
const handleJsonlLine = (rawLine: string) => {
if (parseErrorText) {
return;
}
const line = rawLine.trim();
if (!line && !claudeStreamJson) {
return;
}
rawLines += 1;
if (rawLines > outputLimits.maxTurnLines) {
parseErrorText = streamJsonOutputLimitErrorText("lines", outputLimits.maxTurnLines);
lineBuffer.pending = "";
return;
}
if (!line) {
accountClaudeJsonlLine(rawLine.length);
return;
}
if (handleCustomJsonlLine(line, rawLine)) {
return;
}
const parsedRecords = decodeCliRecords(line);
if (claudeStreamJson) {
const normalized =
parsedRecords.length === 1
? normalizeClaudeCliStreamJsonRecord(parsedRecords[0]!)
: undefined;
// Exempt actual media bytes only; JSON serialization must not erase wire whitespace.
const retainedChars = normalized
? Math.max(normalized.line.length, rawLine.length - normalized.omittedRawChars)
: rawLine.length;
if (!accountClaudeJsonlLine(retainedChars)) {
return;
}
}
for (const parsed of parsedRecords) {
handleParsedRecord(parsed);
}
};
return {
push(chunk: string) {
if (!chunk || parseErrorText) {
return;
}
if (!claudeStreamJson) {
rawChars += chunk.length;
if (rawChars > outputLimits.maxTurnRawChars) {
parseErrorText = streamJsonOutputLimitErrorText("raw", outputLimits.maxTurnRawChars);
lineBuffer.pending = "";
return;
}
}
if (
!frameBoundedCliJsonlChunk(lineBuffer, chunk, outputLimits.maxPendingLineChars, (line) => {
handleJsonlLine(line);
return !parseErrorText;
})
) {
parseErrorText = streamJsonOutputLimitErrorText("line", outputLimits.maxPendingLineChars);
}
},
finish() {
if (parseErrorText) {
return;
}
const tail = lineBuffer.pending;
lineBuffer.pending = "";
if (tail) {
handleJsonlLine(tail);
}
finishTaggedReasoningMessage();
if (classifyClaudeCommentary) {
flushPendingClaudeAssistantText();
}
},
getErrorText() {
return parseErrorText || null;
},
hasTerminalResult() {
return sawTerminalResult;
},
getOutput() {
if (parseErrorText) {
return {
text: "",
sessionId,
usage,
...(diagnosticUsage ? { diagnosticUsage } : {}),
errorText: parseErrorText,
};
}
if (output) {
return output;
}
if (rawLines === 0) {
return null;
}
if (sawCustomJsonlEvent) {
return { text: texts.join("\n").trim() || assistantText.trim(), sessionId, usage };
}
if (isStreamJsonDialect(params) && assistantText.trim()) {
return {
text: assistantText.trim(),
sessionId,
usage,
...(resumeCheckpointId ? { resumeCheckpointId } : {}),
};
}
if (isGeminiStreamJsonDialect(params) && sawGeminiStructuredOutput) {
return { text: "", sessionId, usage };
}
if (isStreamJsonDialect(params)) {
return {
text: "",
sessionId,
usage,
errorText: CLI_STREAM_JSON_MISSING_RESULT_ERROR,
};
}
const text = texts.join("\n").trim();
return text
? { text, sessionId, usage, ...(resumeCheckpointId ? { resumeCheckpointId } : {}) }
: null;
},
};
}
File diff suppressed because it is too large Load Diff
+21 -2239
View File
File diff suppressed because it is too large Load Diff
@@ -14,7 +14,7 @@ import {
} from "../infra/diagnostic-events.js";
import type { HookRunner } from "../plugins/hooks.js";
import { testing as cliBackendsTesting } from "./cli-backends.test-support.js";
import type { CliOutput } from "./cli-output.js";
import type { CliOutput } from "./cli-output-contracts.js";
import { CliAuthProfilePreparationError } from "./cli-runner/auth-profile-preparation-error.js";
import { cliBackendLog } from "./cli-runner/log.js";
import { FailoverError } from "./failover-error.js";
+1 -1
View File
@@ -40,7 +40,7 @@ import {
resolveCliRuntimeOwnerFingerprint,
} from "./cli-auth-epoch.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import type { CliOutput } from "./cli-output.js";
import type { CliOutput } from "./cli-output-contracts.js";
import { CliAuthProfilePreparationError } from "./cli-runner/auth-profile-preparation-error.js";
import { shouldUseClaudeLiveSession } from "./cli-runner/claude-live-session.js";
import {
+14 -13
View File
@@ -38,23 +38,24 @@ import {
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { resolveAgentConfig, resolveDefaultAgentId } from "../agent-scope-config.js";
import type {
CliOutput,
CliStreamingDelta,
CliStreamJsonOutputLimits,
CliThinkingDelta,
CliThinkingProgress,
CliToolResultDelta,
CliToolUseStartDelta,
CliUsage,
} from "../cli-output-contracts.js";
import {
CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS,
CLI_STREAM_JSON_OUTPUT_LIMITS,
createCliJsonlStreamingParser,
extractCliErrorMessage,
frameBoundedCliJsonlChunk,
normalizeClaudeCliStreamJsonRecord,
parseCliOutput,
type CliOutput,
type CliUsage,
type CliStreamJsonOutputLimits,
type CliStreamingDelta,
type CliThinkingDelta,
type CliThinkingProgress,
type CliToolResultDelta,
type CliToolUseStartDelta,
resolveCliStreamJsonOutputLimits,
} from "../cli-output.js";
} from "../cli-output-stream.js";
import { extractCliErrorMessage, parseCliOutput } from "../cli-output.js";
import { classifyFailoverReason } from "../embedded-agent-helpers.js";
import {
type CliTimeoutContext,
@@ -1696,7 +1697,7 @@ function createTurn(params: {
...(params.context.params.agentId ? { agentId: params.context.params.agentId } : {}),
},
abortSignal: params.context.params.abortSignal,
outputLimits: resolveCliStreamJsonOutputLimits(params.context.preparedBackend.backend),
outputLimits: CLI_STREAM_JSON_OUTPUT_LIMITS,
startedAtMs: Date.now(),
rawLines: [],
noOutputTimer: null,
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Carries confirmed CLI messaging delivery across failed execution/finalization paths.
*/
import type { CliOutput } from "../cli-output.js";
import type { CliOutput } from "../cli-output-contracts.js";
const CLI_MESSAGING_DELIVERY_EVIDENCE_KEY = "cliMessagingDeliveryEvidence";
+1 -14
View File
@@ -1,12 +1,11 @@
import { emitAgentEvent } from "../../infra/agent-events.js";
import { emitTrustedDiagnosticEvent } from "../../infra/diagnostic-events.js";
import type {
CliPlanUpdate,
CliStreamingDelta,
CliThinkingDelta,
CliThinkingProgress,
CliToolUseStartDelta,
} from "../cli-output.js";
} from "../cli-output-contracts.js";
import type { ToolSummaryTrace } from "../embedded-agent-runner/types.js";
import { sanitizeToolArgs, sanitizeToolResult } from "../embedded-agent-subscribe.tools.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
@@ -370,17 +369,6 @@ export function createCliEventHandlers(params: {
}
};
const emitCliPlanUpdate = ({ steps }: CliPlanUpdate) => {
observedCliActivity = true;
if (emitLiveEvents) {
emitAgentEvent({
runId: runParams.runId,
stream: "plan",
data: { phase: "update", title: "Plan updated", source: "codex-exec", steps },
});
}
};
return {
emitLiveEvents,
emitCliToolUseStart,
@@ -394,7 +382,6 @@ export function createCliEventHandlers(params: {
emitCliAssistantDelta,
emitCliThinkingDelta,
emitCliThinkingProgress,
emitCliPlanUpdate,
hasObservedCliActivity: () => observedCliActivity,
activeParsedToolCount: () => activeParsedTools.size,
getToolSummary,
+4 -8
View File
@@ -7,12 +7,9 @@ import {
} from "../../infra/event-session-routing.js";
import type { CliBackendConfig } from "../../plugins/cli-backend.types.js";
import type { RunExit } from "../../process/supervisor/types.js";
import {
createCliJsonlStreamingParser,
extractCliErrorMessage,
parseCliOutput,
type CliOutput,
} from "../cli-output.js";
import type { CliOutput } from "../cli-output-contracts.js";
import { createCliJsonlStreamingParser } from "../cli-output-stream.js";
import { extractCliErrorMessage, parseCliOutput } from "../cli-output.js";
import { classifyFailoverReason } from "../embedded-agent-helpers.js";
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
@@ -171,7 +168,6 @@ export async function executeCliProcess(params: {
onAssistantDelta: params.events.emitCliAssistantDelta,
onThinkingDelta: params.events.emitCliThinkingDelta,
onThinkingProgress: params.events.emitCliThinkingProgress,
onPlanUpdate: params.events.emitCliPlanUpdate,
onToolUseStart: params.events.emitParsedToolUseStart,
onToolResult: params.events.emitParsedToolResult,
onDisplayToolUseStart: params.events.emitCliDisplayToolUseStart,
@@ -328,7 +324,7 @@ export async function executeCliProcess(params: {
nodeRunTruncated &&
result.exitCode === 0 &&
!result.timedOut &&
!streamingParser?.getOutput()
!streamingParser?.hasTerminalResult()
) {
throw new FailoverError(
"paired node truncated the Claude CLI stream before the terminal result; refusing to accept partial output.",
@@ -7,7 +7,7 @@ import {
waitForMcpLoopbackToolCallCaptureIdle,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { shouldUseInternalSourceReplySink } from "../../infra/outbound/internal-source-reply.js";
import type { CliOutput, CliToolUseStartDelta } from "../cli-output.js";
import type { CliOutput, CliToolUseStartDelta } from "../cli-output-contracts.js";
import {
isDeliveredMessageToolOnlySourceReplyResult,
isDeliveredMessagingToolResult,
+1 -1
View File
@@ -14,7 +14,7 @@ import {
resolveCliRuntimeOwnerFingerprint,
} from "../cli-auth-epoch.js";
import { resolveCliExecutableIdentity } from "../cli-executable-identity.js";
import type { CliOutput } from "../cli-output.js";
import type { CliOutput } from "../cli-output-contracts.js";
import {
detectImageReferences,
hasHydratableMediaImages,
@@ -7,7 +7,7 @@ import {
waitForDiagnosticEventsDrained,
type DiagnosticEventPrivateData,
} from "../../infra/diagnostic-events.js";
import type { CliOutput } from "../cli-output.js";
import type { CliOutput } from "../cli-output-contracts.js";
import { createClaudeCliModelCallDiagnostics } from "./model-call-diagnostics.js";
import type { PreparedCliRunContext } from "./types.js";
@@ -22,7 +22,7 @@ import {
createDiagnosticTraceContextFromActiveScope,
freezeDiagnosticTraceContext,
} from "../../infra/diagnostic-trace-context.js";
import type { CliOutput, CliUsage } from "../cli-output.js";
import type { CliOutput, CliUsage } from "../cli-output-contracts.js";
import { isFailoverError } from "../failover-error.js";
import type { PreparedCliRunContext } from "./types.js";
+2 -1
View File
@@ -1,4 +1,5 @@
import { formatCliOutputError, type CliOutput } from "../cli-output.js";
import type { CliOutput } from "../cli-output-contracts.js";
import { formatCliOutputError } from "../cli-output.js";
import { classifyFailoverReason } from "../embedded-agent-helpers.js";
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { createCliJsonlStreamingParser } from "../../agents/cli-output.js";
import { createCliJsonlStreamingParser } from "../../agents/cli-output-stream.js";
import type { TemplateContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
import {