diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3669e4c31f6a..b8e0e9c347f5 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/agents/cli-output-contracts.ts b/src/agents/cli-output-contracts.ts new file mode 100644 index 000000000000..33aedcb3288b --- /dev/null +++ b/src/agents/cli-output-contracts.ts @@ -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; +}; + +/** 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; +}; diff --git a/src/agents/cli-output-error.test.ts b/src/agents/cli-output-error.test.ts index 1d3a66127cf6..af5988498e95 100644 --- a/src/agents/cli-output-error.test.ts +++ b/src/agents/cli-output-error.test.ts @@ -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[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])|(? { 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, + }); + }); +}); diff --git a/src/agents/cli-output-events.test.ts b/src/agents/cli-output-events.test.ts new file mode 100644 index 000000000000..a1d8a61315ce --- /dev/null +++ b/src/agents/cli-output-events.test.ts @@ -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) { + return { type: "stream_event", event }; +} + +function claudeMessageStop() { + return claudeStreamEvent({ type: "message_stop" }); +} + +function claudeBlockStart(contentBlock: Record, 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); + }); +}); diff --git a/src/agents/cli-output-events.ts b/src/agents/cli-output-events.ts new file mode 100644 index 000000000000..9e55c96ac5be --- /dev/null +++ b/src/agents/cli-output-events.ts @@ -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; + nameById: Map; + startedIds: Set; + resultDeliveredIds: Set; +}; + +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, + 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 { + 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; + 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 = 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; + // 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): 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): 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; + 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; + 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. */ diff --git a/src/agents/cli-output-framing.test.ts b/src/agents/cli-output-framing.test.ts new file mode 100644 index 000000000000..7ca06cc12503 --- /dev/null +++ b/src/agents/cli-output-framing.test.ts @@ -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) { + return { type: "stream_event", event }; +} + +function claudeBlockStart(contentBlock: Record, 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); + }); +}); diff --git a/src/agents/cli-output-jsonl.test.ts b/src/agents/cli-output-jsonl.test.ts new file mode 100644 index 000000000000..914f118c679d --- /dev/null +++ b/src/agents/cli-output-jsonl.test.ts @@ -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[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) { + 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); + }); +}); diff --git a/src/agents/cli-output-reasoning.test.ts b/src/agents/cli-output-reasoning.test.ts new file mode 100644 index 000000000000..eca639ab59e9 --- /dev/null +++ b/src/agents/cli-output-reasoning.test.ts @@ -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) { + return { type: "stream_event", event }; +} + +function claudeMessageStart(id?: string) { + return claudeStreamEvent({ type: "message_start", ...(id ? { message: { id } } : {}) }); +} + +function claudeBlockStart(contentBlock: Record, 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: "Private analysis.Visible answer.", + }, + }, + }), + JSON.stringify({ + type: "result", + session_id: "session-tagged", + result: "Private analysis.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("Private "); + expect(assistant).toEqual([]); + expect(thinking).toEqual([]); + pushText("analysis.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, text: string) => + parser.push( + `${JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text }, + }, + })}\n`, + ); + + pushText(visible.parser, "
Visible prefix Visible prefix Visible prefix Visible prefix "); + pushText(mixed.parser, "Visible prefix Visible prefix Private analysis."); + expect(tagged.assistant).toEqual([]); + expect(tagged.thinking).toEqual([]); + pushText(tagged.parser, "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: [ + "First.", + "Second.", + "Answer with literal markup.", + ].join("\n"), + }, + }, + })}\n`, + ); + parser.finish(); + + expect(thinking.map((entry) => entry.text)).toEqual(["First.Second."]); + expect(assistant.at(-1)?.text).toBe("\nAnswer with literal markup."); + expect(parser.getOutput()?.text).toBe("Answer with literal 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: "Native analysis.Visible answer.", + }, + }, + }), + JSON.stringify({ + type: "result", + result: "Native analysis.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\nliteral example\n```", + }, + { name: "incomplete leading tag", text: "unfinished visible text" }, + { name: "malformed leading tag", 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("First thought.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("Second thought.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 }, + ]); + }); +}); diff --git a/src/agents/cli-output-records.test.ts b/src/agents/cli-output-records.test.ts new file mode 100644 index 000000000000..df91bea40dc7 --- /dev/null +++ b/src/agents/cli-output-records.test.ts @@ -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[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) { + 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, + }); + }); +}); diff --git a/src/agents/cli-output-records.ts b/src/agents/cli-output-records.ts new file mode 100644 index 000000000000..53e38369b2ba --- /dev/null +++ b/src/agents/cli-output-records.ts @@ -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; +}): 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[] { + const parsedRecords: Record[] = []; + 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 | 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): 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): 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 { + 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, +): 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 | 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, + 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, + 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 | 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; +}): boolean { + if (!params.providerId || !isClaudeCliProvider(params.providerId)) { + return false; + } + return !Object.hasOwn(params.parsed, "type") || params.parsed.type === "result"; +} + +function hasExplicitCliErrorPayload(parsed: Record): boolean { + if (typeof parsed.error === "string") { + return Boolean(parsed.error.trim()); + } + if (isRecord(parsed.error)) { + return Boolean(readNestedErrorMessage(parsed.error)); + } + return false; +} + +/** Parses 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; + 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 | 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 | 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. diff --git a/src/agents/cli-output-stream.test.ts b/src/agents/cli-output-stream.test.ts new file mode 100644 index 000000000000..864d408bae27 --- /dev/null +++ b/src/agents/cli-output-stream.test.ts @@ -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) { + 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, 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); + }); +}); diff --git a/src/agents/cli-output-stream.ts b/src/agents/cli-output-stream.ts new file mode 100644 index 000000000000..0084834b4b71 --- /dev/null +++ b/src/agents/cli-output-stream.ts @@ -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, +): { 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[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; + 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) => { + 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; + }, + }; +} diff --git a/src/agents/cli-output.test.ts b/src/agents/cli-output.test.ts deleted file mode 100644 index f1bcb040bc57..000000000000 --- a/src/agents/cli-output.test.ts +++ /dev/null @@ -1,3519 +0,0 @@ -/** Tests CLI JSON/JSONL output parsing, streamed deltas, and error extraction. */ -import { readFileSync } from "node:fs"; -import { describe, expect, it, vi } from "vitest"; -import { - createCliJsonlStreamingParser, - extractCliErrorMessage, - formatCliOutputError, - parseCliOutput, - type CliThinkingProgress, - type CliToolResultDelta, - type CliToolUseStartDelta, -} from "./cli-output.js"; -import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js"; - -type ParseCliOutputParams = Parameters[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) { - 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, 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 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 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 } }; -} - -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([ - { - 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(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, - }); - }, - ); - - 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("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("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("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("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("createCliJsonlStreamingParser", () => { - 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.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("surfaces codex-exec todo snapshots as typed plan updates", () => { - const plans: Array<{ steps: Array<{ step: string; status: string }> }> = []; - const parser = createCliJsonlStreamingParser({ - backend: { command: "codex", output: "jsonl", sessionIdFields: ["thread_id"] }, - providerId: "codex-cli", - onAssistantDelta: () => {}, - onPlanUpdate: (plan) => plans.push(plan), - }); - - parser.push( - `${JSON.stringify({ - type: "item.updated", - item: { - type: "todo_list", - items: [ - { text: "Inspect", completed: true }, - { text: "Patch", completed: false }, - ], - }, - })}\n`, - ); - - expect(plans).toEqual([ - { - steps: [ - { step: "Inspect", status: "completed" }, - { step: "Patch", status: "pending" }, - ], - }, - ]); - }); - - 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("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: "Private analysis.Visible answer.", - }, - }, - }), - JSON.stringify({ - type: "result", - session_id: "session-tagged", - result: "Private analysis.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("Private "); - expect(assistant).toEqual([]); - expect(thinking).toEqual([]); - pushText("analysis.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, text: string) => - parser.push( - `${JSON.stringify({ - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text }, - }, - })}\n`, - ); - - pushText(visible.parser, "
Visible prefix Visible prefix Visible prefix Visible prefix "); - pushText(mixed.parser, "Visible prefix Visible prefix Private analysis."); - expect(tagged.assistant).toEqual([]); - expect(tagged.thinking).toEqual([]); - pushText(tagged.parser, "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: [ - "First.", - "Second.", - "Answer with literal markup.", - ].join("\n"), - }, - }, - })}\n`, - ); - parser.finish(); - - expect(thinking.map((entry) => entry.text)).toEqual(["First.Second."]); - expect(assistant.at(-1)?.text).toBe("\nAnswer with literal markup."); - expect(parser.getOutput()?.text).toBe("Answer with literal 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: "Native analysis.Visible answer.", - }, - }, - }), - JSON.stringify({ - type: "result", - result: "Native analysis.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\nliteral example\n```", - }, - { name: "incomplete leading tag", text: "unfinished visible text" }, - { name: "malformed leading tag", 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("First thought.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("Second thought.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 }, - ]); - }); - - 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); - }); - - 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 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("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("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, - }); - }); - - 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("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, - }); - }); - - 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("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); - }); - - 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); - }); - - 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); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 243a7fada216..36db916c853f 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -3,81 +3,19 @@ * JSONL streaming, Claude stream-json dialects, usage metadata, and tool event * reconstruction. */ -import { estimateBase64DecodedBytes } from "@openclaw/media-core/base64"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import type { CliBackendConfig, CliBackendParseJsonlEvent } from "../plugins/cli-backend.types.js"; +import type { CliOutput } from "./cli-output-contracts.js"; import { - createReasoningTagTextPartitioner, - scanReasoningTags, - type ReasoningTagTextDelta, -} from "../../packages/markdown-core/src/reasoning-tags.js"; -import type { AgentPlanStep } from "../channels/streaming.js"; -import { formatErrorMessage } from "../infra/errors.js"; -import type { - CliBackendConfig, - CliBackendParseJsonlEvent, - CliBackendParsedJsonlEvent, -} from "../plugins/cli-backend.types.js"; -import { extractBalancedJsonFragments } from "../shared/balanced-json.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; -}; - -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; -}; + collectExplicitCliErrorText, + decodeCliRecords, + isStreamJsonDialect, + parseCliJson, +} from "./cli-output-records.js"; +import { + CLI_STREAM_JSON_MISSING_RESULT_ERROR, + createCliJsonlStreamingParser, +} from "./cli-output-stream.js"; function normalizeCliContextValue(value: string | undefined): string | undefined { const normalized = value?.trim().replace(/\s+/g, " "); @@ -109,2155 +47,6 @@ export function formatCliOutputError( ].join(" "); } -export const CLI_STREAM_JSON_DEFAULT_MAX_TURN_RAW_CHARS = 8 * 1024 * 1024; -const CLI_STREAM_JSON_DEFAULT_MAX_TURN_LINES = 20_000; -const CLI_STREAM_JSON_MISSING_RESULT_ERROR = "CLI stream-json output ended without a result event."; - -/** Incremental assistant text emitted while parsing a streaming CLI response. */ -export type CliStreamingDelta = { - text: string; - 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; -}; - -export type CliPlanUpdate = { - steps: AgentPlanStep[]; -}; - -/** 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; -}; - -/** Tool-call result event reconstructed from CLI stream output. */ -export type CliToolResultDelta = { - toolCallId: string; - name: string; - isError: boolean; - result?: unknown; -}; - -function isClaudeCliProvider(providerId: string): boolean { - return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli"; -} - -function isGeminiCliProvider(providerId: string): boolean { - return normalizeLowercaseStringOrEmpty(providerId) === "google-gemini-cli"; -} - -function isGeminiStreamJsonDialect(params: { - backend: CliBackendConfig; - providerId: string; -}): boolean { - return ( - params.backend.jsonlDialect === "gemini-stream-json" || isGeminiCliProvider(params.providerId) - ); -} - -function isClaudeStreamJsonDialect(params: { - backend: CliBackendConfig; - providerId: string; -}): boolean { - if (params.backend.jsonlDialect) { - return params.backend.jsonlDialect === "claude-stream-json"; - } - return isClaudeCliProvider(params.providerId); -} - -function isStreamJsonDialect(params: { backend: CliBackendConfig; providerId: string }): boolean { - return supportsCliJsonlToolEvents(params); -} - -/** Returns whether JSONL output carries correlated provider tool events. */ -function supportsCliJsonlToolEvents(params: { - backend: CliBackendConfig; - providerId: string; -}): boolean { - return ( - params.backend.jsonlDialect === "claude-stream-json" || - isClaudeCliProvider(params.providerId) || - isGeminiStreamJsonDialect(params) - ); -} - -function isClaudeStreamJsonResult(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; -}): boolean { - return supportsCliJsonlToolEvents(params) && params.parsed.type === "result"; -} - -function extractJsonObjectCandidates(raw: string): string[] { - return extractBalancedJsonFragments(raw, { openers: ["{"] }).map((fragment) => fragment.json); -} - -function parseJsonRecordCandidates(raw: string): Record[] { - const parsedRecords: Record[] = []; - 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 | undefined { - if (isRecord(parsed.error)) { - const errorMessage = readNestedErrorMessage(parsed.error); - if (errorMessage) { - return errorMessage; - } - } - if (typeof parsed.message === "string") { - const trimmed = parsed.message.trim(); - if (trimmed) { - return trimmed; - } - } - if (typeof parsed.error === "string") { - const trimmed = parsed.error.trim(); - if (trimmed) { - return trimmed; - } - } - return undefined; -} - -function unwrapCliErrorText(raw: string): string { - const trimmed = raw.trim(); - if (!trimmed) { - return ""; - } - for (const parsed of parseJsonRecordCandidates(trimmed)) { - const nested = readNestedErrorMessage(parsed); - if (nested) { - return nested; - } - } - return trimmed; -} - -function toCliUsage(raw: Record): CliUsage | undefined { - const 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 }; -} - -function readCliUsage(parsed: Record): 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; -} - -function collectExplicitCliErrorText(parsed: Record): string { - const subtype = typeof parsed.subtype === "string" ? parsed.subtype.trim() : ""; - const isResultError = - parsed.is_error === true || - (parsed.type === "result" && (subtype.startsWith("error_") || parsed.status === "error")); - if (isResultError) { - const text = - collectCliText(parsed.result) || - collectCliText(parsed.message) || - collectCliText(parsed.content); - if (text) { - return unwrapCliErrorText(text); - } - const nested = readNestedErrorMessage(parsed); - if (nested) { - return unwrapCliErrorText(nested); - } - if (subtype) { - return `Claude CLI result subtype ${subtype}.`; - } - return "CLI result was marked as an error."; - } - - const nested = readNestedErrorMessage(parsed); - if (nested) { - return unwrapCliErrorText(nested); - } - - if (parsed.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, -): 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 | 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, - terminalFailure: CliTerminalFailure | undefined, -): string { - const explicitErrorText = collectExplicitCliErrorText(parsed); - return ( - ((terminalFailure ? readClaudeMaxTurnsErrorText(parsed) : undefined) ?? explicitErrorText) || - (terminalFailure ? "Reached maximum number of turns." : "") - ); -} - -function pickCliSessionId( - parsed: Record, - 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; -} - -function pickCliResumeCheckpointId(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; -}): 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; -}): boolean { - if (!params.providerId || !isClaudeCliProvider(params.providerId)) { - return false; - } - return !Object.hasOwn(params.parsed, "type") || params.parsed.type === "result"; -} - -export function resolveCliStreamJsonOutputLimits( - _backend: CliBackendConfig, -): CliStreamJsonOutputLimits { - return { - 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, - }; -} - -/** 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, -): { 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.`; -} - -function hasExplicitCliErrorPayload(parsed: Record): boolean { - if (typeof parsed.error === "string") { - return Boolean(parsed.error.trim()); - } - if (isRecord(parsed.error)) { - return Boolean(readNestedErrorMessage(parsed.error)); - } - return false; -} - -/** Parses JSON CLI output, including mixed stdout that contains embedded JSON objects. */ -/** Parses a single JSON payload emitted by a CLI backend. */ -function parseCliJson( - raw: string, - backend: CliBackendConfig, - providerId?: string, -): CliOutput | null { - const parsedRecords = parseJsonRecordCandidates(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 }; -} - -function parseClaudeCliJsonlResult(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; - 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. -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. -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)); -} - -function parseClaudeCliStreamingDelta(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; - textSoFar: string; - sessionId?: string; - usage?: CliUsage; -}): CliStreamingDelta | 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 { - text: `${params.textSoFar}${delta.text}`, - delta: delta.text, - sessionId: params.sessionId, - usage: params.usage, - }; -} - -type PendingToolUse = { - toolCallId: string; - name: string; - kind: CliToolUseStartDelta["kind"]; - inputJsonParts: string[]; -}; - -type ToolUseTracker = { - pendingByIndex: Map; - nameById: Map; - startedIds: Set; - resultDeliveredIds: Set; -}; - -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, - 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, - }); -} - -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 { - if (parts.length === 0) { - return {}; - } - try { - const parsed: unknown = JSON.parse(parts.join("")); - return isRecord(parsed) ? parsed : {}; - } catch { - return {}; - } -} - -function dispatchClaudeCliStreamingToolEvent(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; - 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 = 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; - // 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; -}; - -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): 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): 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 }); -} - -function dispatchClaudeCliThinking(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; - 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 }); - } - } -} - -function dispatchGeminiCliStreamingToolEvent(params: { - backend: CliBackendConfig; - providerId: string; - parsed: Record; - 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, - ); - } -} - -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 - ); -} - -function preferGeminiCliStreamJsonError(current: string | undefined, next: string): string { - if (!current) { - return next; - } - if (isFallbackGeminiCliStreamJsonError(current) && !isFallbackGeminiCliStreamJsonError(next)) { - return next; - } - return current; -} - -function readGeminiCliStreamJsonError(parsed: Record): 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. -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 }; -} - -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. */ -export function createCliJsonlStreamingParser(params: { - backend: CliBackendConfig; - providerId: string; - parseJsonlEvent?: CliBackendParseJsonlEvent; - onAssistantDelta: (delta: CliStreamingDelta) => void; - onThinkingDelta?: (delta: CliThinkingDelta) => void; - onThinkingProgress?: (progress: CliThinkingProgress) => void; - onPlanUpdate?: (update: CliPlanUpdate) => 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; -}) { - 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; - const toolTracker = createToolUseTracker(); - const outputLimits = resolveCliStreamJsonOutputLimits(params.backend); - // Classification is keyed on consumer presence so reclassified pre-tool text - // always has a destination; a separate enable flag let it be dropped (#92092). - const classifyClaudeCommentary = - 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 emitTaggedReasoning = (delta: string) => { - if (!delta) { - return; - } - currentTaggedReasoningText = `${currentTaggedReasoningText}${delta}`; - // Native thinking is the provider-authored shape and remains authoritative - // when a text block mirrors it with tags. - if (!thinkingTracker.emittedText) { - params.onThinkingDelta?.({ - text: currentTaggedReasoningText, - delta, - isReasoningSnapshot: true, - }); - } - }; - - const routeTaggedReasoningDeltas = (deltas: readonly ReasoningTagTextDelta[]) => { - for (const delta of deltas) { - if (delta.kind === "thinking") { - emitTaggedReasoning(delta.text); - } else { - emitClaudeVisibleText(delta.text); - } - } - }; - - const finishTaggedReasoningMessage = () => { - if (claudeStreamJson) { - routeTaggedReasoningDeltas(taggedReasoningRouter.finish()); - } - }; - - const beginTaggedReasoningMessage = () => { - finishTaggedReasoningMessage(); - taggedReasoningRouter = createLeadingTaggedReasoningRouter(); - currentTaggedReasoningText = ""; - }; - - const updateSessionId = (nextSessionId: string | undefined) => { - const normalized = nextSessionId?.trim(); - if (!normalized || normalized === sessionId) { - return; - } - sessionId = normalized; - params.onSessionId?.(normalized); - }; - - const handleCustomJsonlEvent = (event: CliBackendParsedJsonlEvent) => { - if (output?.errorText && event.kind !== "sessionId" && event.kind !== "result") { - return; - } - sawCustomJsonlEvent = true; - if (event.kind === "sessionId") { - updateSessionId(event.sessionId); - if (output) { - output = { ...output, sessionId }; - } - return; - } - if (event.kind === "text") { - if (!event.text) { - return; - } - assistantText = `${assistantText}${event.text}`; - params.onAssistantDelta({ - text: assistantText, - delta: event.text, - sessionId, - usage, - }); - return; - } - if (event.kind === "thinking") { - if (!event.text || !params.onThinkingDelta) { - return; - } - customThinkingText = `${customThinkingText}${event.text}`; - params.onThinkingDelta({ - text: customThinkingText, - delta: event.text, - isReasoningSnapshot: true, - }); - return; - } - if (event.kind === "toolStart") { - emitToolStartOnce( - toolTracker, - event.toolCallId, - event.name, - "tool_use", - event.args ?? {}, - params.onDisplayToolUseStart ?? params.onToolUseStart, - ); - return; - } - if (event.kind === "toolResult") { - if (event.name) { - toolTracker.nameById.set(event.toolCallId, event.name); - } - emitToolResultOnce( - toolTracker, - event.toolCallId, - event.isError === true, - event.result, - params.onDisplayToolResult ?? params.onToolResult, - ); - return; - } - updateSessionId(event.sessionId); - if (event.usage) { - usage = event.usage; - params.onUsage?.(event.usage, true); - } - const existingErrorText = output?.errorText; - const eventText = event.text?.trim() ?? ""; - const existingText = output?.text.trim() ?? ""; - const streamedText = assistantText.trim(); - const delegatedText = texts.join("\n").trim(); - const resultText = existingErrorText - ? existingText || delegatedText || streamedText - : eventText || existingText || delegatedText || streamedText; - const errorText = existingErrorText || event.errorText; - output = { - ...output, - text: resultText, - sessionId, - usage, - ...(errorText ? { errorText } : {}), - }; - }; - - 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; - 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]) { - handleCustomJsonlEvent(event); - } - return true; - }; - - const handleParsedRecord = (parsed: Record) => { - if (parseErrorText) { - return; - } - const parsedSessionId = - pickCliSessionId(parsed, params.backend) ?? - (!sessionId && typeof parsed.thread_id === "string" ? parsed.thread_id.trim() : undefined); - 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 (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 = ""; - emitTaggedReasoning(taggedResult.reasoningText); - } - 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?.type === "todo_list" && Array.isArray(item.items)) { - const steps = item.items.flatMap((entry) => { - if (!isRecord(entry) || typeof entry.text !== "string") { - return []; - } - return [ - { - step: entry.text, - // codex exec JSONL exposes only a completed boolean for todo items. - status: entry.completed === true ? ("completed" as const) : ("pending" as const), - }, - ]; - }); - if (steps.length > 0) { - params.onPlanUpdate?.({ steps }); - } - } - 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, - textSoFar: assistantText, - sessionId, - usage, - }); - 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.delta)); - return; - } - emitClaudeVisibleText(delta.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 = parseJsonRecordCandidates(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; - }, - getOutput() { - if (parseErrorText) { - return { - text: "", - sessionId, - usage, - ...(diagnosticUsage ? { diagnosticUsage } : {}), - errorText: parseErrorText, - }; - } - if (output) { - return output; - } - 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 } : {}), - }; - } - const text = texts.join("\n").trim(); - return text - ? { text, sessionId, usage, ...(resumeCheckpointId ? { resumeCheckpointId } : {}) } - : null; - }, - }; -} - -/** Parses complete JSONL CLI output into the final assistant result and metadata. */ -/** Parses complete JSONL output from a CLI backend into normalized text and metadata. */ -function parseCliJsonl( - raw: string, - backend: CliBackendConfig, - providerId: string, -): CliOutput | null { - const lines = normalizeStringEntries(raw.split(/\r?\n/g)); - if (lines.length === 0) { - return null; - } - let sessionId: string | undefined; - let resumeCheckpointId: string | undefined; - let usage: CliUsage | undefined; - let diagnosticUsage: CliUsage | undefined; - const texts: string[] = []; - let streamJsonText = ""; - let pendingMessageSeparator = false; - let currentMessageStart = 0; - let segmentStart = 0; - let preserveFrom = 0; - let sawToolUseSinceText = false; - let currentMessageHadToolUse = false; - let previousMessageHadToolUse = false; - let committedResult: CliOutput | null = null; - let geminiErrorText: string | undefined; - let sawGeminiStructuredOutput = false; - const streamJsonDialect = isStreamJsonDialect({ backend, providerId }); - for (const line of lines) { - for (const parsed of parseJsonRecordCandidates(line)) { - sessionId = pickCliSessionId(parsed, backend) ?? sessionId; - if (!sessionId && typeof parsed.thread_id === "string") { - sessionId = parsed.thread_id.trim(); - } - resumeCheckpointId = - pickCliResumeCheckpointId({ backend, providerId, parsed }) ?? resumeCheckpointId; - const nextUsage = readCliUsage(parsed); - const isClaudeTerminalResult = - isClaudeStreamJsonDialect({ backend, providerId }) && parsed.type === "result"; - if (isClaudeTerminalResult && nextUsage && usage) { - diagnosticUsage = nextUsage; - } - const shouldUseUsage = !isClaudeStreamJsonResult({ backend, providerId, parsed }) || !usage; - if (shouldUseUsage) { - usage = nextUsage ?? usage; - } - - if (isGeminiStreamJsonDialect({ backend, providerId })) { - const nextGeminiErrorText = readGeminiCliStreamJsonError(parsed); - if (nextGeminiErrorText) { - geminiErrorText = preferGeminiCliStreamJsonError(geminiErrorText, nextGeminiErrorText); - sawGeminiStructuredOutput = true; - continue; - } - if ( - parsed.type === "message" && - parsed.role === "assistant" && - typeof parsed.content === "string" - ) { - streamJsonText = `${streamJsonText}${parsed.content}`; - sawGeminiStructuredOutput = true; - continue; - } - if ( - parsed.type === "tool_use" || - parsed.type === "tool_result" || - parsed.type === "result" - ) { - sawGeminiStructuredOutput = true; - } - } - - const claudeResult = parseClaudeCliJsonlResult({ - backend, - providerId, - parsed, - sessionId, - usage, - }); - if (claudeResult) { - if (claudeResult.errorText) { - return { - ...claudeResult, - ...(resumeCheckpointId ? { resumeCheckpointId } : {}), - }; - } - // Live sessions reparse the completed JSONL transcript, so preserve - // streamed text here as well as in the incremental parser above, and - // keep scanning: an interim result can be followed by more stream - // events and the actual terminal result. - const streamedText = streamJsonText.slice(segmentStart).trim(); - const preservedCandidate = streamJsonText.slice(preserveFrom).trim(); - const keepStreamed = preferStreamedClaudeTextOverResult({ - streamedText: preservedCandidate, - finalMessageText: streamJsonText.slice(currentMessageStart).trim(), - resultText: claudeResult.text, - }); - const nextText = ( - keepStreamed - ? preservedCandidate - : claudeResult.text || streamedText || texts.join("\n").trim() - ).trim(); - const previousText = committedResult?.text?.trim() ?? ""; - let text = nextText; - if ( - previousText && - nextText && - previousText !== nextText && - !nextText.startsWith(previousText) - ) { - text = `${previousText}\n${nextText}`; - } else if (!nextText) { - text = previousText; - } - committedResult = { - ...claudeResult, - text, - ...(resumeCheckpointId ? { resumeCheckpointId } : {}), - ...(diagnosticUsage ? { diagnosticUsage } : {}), - }; - segmentStart = streamJsonText.length; - currentMessageStart = segmentStart; - preserveFrom = segmentStart; - pendingMessageSeparator = false; - sawToolUseSinceText = false; - currentMessageHadToolUse = false; - previousMessageHadToolUse = false; - continue; - } - - if (parsed.type === "stream_event" && isRecord(parsed.event)) { - // Same boundary contracts as the incremental parser above. - if (parsed.event.type === "message_start") { - pendingMessageSeparator = true; - previousMessageHadToolUse = currentMessageHadToolUse; - currentMessageHadToolUse = false; - } - if ( - parsed.event.type === "content_block_start" && - isRecord(parsed.event.content_block) && - isClaudeToolUseBlockType(parsed.event.content_block.type) - ) { - sawToolUseSinceText = true; - currentMessageHadToolUse = true; - } - } - const claudeDelta = parseClaudeCliStreamingDelta({ - backend, - providerId, - parsed, - textSoFar: streamJsonText, - sessionId, - usage, - }); - if (claudeDelta) { - const boundaryPending = pendingMessageSeparator || sawToolUseSinceText; - const isToolSplitBoundary = pendingMessageSeparator - ? previousMessageHadToolUse - : sawToolUseSinceText; - const separator = - boundaryPending && streamJsonText - ? missingMessageBoundarySeparator(streamJsonText, claudeDelta.delta) - : ""; - if (boundaryPending && streamJsonText) { - currentMessageStart = streamJsonText.length + separator.length; - if (!isToolSplitBoundary) { - preserveFrom = currentMessageStart; - } - } - pendingMessageSeparator = false; - sawToolUseSinceText = false; - streamJsonText = `${streamJsonText}${separator}${claudeDelta.delta}`; - continue; - } - - 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 (committedResult) { - return committedResult; - } - if (isGeminiStreamJsonDialect({ backend, providerId }) && geminiErrorText) { - return { text: "", sessionId, usage, errorText: geminiErrorText }; - } - if (streamJsonDialect && (streamJsonText.trim() || sawGeminiStructuredOutput)) { - return { - text: streamJsonText.trim(), - sessionId, - usage, - ...(resumeCheckpointId ? { resumeCheckpointId } : {}), - }; - } - if (streamJsonDialect) { - return { text: "", sessionId, usage, errorText: CLI_STREAM_JSON_MISSING_RESULT_ERROR }; - } - const text = texts.join("\n").trim(); - if (!text) { - return null; - } - return { text, sessionId, usage }; -} - -/** Parses CLI output according to the backend output mode with text fallback. */ /** Parses CLI backend output using the configured JSON/JSONL/plain-text mode. */ export function parseCliOutput(params: { raw: string; @@ -2272,20 +61,15 @@ export function parseCliOutput(params: { return { text: params.raw.trim(), sessionId: params.fallbackSessionId }; } if (outputMode === "jsonl") { - let parsed: CliOutput | null; - if (params.parseJsonlEvent) { - const parser = createCliJsonlStreamingParser({ - backend: params.backend, - providerId: params.providerId, - parseJsonlEvent: params.parseJsonlEvent, - onAssistantDelta: () => {}, - }); - parser.push(params.raw); - parser.finish(); - parsed = parser.getOutput(); - } else { - parsed = parseCliJsonl(params.raw, params.backend, params.providerId); - } + const parser = createCliJsonlStreamingParser({ + backend: params.backend, + providerId: params.providerId, + parseJsonlEvent: params.parseJsonlEvent, + onAssistantDelta: () => {}, + }); + parser.push(params.raw); + parser.finish(); + const parsed = parser.getOutput(); if (parsed) { return parsed; } @@ -2306,10 +90,9 @@ export function parseCliOutput(params: { ); } -/** Extracts the most specific structured CLI error message from mixed or JSON output. */ /** Extracts a human-readable error message from mixed CLI stderr/stdout text. */ export function extractCliErrorMessage(raw: string): string | null { - const parsedRecords = parseJsonRecordCandidates(raw); + const parsedRecords = decodeCliRecords(raw); if (parsedRecords.length === 0) { return null; } @@ -2324,4 +107,3 @@ export function extractCliErrorMessage(raw: string): string | null { return errorText || null; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index 99840b46c596..6c1decf37584 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -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"; diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 88f7d630b44b..3644364d6c4c 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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 { diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index 01ba10d640ec..f4fb1fa405ae 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -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, diff --git a/src/agents/cli-runner/delivery-evidence.ts b/src/agents/cli-runner/delivery-evidence.ts index 324b880a8cfb..0d8f55ca1e3b 100644 --- a/src/agents/cli-runner/delivery-evidence.ts +++ b/src/agents/cli-runner/delivery-evidence.ts @@ -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"; diff --git a/src/agents/cli-runner/execute-events.ts b/src/agents/cli-runner/execute-events.ts index 2fe6e3f148a8..ae76b463bf51 100644 --- a/src/agents/cli-runner/execute-events.ts +++ b/src/agents/cli-runner/execute-events.ts @@ -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, diff --git a/src/agents/cli-runner/execute-process.ts b/src/agents/cli-runner/execute-process.ts index ceaaa566bcbe..65af7575a6cd 100644 --- a/src/agents/cli-runner/execute-process.ts +++ b/src/agents/cli-runner/execute-process.ts @@ -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.", diff --git a/src/agents/cli-runner/execute-tool-tracking.ts b/src/agents/cli-runner/execute-tool-tracking.ts index 7ddc83202899..cf6907eb19f4 100644 --- a/src/agents/cli-runner/execute-tool-tracking.ts +++ b/src/agents/cli-runner/execute-tool-tracking.ts @@ -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, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 8981cbb7654d..240f2644eda7 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -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, diff --git a/src/agents/cli-runner/model-call-diagnostics.test.ts b/src/agents/cli-runner/model-call-diagnostics.test.ts index ff531a962dc1..35df06e8b861 100644 --- a/src/agents/cli-runner/model-call-diagnostics.test.ts +++ b/src/agents/cli-runner/model-call-diagnostics.test.ts @@ -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"; diff --git a/src/agents/cli-runner/model-call-diagnostics.ts b/src/agents/cli-runner/model-call-diagnostics.ts index 5c668c4156f0..fb24af8935c4 100644 --- a/src/agents/cli-runner/model-call-diagnostics.ts +++ b/src/agents/cli-runner/model-call-diagnostics.ts @@ -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"; diff --git a/src/agents/cli-runner/output-error.ts b/src/agents/cli-runner/output-error.ts index 0c74d029a885..718f37475392 100644 --- a/src/agents/cli-runner/output-error.ts +++ b/src/agents/cli-runner/output-error.ts @@ -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"; diff --git a/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts b/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts index fe123a51d78a..68013d50bd18 100644 --- a/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts @@ -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 {