import { describe, expect, it } from "vitest"; import { createCliJsonlStreamingParser, extractCliErrorMessage, parseCliJson, parseCliJsonl, } from "./cli-output.js"; import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js"; describe("parseCliJson", () => { 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("parses Gemini CLI response text and stats payloads", () => { const result = parseCliJson( JSON.stringify({ session_id: "gemini-session-123", response: "Gemini says hello", stats: { total_tokens: 21, input_tokens: 13, output_tokens: 5, cached: 8, input: 5, }, }), { command: "gemini", output: "json", sessionIdFields: ["session_id"], }, ); expect(result).toEqual({ text: "Gemini says hello", sessionId: "gemini-session-123", usage: { input: 5, output: 5, cacheRead: 8, cacheWrite: undefined, total: 21, }, }); }); 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("falls back to Gemini stats when usage exists without token fields", () => { const result = parseCliJson( JSON.stringify({ session_id: "gemini-session-789", response: "Gemini says hello", usage: {}, stats: { total_tokens: 21, input_tokens: 13, output_tokens: 5, cached: 8, input: 5, }, }), { command: "gemini", output: "json", sessionIdFields: ["session_id"], }, ); expect(result).toEqual({ text: "Gemini says hello", sessionId: "gemini-session-789", usage: { input: 5, output: 5, cacheRead: 8, cacheWrite: undefined, total: 21, }, }); }); 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, }, }); }); }); describe("parseCliJsonl", () => { it("parses Claude stream-json result events", () => { const result = parseCliJsonl( [ JSON.stringify({ type: "init", session_id: "session-123" }), JSON.stringify({ type: "result", session_id: "session-123", result: "Claude says hello", usage: { input_tokens: 12, output_tokens: 3, cache_read_input_tokens: 4, }, }), ].join("\n"), { command: "claude", output: "jsonl", sessionIdFields: ["session_id"], }, "claude-cli", ); expect(result).toEqual({ text: "Claude says hello", sessionId: "session-123", usage: { input: 12, output: 3, cacheRead: 4, cacheWrite: undefined, total: undefined, }, }); }); it("parses Claude stream-json result events for an explicit backend dialect", () => { const result = parseCliJsonl( [ JSON.stringify({ type: "init", session_id: "session-dialect" }), JSON.stringify({ type: "result", session_id: "session-dialect", result: "dialect says hello", usage: { input_tokens: 5, output_tokens: 2 }, }), ].join("\n"), { command: "local-cli", output: "jsonl", jsonlDialect: "claude-stream-json", sessionIdFields: ["session_id"], }, "local-cli", ); expect(result).toMatchObject({ text: "dialect says hello", sessionId: "session-dialect", usage: { input: 5, output: 2 }, }); }); 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("preserves Claude session metadata even when the final result text is empty", () => { const result = parseCliJsonl( [ JSON.stringify({ type: "init", session_id: "session-456" }), JSON.stringify({ type: "result", session_id: "session-456", result: " ", usage: { input_tokens: 18, output_tokens: 0, }, }), ].join("\n"), { command: "claude", output: "jsonl", sessionIdFields: ["session_id"], }, "claude-cli", ); expect(result).toEqual({ text: "", sessionId: "session-456", usage: { input: 18, output: undefined, cacheRead: undefined, cacheWrite: undefined, total: undefined, }, }); }); it("parses multiple JSON objects embedded on the same line", () => { const result = parseCliJsonl( '{"type":"init","session_id":"session-999"} {"type":"result","session_id":"session-999","result":"done"}', { command: "claude", output: "jsonl", sessionIdFields: ["session_id"], }, "claude-cli", ); expect(result).toEqual({ text: "done", sessionId: "session-999", 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); }); }); describe("createCliJsonlStreamingParser", () => { it("streams Claude stream-json deltas for an explicit backend dialect", () => { const deltas: Array<{ text: string; delta: string; sessionId?: 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-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 }, ]); }); });