mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 06:00:43 +00:00
* 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
386 lines
9.6 KiB
TypeScript
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 },
|
|
]);
|
|
});
|
|
});
|