Files
openclaw/src/agents/cli-output.test.ts
Tak Hoffman 7c09ba70ef fix(trace command): Improve trace raw diagnostics and trace command UX (#66089)
* improve trace raw diagnostics and command acks

* address trace review feedback

* avoid sync transcript reads in raw trace

* preserve raw cli output for trace

* gate trace emission at reply time

* reflect raw trace mode in status surfaces
2026-04-13 14:26:57 -05:00

386 lines
9.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
createCliJsonlStreamingParser,
extractCliErrorMessage,
parseCliJson,
parseCliJsonl,
} from "./cli-output.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 =
"Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going.";
const apiError = `API Error: 400 ${JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message,
},
request_id: "req_011CZqHuXhFetYCnr8325DQc",
})}`;
const result = extractCliErrorMessage(
[
JSON.stringify({ type: "system", subtype: "init", session_id: "session-api-error" }),
JSON.stringify({
type: "assistant",
message: {
model: "<synthetic>",
role: "assistant",
content: [{ type: "text", text: apiError }],
},
session_id: "session-api-error",
error: "unknown",
}),
JSON.stringify({
type: "result",
subtype: "success",
is_error: true,
result: apiError,
session_id: "session-api-error",
}),
].join("\n"),
);
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 },
]);
});
});