diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index e58e8cfbe649..81ac21f61455 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -258,7 +258,9 @@ Gemini CLI OAuth is shipped as part of the bundled `google` plugin. -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) diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 200eeb25e418..f0745797dd6e 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -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 diff --git a/docs/providers/google.md b/docs/providers/google.md index 0b315c8d0e68..bfc9de8ce672 100644 --- a/docs/providers/google.md +++ b/docs/providers/google.md @@ -435,11 +435,14 @@ WebSocket endpoint, sends the initial setup payload, and waits for - - When using the `google-gemini-cli` OAuth provider, OpenClaw normalizes - the CLI JSON output as follows: + + 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 diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index cb2f45c4a4ec..48654d22fec4 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -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 diff --git a/docs/reference/prompt-caching.md b/docs/reference/prompt-caching.md index b4060aadec77..5dc70fb8752e 100644 --- a/docs/reference/prompt-caching.md +++ b/docs/reference/prompt-caching.md @@ -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 diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 37c49da5a12a..1ed632617992 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -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`. diff --git a/extensions/google/cli-backend.ts b/extensions/google/cli-backend.ts index 84f5f13cdd08..f3db7ec66618 100644 --- a/extensions/google/cli-backend.ts +++ b/extensions/google/cli-backend.ts @@ -49,6 +49,9 @@ type PreparedGeminiCliExecution = { cleanup?: () => Promise; }; +type GeminiCliBackendConfig = CliBackendPlugin["config"]; +type GeminiCliOutputMode = NonNullable; + 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", diff --git a/extensions/google/setup-api.test.ts b/extensions/google/setup-api.test.ts index b3554a40489b..d2d16e72986f 100644 --- a/extensions/google/setup-api.test.ts +++ b/extensions/google/setup-api.test.ts @@ -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(); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index dc90f97f2d76..7aeb3dd2fb9d 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -225,6 +225,40 @@ function normalizeTestClaudeBackendConfig( }; } +function readTestGeminiOutput( + args: readonly string[] | undefined, +): NonNullable { + 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"); diff --git a/src/agents/cli-output.test.ts b/src/agents/cli-output.test.ts index 2af960f51e78..b7b96f5965a6 100644 --- a/src/agents/cli-output.test.ts +++ b/src/agents/cli-output.test.ts @@ -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: { diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 8b79d6614544..bb8ff3e15c08 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -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; + 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 | 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; diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index b9d00fc15b15..859cf50ea78a 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -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[]) => { diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index e753b1d6b566..fb752c96d0c0 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -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)}`, diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 8c766289f53f..798e344f66b9 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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). */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index fd845933ec8b..7aa6a06cf213 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -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(),