fix: parse Gemini CLI stream output

This commit is contained in:
Shakker
2026-06-16 21:04:15 +01:00
committed by Shakker
parent 81c9bd3997
commit c6d7d85763
15 changed files with 922 additions and 36 deletions
+3 -1
View File
@@ -258,7 +258,9 @@ Gemini CLI OAuth is shipped as part of the bundled `google` plugin.
</Step>
</Steps>
Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`, with `stats.cached` normalized into OpenClaw `cacheRead`.
Gemini CLI uses `stream-json` by default. OpenClaw reads assistant stream
messages and normalizes `stats.cached` into `cacheRead`; legacy
`--output-format json` overrides still read reply text from `response`.
### Z.AI (GLM)
+19 -8
View File
@@ -287,8 +287,10 @@ load local files from plain paths.
## Inputs / outputs
- `output: "json"` (default) tries to parse JSON and extract text + session id.
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and
usage from `stats` when `usage` is missing or empty.
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage
from `stats` when `usage` is missing or empty. The bundled Gemini CLI default
uses `stream-json`, but old `--output-format json` overrides still use the
JSON parser.
- `output: "jsonl"` parses JSONL streams and extracts the final agent message plus session
identifiers when present.
- `output: "text"` treats stdout as the final response.
@@ -318,8 +320,11 @@ The bundled Anthropic plugin registers a default for `claude-cli`:
The bundled Google plugin also registers a default for `google-gemini-cli`:
- `command: "gemini"`
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
- `args: ["--skip-trust", "--output-format", "stream-json", "--prompt", "{prompt}"]`
- `resumeArgs: ["--skip-trust", "--resume", "{sessionId}", "--output-format", "stream-json", "--prompt", "{prompt}"]`
- `output: "jsonl"`
- `resumeOutput: "jsonl"`
- `jsonlDialect: "gemini-stream-json"`
- `imageArg: "@"`
- `imagePathScope: "workspace"`
- `modelArg: "--model"`
@@ -330,9 +335,13 @@ Prerequisite: the local Gemini CLI must be installed and available as
`gemini` on `PATH` (`brew install gemini-cli` or
`npm install -g @google/gemini-cli`).
Gemini CLI JSON notes:
Gemini CLI output notes:
- Reply text is read from the JSON `response` field.
- The default `stream-json` parser reads assistant `message` events, tool events,
final `result` usage, and fatal Gemini error events.
- If you override Gemini args to `--output-format json`, OpenClaw normalizes that
backend back to `output: "json"` and reads reply text from the JSON `response`
field.
- Usage falls back to `stats` when `usage` is absent or empty.
- `stats.cached` is normalized into OpenClaw `cacheRead`.
- If `stats.input` is missing, OpenClaw derives input tokens from
@@ -372,8 +381,10 @@ api.registerTextTransforms({
rewrites streamed assistant deltas and parsed final text before OpenClaw handles
its own control markers and channel delivery.
For CLIs that emit Claude Code stream-json compatible JSONL, set
`jsonlDialect: "claude-stream-json"` on that backend's config.
For CLIs that emit provider-specific JSONL events, set `jsonlDialect` on that
backend's config. Supported dialects are `claude-stream-json` for Claude
Code-compatible streams and `gemini-stream-json` for Gemini CLI `stream-json`
events.
## Native compaction ownership
+7 -4
View File
@@ -435,11 +435,14 @@ WebSocket endpoint, sends the initial setup payload, and waits for
</Accordion>
<Accordion title="Gemini CLI JSON usage notes">
When using the `google-gemini-cli` OAuth provider, OpenClaw normalizes
the CLI JSON output as follows:
<Accordion title="Gemini CLI usage notes">
When using the `google-gemini-cli` OAuth provider, OpenClaw uses Gemini
CLI `stream-json` output by default and normalizes usage from the final
`stats` payload. Legacy `--output-format json` overrides still use the
JSON parser.
- Reply text comes from the CLI JSON `response` field.
- Streamed reply text comes from assistant `message` events.
- For legacy JSON output, reply text comes from the CLI JSON `response` field.
- Usage falls back to `stats` when the CLI leaves `usage` empty.
- `stats.cached` is normalized into OpenClaw `cacheRead`.
- If `stats.input` is missing, OpenClaw derives input tokens from
+3 -3
View File
@@ -31,9 +31,9 @@ OpenClaw features that can generate provider usage or paid API calls.
- `/usage tokens` shows tokens only; subscription-style OAuth/token and CLI flows
still show tokens only unless that runtime supplies compatible usage metadata
and an explicit local price is configured.
- Gemini CLI note: when the CLI returns JSON output, OpenClaw reads usage from
`stats`, normalizes `stats.cached` into `cacheRead`, and derives input tokens
from `stats.input_tokens - stats.cached` when needed.
- Gemini CLI note: the default `stream-json` output and legacy JSON overrides
both read usage from `stats`, normalize `stats.cached` into `cacheRead`, and
derive input tokens from `stats.input_tokens - stats.cached` when needed.
Anthropic note: Anthropic staff told us OpenClaw-style Claude CLI usage is
allowed again, so OpenClaw treats Claude CLI reuse and `claude -p` usage as
+4 -3
View File
@@ -163,10 +163,11 @@ If the provider does not support this cache mode, `cacheRetention` has no effect
OpenClaw manages a provider-native `cachedContents` resource rather than
injecting cache markers into the request.
### Gemini CLI JSON usage
### Gemini CLI usage
- Gemini CLI JSON output can also surface cache hits through `stats.cached`;
OpenClaw maps that to `cacheRead`.
- Gemini CLI `stream-json` output can surface cache hits through `stats.cached`;
OpenClaw maps that to `cacheRead`. Legacy `--output-format json` overrides use
the same usage normalization.
- If the CLI omits a direct `stats.input` value, OpenClaw derives input tokens
from `stats.input_tokens - stats.cached`.
- This is usage normalization only. It does not mean OpenClaw is creating
+5 -3
View File
@@ -92,9 +92,11 @@ Usage surfaces normalize common provider-native field aliases before display.
For OpenAI-family Responses traffic, that includes both `input_tokens` /
`output_tokens` and `prompt_tokens` / `completion_tokens`, so transport-specific
field names do not change `/status`, `/usage`, or session summaries.
Gemini CLI JSON usage is normalized too: reply text comes from `response`, and
`stats.cached` maps to `cacheRead` with `stats.input_tokens - stats.cached`
used when the CLI omits an explicit `stats.input` field.
Gemini CLI usage is normalized too: the default `stream-json` parser reads
assistant `message` events, and `stats.cached` maps to `cacheRead` with
`stats.input_tokens - stats.cached` used when the CLI omits an explicit
`stats.input` field. Legacy JSON overrides still read reply text from
`response`.
For native OpenAI-family Responses traffic, WebSocket/SSE usage aliases are
normalized the same way, and totals fall back to normalized input + output when
`total_tokens` is missing or `0`.
+49 -3
View File
@@ -49,6 +49,9 @@ type PreparedGeminiCliExecution = {
cleanup?: () => Promise<void>;
};
type GeminiCliBackendConfig = CliBackendPlugin["config"];
type GeminiCliOutputMode = NonNullable<GeminiCliBackendConfig["output"]>;
function normalizeString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
@@ -403,6 +406,47 @@ async function prepareGeminiCliAuthHome(
return null;
}
function mapGeminiCliOutputFormat(value: string | undefined): GeminiCliOutputMode | undefined {
if (value === "stream-json") {
return "jsonl";
}
if (value === "json" || value === "text") {
return value;
}
return undefined;
}
function readGeminiCliOutputFormat(args: readonly string[] | undefined): GeminiCliOutputMode {
for (let index = 0; index < (args?.length ?? 0); index += 1) {
const arg = args?.[index];
if (arg === "--output-format" || arg === "-o") {
return mapGeminiCliOutputFormat(args?.[index + 1]) ?? "text";
}
const inline = arg?.startsWith("--output-format=")
? arg.slice("--output-format=".length)
: arg?.startsWith("-o=")
? arg.slice("-o=".length)
: undefined;
const mapped = mapGeminiCliOutputFormat(inline);
if (mapped) {
return mapped;
}
}
return "text";
}
function normalizeGeminiCliBackendConfig(config: GeminiCliBackendConfig): GeminiCliBackendConfig {
const output = readGeminiCliOutputFormat(config.args);
const resumeOutput = readGeminiCliOutputFormat(config.resumeArgs ?? config.args);
const usesStreamJson = output === "jsonl" || resumeOutput === "jsonl";
return {
...config,
output,
resumeOutput,
jsonlDialect: usesStreamJson ? "gemini-stream-json" : undefined,
};
}
export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
return {
id: "google-gemini-cli",
@@ -420,6 +464,7 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
bundleMcpMode: "gemini-system-settings",
nativeToolMode: "always-on",
authEpochMode: "profile-only",
normalizeConfig: normalizeGeminiCliBackendConfig,
prepareExecution: async (ctx) =>
await prepareGeminiCliAuthHome(
{
@@ -433,18 +478,19 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
),
config: {
command: "gemini",
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
args: ["--skip-trust", "--output-format", "stream-json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format",
"json",
"stream-json",
"--prompt",
"{prompt}",
],
output: "json",
output: "jsonl",
input: "arg",
jsonlDialect: "gemini-stream-json",
imageArg: "@",
imagePathScope: "workspace",
modelArg: "--model",
+42
View File
@@ -91,6 +91,48 @@ describe("google setup entry", () => {
});
});
describe("google gemini cli backend config", () => {
it("keeps legacy json output overrides on the json parser", () => {
const backend = buildGoogleGeminiCliBackend();
const normalized = backend.normalizeConfig?.({
...backend.config,
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format=json",
"--prompt",
"{prompt}",
],
});
expect(normalized?.output).toBe("json");
expect(normalized?.resumeOutput).toBe("json");
expect(normalized?.jsonlDialect).toBeUndefined();
});
it("keeps short stream-json output overrides on the jsonl parser", () => {
const backend = buildGoogleGeminiCliBackend();
const normalized = backend.normalizeConfig?.({
...backend.config,
args: ["--skip-trust", "-o", "stream-json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"-o=stream-json",
"--prompt",
"{prompt}",
],
});
expect(normalized?.output).toBe("jsonl");
expect(normalized?.resumeOutput).toBe("jsonl");
expect(normalized?.jsonlDialect).toBe("gemini-stream-json");
});
});
describe("google gemini cli backend auth bridge", () => {
it("materializes selected OpenClaw OAuth credentials into a persistent profile-scoped Gemini CLI home", async () => {
const backend = buildGoogleGeminiCliBackend();
+108 -5
View File
@@ -225,6 +225,40 @@ function normalizeTestClaudeBackendConfig(
};
}
function readTestGeminiOutput(
args: readonly string[] | undefined,
): NonNullable<CliBackendConfig["output"]> {
for (let index = 0; index < (args?.length ?? 0); index += 1) {
const arg = args?.[index];
const value =
arg === "--output-format" || arg === "-o"
? args?.[index + 1]
: arg?.startsWith("--output-format=")
? arg.slice("--output-format=".length)
: arg?.startsWith("-o=")
? arg.slice("-o=".length)
: undefined;
if (value === "stream-json") {
return "jsonl";
}
if (value === "json" || value === "text") {
return value;
}
}
return "text";
}
function normalizeTestGeminiBackendConfig(config: CliBackendConfig): CliBackendConfig {
const output = readTestGeminiOutput(config.args);
const resumeOutput = readTestGeminiOutput(config.resumeArgs ?? config.args);
return {
...config,
output,
resumeOutput,
jsonlDialect: output === "jsonl" || resumeOutput === "jsonl" ? "gemini-stream-json" : undefined,
};
}
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
});
@@ -345,18 +379,21 @@ beforeEach(() => {
bundleMcpMode: "gemini-system-settings",
authEpochMode: "profile-only",
prepareExecution: async () => null,
normalizeConfig: normalizeTestGeminiBackendConfig,
config: {
command: "gemini",
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
args: ["--skip-trust", "--output-format", "stream-json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format",
"json",
"stream-json",
"--prompt",
"{prompt}",
],
output: "jsonl",
jsonlDialect: "gemini-stream-json",
imageArg: "@",
imagePathScope: "workspace",
modelArg: "--model",
@@ -937,7 +974,7 @@ describe("resolveCliBackendConfig claude-cli defaults", () => {
});
describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
it("uses Gemini CLI json args and existing-session resume mode", () => {
it("uses Gemini CLI stream-json args and existing-session resume mode", () => {
const resolved = requireCliBackendConfig("google-gemini-cli");
expect(resolved?.bundleMcp).toBe(true);
@@ -947,7 +984,7 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
expect(resolved?.config.args).toEqual([
"--skip-trust",
"--output-format",
"json",
"stream-json",
"--prompt",
"{prompt}",
]);
@@ -956,16 +993,82 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
"--resume",
"{sessionId}",
"--output-format",
"json",
"stream-json",
"--prompt",
"{prompt}",
]);
expect(resolved?.config.output).toBe("jsonl");
expect(resolved?.config.resumeOutput).toBe("jsonl");
expect(resolved?.config.jsonlDialect).toBe("gemini-stream-json");
expect(resolved?.config.modelArg).toBe("--model");
expect(resolved?.config.sessionMode).toBe("existing");
expect(resolved?.config.sessionIdFields).toEqual(["session_id", "sessionId"]);
expect(resolved?.config.modelAliases?.pro).toBe("gemini-3.1-pro-preview");
});
it("keeps legacy Gemini CLI json arg overrides on the json parser", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"google-gemini-cli": {
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"--output-format=json",
"--prompt",
"{prompt}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = requireCliBackendConfig("google-gemini-cli", cfg);
expect(resolved?.config.args).toEqual([
"--skip-trust",
"--output-format",
"json",
"--prompt",
"{prompt}",
]);
expect(resolved?.config.output).toBe("json");
expect(resolved?.config.resumeOutput).toBe("json");
expect(resolved?.config.jsonlDialect).toBeUndefined();
});
it("keeps Gemini CLI short stream-json arg overrides on the jsonl parser", () => {
const cfg = {
agents: {
defaults: {
cliBackends: {
"google-gemini-cli": {
args: ["--skip-trust", "-o", "stream-json", "--prompt", "{prompt}"],
resumeArgs: [
"--skip-trust",
"--resume",
"{sessionId}",
"-o=stream-json",
"--prompt",
"{prompt}",
],
},
},
},
},
} satisfies OpenClawConfig;
const resolved = requireCliBackendConfig("google-gemini-cli", cfg);
expect(resolved?.config.output).toBe("jsonl");
expect(resolved?.config.resumeOutput).toBe("jsonl");
expect(resolved?.config.jsonlDialect).toBe("gemini-stream-json");
});
it("uses Codex CLI bundle MCP config overrides", () => {
const resolved = requireCliBackendConfig("codex-cli");
+442
View File
@@ -6,6 +6,7 @@ import {
parseCliJson,
parseCliJsonl,
supportsCliJsonlToolEvents,
type CliToolResultDelta,
type CliToolUseStartDelta,
} from "./cli-output.js";
import { createClaudeApiErrorFixture } from "./test-helpers/claude-api-error-fixture.js";
@@ -19,6 +20,13 @@ describe("supportsCliJsonlToolEvents", () => {
"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);
@@ -303,6 +311,250 @@ describe("parseCliJsonl", () => {
});
});
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(
[
@@ -535,6 +787,196 @@ describe("createCliJsonlStreamingParser", () => {
]);
});
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: {
+176 -2
View File
@@ -40,6 +40,7 @@ export type CliOutput = {
rawText?: string;
sessionId?: string;
usage?: CliUsage;
errorText?: string;
diagnostics?: {
process?: CliProcessDiagnostics;
};
@@ -80,13 +81,28 @@ function isClaudeCliProvider(providerId: string): boolean {
return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli";
}
/** Returns whether JSONL output carries correlated Claude-style tool events. */
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)
);
}
/** 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)
params.backend.jsonlDialect === "claude-stream-json" ||
isClaudeCliProvider(params.providerId) ||
isGeminiStreamJsonDialect(params)
);
}
@@ -652,6 +668,77 @@ function dispatchClaudeCliStreamingToolEvent(params: {
}
}
function dispatchGeminiCliStreamingToolEvent(params: {
backend: CliBackendConfig;
providerId: string;
parsed: Record<string, unknown>;
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, 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, unknown>): 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;
}
/** Creates a stateful parser for streaming JSONL CLI backend output. */
export function createCliJsonlStreamingParser(params: {
backend: CliBackendConfig;
@@ -715,6 +802,18 @@ export function createCliJsonlStreamingParser(params: {
if (shouldUseUsage) {
usage = nextUsage ?? usage;
}
const geminiErrorText = isGeminiStreamJsonDialect(params)
? readGeminiCliStreamJsonError(parsed)
: undefined;
if (geminiErrorText) {
output = {
text: "",
sessionId,
usage,
errorText: preferGeminiCliStreamJsonError(output?.errorText, geminiErrorText),
};
return;
}
if (classifyClaudeCommentary && parsed.type === "result") {
flushPendingClaudeAssistantText();
@@ -754,6 +853,14 @@ export function createCliJsonlStreamingParser(params: {
}
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,
@@ -773,6 +880,33 @@ export function createCliJsonlStreamingParser(params: {
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 (classifyClaudeCommentary) {
@@ -829,6 +963,9 @@ export function createCliJsonlStreamingParser(params: {
if (output) {
return output;
}
if (isGeminiStreamJsonDialect(params) && (assistantText.trim() || sessionId || usage)) {
return { text: assistantText.trim(), sessionId, usage };
}
const text = texts.join("\n").trim();
return text ? { text, sessionId, usage } : null;
},
@@ -849,6 +986,9 @@ export function parseCliJsonl(
let sessionId: string | undefined;
let usage: CliUsage | undefined;
const texts: string[] = [];
let geminiText = "";
let geminiErrorText: string | undefined;
let sawGeminiStructuredOutput = false;
for (const line of lines) {
for (const parsed of parseJsonRecordCandidates(line)) {
sessionId = pickCliSessionId(parsed, backend) ?? sessionId;
@@ -861,6 +1001,31 @@ export function parseCliJsonl(
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"
) {
geminiText = `${geminiText}${parsed.content}`;
sawGeminiStructuredOutput = true;
continue;
}
if (
parsed.type === "tool_use" ||
parsed.type === "tool_result" ||
parsed.type === "result"
) {
sawGeminiStructuredOutput = true;
}
}
const claudeResult = parseClaudeCliJsonlResult({
backend,
providerId,
@@ -881,6 +1046,15 @@ export function parseCliJsonl(
}
}
}
if (isGeminiStreamJsonDialect({ backend, providerId }) && geminiErrorText) {
return { text: "", sessionId, usage, errorText: geminiErrorText };
}
if (
isGeminiStreamJsonDialect({ backend, providerId }) &&
(sawGeminiStructuredOutput || sessionId || usage)
) {
return { text: geminiText.trim(), sessionId, usage };
}
const text = texts.join("\n").trim();
if (!text) {
return null;
+48 -2
View File
@@ -124,8 +124,9 @@ function buildPreparedCliRunContext(params: {
if (params.provider === "google-gemini-cli") {
return {
command: "gemini",
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
output: "json" as const,
args: ["--skip-trust", "--output-format", "stream-json", "--prompt", "{prompt}"],
output: "jsonl" as const,
jsonlDialect: "gemini-stream-json" as const,
input: "arg" as const,
modelArg: "--model",
sessionMode: "existing" as const,
@@ -906,6 +907,51 @@ describe("runCliAgent spawn path", () => {
});
});
it("rejects Gemini stream-json error results emitted with a zero exit code", async () => {
supervisorSpawnMock.mockResolvedValueOnce(
createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout:
[
JSON.stringify({
type: "message",
role: "assistant",
content: "partial text",
delta: true,
}),
JSON.stringify({
type: "result",
status: "error",
error: {
message: "Gemini stream failed",
},
}),
].join("\n") + "\n",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
);
await expectRejectsWithFields(
executePreparedCliRun(
buildPreparedCliRunContext({
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
runId: "run-gemini-stream-json-error",
}),
),
{
name: "FailoverError",
message: "Gemini stream failed",
reason: "unknown",
},
);
});
it("passes Codex system prompts through model_instructions_file", async () => {
let promptFileText = "";
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
+12
View File
@@ -1361,6 +1361,18 @@ export async function executePreparedCliRun(
outputMode,
fallbackSessionId: resolvedSessionId,
});
if (parsed.errorText) {
const reason =
classifyFailoverReason(parsed.errorText, { provider: params.provider }) ?? "unknown";
throw new FailoverError(parsed.errorText, {
reason,
provider: params.provider,
model: context.modelId,
sessionId: params.sessionId,
lane: params.lane,
status: resolveFailoverStatus(reason),
});
}
const rawText = parsed.text;
cliBackendLog.info(
`cli turn: provider=${params.provider} model=${context.modelId} durationMs=${Date.now() - cliTurnStartedAt} ${formatCliBackendOutputDigest(rawText)}`,
+1 -1
View File
@@ -135,7 +135,7 @@ export type CliBackendConfig = {
/** Output parsing mode when resuming a CLI session. */
resumeOutput?: "json" | "text" | "jsonl";
/** JSONL event dialect for CLIs with provider-specific stream formats. */
jsonlDialect?: "claude-stream-json";
jsonlDialect?: "claude-stream-json" | "gemini-stream-json";
/** Long-lived CLI process mode. */
liveSession?: "claude-stdio";
/** Prompt input mode (default: arg). */
+3 -1
View File
@@ -785,7 +785,9 @@ export const CliBackendSchema = z
args: z.array(z.string()).optional(),
output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(),
jsonlDialect: z.literal("claude-stream-json").optional(),
jsonlDialect: z
.union([z.literal("claude-stream-json"), z.literal("gemini-stream-json")])
.optional(),
liveSession: z.literal("claude-stdio").optional(),
input: z.union([z.literal("arg"), z.literal("stdin")]).optional(),
maxPromptArgChars: z.number().int().positive().optional(),