mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 07:03:33 +00:00
1622 lines
44 KiB
TypeScript
1622 lines
44 KiB
TypeScript
/** Tests CLI JSON/JSONL output parsing, streamed deltas, and error extraction. */
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createCliJsonlStreamingParser,
|
|
extractCliErrorMessage,
|
|
parseCliJson,
|
|
parseCliJsonl,
|
|
supportsCliJsonlToolEvents,
|
|
type CliToolResultDelta,
|
|
type CliToolUseStartDelta,
|
|
} from "./cli-output.js";
|
|
import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js";
|
|
|
|
describe("supportsCliJsonlToolEvents", () => {
|
|
it.each([
|
|
["Claude provider", { command: "claude", output: "jsonl" as const }, "claude-cli", true],
|
|
[
|
|
"explicit Claude dialect",
|
|
{ command: "custom", output: "jsonl" as const, jsonlDialect: "claude-stream-json" as const },
|
|
"custom-cli",
|
|
true,
|
|
],
|
|
["Gemini provider", { command: "gemini", output: "jsonl" as const }, "google-gemini-cli", true],
|
|
[
|
|
"explicit Gemini dialect",
|
|
{ command: "custom", output: "jsonl" as const, jsonlDialect: "gemini-stream-json" as const },
|
|
"custom-cli",
|
|
true,
|
|
],
|
|
["generic JSONL", { command: "custom", output: "jsonl" as const }, "custom-cli", false],
|
|
])("%s: %s", (_name, backend, providerId, expected) => {
|
|
expect(supportsCliJsonlToolEvents({ backend, providerId })).toBe(expected);
|
|
});
|
|
});
|
|
|
|
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("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,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
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).toEqual({
|
|
text: "dialect says hello",
|
|
sessionId: "session-dialect",
|
|
usage: {
|
|
input: 5,
|
|
output: 2,
|
|
cacheRead: undefined,
|
|
cacheWrite: undefined,
|
|
total: undefined,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("parses Gemini stream-json message and result events", () => {
|
|
const result = parseCliJsonl(
|
|
[
|
|
JSON.stringify({
|
|
type: "init",
|
|
timestamp: "2026-06-16T19:36:46.000Z",
|
|
session_id: "gemini-session-123",
|
|
model: "gemini-3.1-pro-preview",
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
timestamp: "2026-06-16T19:36:47.000Z",
|
|
role: "assistant",
|
|
content: "Gemini says ",
|
|
delta: true,
|
|
}),
|
|
JSON.stringify({
|
|
type: "message",
|
|
timestamp: "2026-06-16T19:36:48.000Z",
|
|
role: "assistant",
|
|
content: "hello",
|
|
delta: true,
|
|
}),
|
|
JSON.stringify({
|
|
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,
|
|
},
|
|
}),
|
|
].join("\n"),
|
|
{
|
|
command: "gemini",
|
|
output: "jsonl",
|
|
jsonlDialect: "gemini-stream-json",
|
|
sessionIdFields: ["session_id"],
|
|
},
|
|
"google-gemini-cli",
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
text: "Gemini says hello",
|
|
sessionId: "gemini-session-123",
|
|
usage: {
|
|
input: 5,
|
|
output: 5,
|
|
cacheRead: 8,
|
|
cacheWrite: undefined,
|
|
total: 21,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps Gemini tool-only stream-json output structured instead of raw JSONL", () => {
|
|
const result = parseCliJsonl(
|
|
[
|
|
JSON.stringify({
|
|
type: "init",
|
|
timestamp: "2026-06-16T19:36:46.000Z",
|
|
session_id: "gemini-session-123",
|
|
model: "gemini-3.1-pro-preview",
|
|
}),
|
|
JSON.stringify({
|
|
type: "tool_use",
|
|
timestamp: "2026-06-16T19:36:47.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:48.000Z",
|
|
tool_id: "tool-1",
|
|
status: "success",
|
|
output: "created",
|
|
}),
|
|
JSON.stringify({
|
|
type: "result",
|
|
timestamp: "2026-06-16T19:36:49.000Z",
|
|
status: "success",
|
|
stats: { total_tokens: 2, input_tokens: 1, output_tokens: 1 },
|
|
}),
|
|
].join("\n"),
|
|
{
|
|
command: "gemini",
|
|
output: "jsonl",
|
|
jsonlDialect: "gemini-stream-json",
|
|
sessionIdFields: ["session_id"],
|
|
},
|
|
"google-gemini-cli",
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
text: "",
|
|
sessionId: "gemini-session-123",
|
|
usage: {
|
|
input: 1,
|
|
output: 1,
|
|
cacheRead: undefined,
|
|
cacheWrite: undefined,
|
|
total: 2,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("parses Gemini stream-json result errors as provider errors", () => {
|
|
const result = parseCliJsonl(
|
|
[
|
|
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"),
|
|
{
|
|
command: "gemini",
|
|
output: "jsonl",
|
|
jsonlDialect: "gemini-stream-json",
|
|
},
|
|
"google-gemini-cli",
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
text: "",
|
|
sessionId: undefined,
|
|
usage: undefined,
|
|
errorText: "Gemini stream failed",
|
|
});
|
|
});
|
|
|
|
it("keeps detailed Gemini stream-json error events over generic result errors", () => {
|
|
const result = parseCliJsonl(
|
|
[
|
|
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"),
|
|
{
|
|
command: "gemini",
|
|
output: "jsonl",
|
|
jsonlDialect: "gemini-stream-json",
|
|
},
|
|
"google-gemini-cli",
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
text: "",
|
|
sessionId: undefined,
|
|
usage: {
|
|
input: undefined,
|
|
output: undefined,
|
|
cacheRead: undefined,
|
|
cacheWrite: undefined,
|
|
total: 1,
|
|
},
|
|
errorText: "Invalid stream payload",
|
|
});
|
|
});
|
|
|
|
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("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("unwraps nested Claude agent result JSON from stream-json output", () => {
|
|
const result = parseCliJsonl(
|
|
[
|
|
JSON.stringify({ type: "init", session_id: "session-nested-jsonl" }),
|
|
JSON.stringify({
|
|
type: "result",
|
|
session_id: "session-nested-jsonl",
|
|
result: JSON.stringify({
|
|
type: "result",
|
|
result: JSON.stringify({
|
|
type: "result",
|
|
subtype: "success",
|
|
result: "actual response text",
|
|
}),
|
|
}),
|
|
}),
|
|
].join("\n"),
|
|
{
|
|
command: "claude",
|
|
output: "jsonl",
|
|
sessionIdFields: ["session_id"],
|
|
},
|
|
"claude-cli",
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
text: "actual response text",
|
|
sessionId: "session-nested-jsonl",
|
|
usage: 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("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);
|
|
});
|
|
});
|
|
|
|
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 },
|
|
]);
|
|
});
|
|
|
|
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",
|
|
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 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,
|
|
});
|
|
});
|
|
|
|
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", args: { command: "ls -la" } }]);
|
|
expect(results).toEqual([
|
|
{ toolCallId: "toolu_1", name: "Bash", isError: false, result: "total 0\n" },
|
|
]);
|
|
});
|
|
|
|
it("reassembles streamed tool args from input_json_delta chunks", () => {
|
|
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: "tool_use", id: "toolu_chunked", name: "Bash", input: {} },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "input_json_delta", partial_json: '{"command":' },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "input_json_delta", partial_json: ' "echo hi"}' },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: { type: "content_block_stop", index: 0 },
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(starts).toEqual([
|
|
{ toolCallId: "toolu_chunked", name: "Bash", args: { command: "echo hi" } },
|
|
]);
|
|
});
|
|
|
|
it("emits empty args when streamed tool args are malformed", () => {
|
|
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: "tool_use", id: "toolu_bad", name: "Bash", input: {} },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
index: 0,
|
|
delta: { type: "input_json_delta", partial_json: '{"command": "ls' },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: { type: "content_block_stop", index: 0 },
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(starts).toEqual([{ toolCallId: "toolu_bad", name: "Bash", args: {} }]);
|
|
});
|
|
|
|
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", 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, 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("fires onCommentaryText with accumulated text before a tool_use block", () => {
|
|
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(
|
|
[
|
|
JSON.stringify({ type: "init", session_id: "session-commentary" }),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: "Let me check " },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: "that for you." },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_start",
|
|
index: 1,
|
|
content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} },
|
|
},
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(commentaryTexts).toEqual(["Let me check that for you."]);
|
|
expect(deltas).toEqual([]);
|
|
});
|
|
|
|
it("flushes Claude text as an assistant delta when no tool follows", () => {
|
|
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(
|
|
[
|
|
JSON.stringify({ type: "init", session_id: "session-answer" }),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: "Final " },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: "answer." },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "message_stop",
|
|
},
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(commentaryTexts).toEqual([]);
|
|
expect(deltas).toEqual([{ text: "Final answer.", delta: "Final answer." }]);
|
|
});
|
|
|
|
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("does not fire onCommentaryText when no text precedes tool_use", () => {
|
|
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(
|
|
[
|
|
JSON.stringify({ type: "init", session_id: "session-no-commentary" }),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_start",
|
|
index: 0,
|
|
content_block: { type: "tool_use", id: "toolu_1", name: "Bash", input: {} },
|
|
},
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(commentaryTexts).toEqual([]);
|
|
});
|
|
|
|
it("does not duplicate commentary when consecutive tool_use blocks have no new text", () => {
|
|
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(
|
|
[
|
|
JSON.stringify({ type: "init", session_id: "session-multi-commentary" }),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: "First, checking files." },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_start",
|
|
index: 1,
|
|
content_block: { type: "tool_use", id: "toolu_1", name: "Read", input: {} },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_start",
|
|
index: 2,
|
|
content_block: { type: "tool_use", id: "toolu_2", name: "Bash", input: {} },
|
|
},
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(commentaryTexts).toEqual(["First, checking files."]);
|
|
});
|
|
|
|
it("emits only the new segment on text-tool-text-tool sequences", () => {
|
|
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(
|
|
[
|
|
JSON.stringify({ type: "init", session_id: "session-segment" }),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: "Reading the file now." },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_start",
|
|
index: 1,
|
|
content_block: { type: "tool_use", id: "toolu_a", name: "Read", input: {} },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_delta",
|
|
delta: { type: "text_delta", text: " Now searching." },
|
|
},
|
|
}),
|
|
JSON.stringify({
|
|
type: "stream_event",
|
|
event: {
|
|
type: "content_block_start",
|
|
index: 3,
|
|
content_block: { type: "tool_use", id: "toolu_b", name: "Grep", input: {} },
|
|
},
|
|
}),
|
|
].join("\n") + "\n",
|
|
);
|
|
parser.finish();
|
|
|
|
expect(commentaryTexts).toEqual(["Reading the file now.", "Now searching."]);
|
|
});
|
|
});
|