mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: parse Gemini CLI stream output
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user