Handle Gemini CLI v0.38 stream-json wire format across parser, UI, and CLI formatter (#5273)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Each agent uses an adapter that drives a CLI (Claude, Gemini, Codex,
etc.)
> - The Gemini adapter parses a JSONL transcript stream the CLI emits to
learn what the model said
> - Gemini CLI v0.38 changed the transcript shape: assistant text now
comes through `type=message` with `role`/`content` and terminal status
comes through `type=status` / `type=stats`
> - The existing parser was written against the older `type=assistant` /
`type=result` shape, so post-v0.38 outputs left the parsed summary empty
and downgraded the SSH hello probe to "unexpected output"
> - This pull request updates every Gemini consumer (server parser, UI
parser, CLI formatter) to accept the v0.38 shape while keeping the
legacy shape working
> - The benefit is the Gemini adapter handles current upstream output
without losing backward compatibility, with explicit test coverage for
both shapes

## What Changed

- `packages/adapters/gemini-local/src/server/parse.ts` recognizes
`type=message` events with role/content and stops downgrading them
- `packages/adapters/gemini-local/src/ui/parse-stdout.ts` mirrors the
parser changes for the live UI transcript
- `packages/adapters/gemini-local/src/cli/format-event.ts` formats the
new event shape correctly for CLI output
- `parse.test.ts` and `parse-stdout.test.ts` add v0.38 coverage;
`gemini-local-adapter.test.ts` and `execute.remote.test.ts` switch
happy-path fixtures to the current real wire format and keep dedicated
tests for the older schema

## Verification

- `pnpm vitest run --no-coverage --project
@paperclipai/adapter-gemini-local` — full suite passes including new
v0.38 cases and preserved legacy cases
- `pnpm typecheck` clean

## Risks

Low risk — additive event handling. Legacy event shape path is preserved
with its own tests, so existing fixtures continue to parse identically.

## Model Used

Claude Opus 4.7 (1M context)

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A (no UI)
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-05-05 08:00:14 -07:00 committed by GitHub
parent 3c73ed26b5
commit ea7f53fd7d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 253 additions and 60 deletions

View file

@ -19,13 +19,12 @@ const {
timedOut: false,
stdout: [
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-1", model: "gemini-2.5-pro" }),
JSON.stringify({ type: "assistant", message: { content: [{ type: "output_text", text: "hello" }] } }),
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
JSON.stringify({
type: "result",
subtype: "success",
status: "success",
session_id: "gemini-session-1",
usage: { promptTokenCount: 1, cachedContentTokenCount: 0, candidatesTokenCount: 1 },
result: "hello",
stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
}),
].join("\n"),
stderr: "",

View file

@ -43,4 +43,91 @@ describe("parseGeminiJsonl", () => {
expect(parsed.summary).toBe("visible response");
});
it("captures assistant text from gemini CLI v0.38 stream-json schema", () => {
const stdout = [
JSON.stringify({
type: "init",
timestamp: "2026-05-04T05:43:41.203Z",
session_id: "session-abc",
model: "auto-gemini-3",
}),
JSON.stringify({
type: "message",
timestamp: "2026-05-04T05:43:41.205Z",
role: "user",
content: "Respond with hello.",
}),
JSON.stringify({
type: "message",
timestamp: "2026-05-04T05:43:45.198Z",
role: "assistant",
content: "hello.",
delta: true,
}),
JSON.stringify({
type: "result",
timestamp: "2026-05-04T05:43:45.819Z",
status: "success",
stats: {
total_tokens: 9468,
input_tokens: 9095,
output_tokens: 29,
cached: 8132,
duration_ms: 4616,
},
}),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.summary).toBe("hello.");
expect(result.sessionId).toBe("session-abc");
expect(result.errorMessage).toBeNull();
expect(result.usage.inputTokens).toBe(9095);
expect(result.usage.outputTokens).toBe(29);
expect(result.usage.cachedInputTokens).toBe(8132);
});
it("ignores user messages and only collects assistant content", () => {
const stdout = [
JSON.stringify({ type: "message", role: "user", content: "ignore me" }),
JSON.stringify({ type: "message", role: "assistant", content: "first" }),
JSON.stringify({ type: "message", role: "assistant", content: "second" }),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.summary).toBe("first\n\nsecond");
});
it("preserves the legacy claude-style `assistant` event handler", () => {
const stdout = [
JSON.stringify({
type: "system",
subtype: "init",
session_id: "legacy-session",
}),
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "legacy hello" }] },
}),
JSON.stringify({ type: "result", subtype: "success", result: "legacy hello" }),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.summary).toBe("legacy hello");
expect(result.sessionId).toBe("legacy-session");
});
it("flags result events with status=error", () => {
const stdout = [
JSON.stringify({
type: "result",
status: "error",
error: "boom",
}),
].join("\n");
const result = parseGeminiJsonl(stdout);
expect(result.errorMessage).toBe("boom");
});
});

View file

@ -64,7 +64,10 @@ function accumulateUsage(
);
target.cachedInputTokens += asNumber(
source.cached_input_tokens,
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
asNumber(
source.cachedInputTokens,
asNumber(source.cachedContentTokenCount, asNumber(source.cached, 0)),
),
);
target.outputTokens += asNumber(
source.output_tokens,
@ -121,14 +124,14 @@ export function parseGeminiJsonl(stdout: string) {
continue;
}
// Gemini CLI v0.38+ stream-json schema emits assistant turns as:
// {"type":"message","role":"assistant","content":"...","delta":true}
// These are discrete final messages (one per assistant turn), not
// cumulative streaming tokens, so collecting all of them produces the
// expected concatenated turn-by-turn summary rather than duplicated text.
if (type === "message") {
const role = asString(event.role, "").trim().toLowerCase();
if (role === "assistant") {
// Mirror the assistant-event handling above: collect every assistant
// message including deltas. Gemini CLI emits these as discrete final
// messages (one per assistant turn), not as cumulative streaming
// tokens, so collecting all of them produces the expected concatenated
// turn-by-turn summary rather than duplicated text.
messages.push(...collectMessageText(event.content));
}
continue;
@ -136,14 +139,19 @@ export function parseGeminiJsonl(stdout: string) {
if (type === "result") {
resultEvent = event;
accumulateUsage(usage, event.usage ?? event.usageMetadata);
accumulateUsage(usage, event.usage ?? event.usageMetadata ?? event.stats);
const resultText =
asString(event.result, "").trim() ||
asString(event.text, "").trim() ||
asString(event.response, "").trim();
if (resultText && messages.length === 0) messages.push(resultText);
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
const status = asString(event.status, "").toLowerCase();
const isError =
event.is_error === true ||
asString(event.subtype, "").toLowerCase() === "error" ||
status === "error" ||
status === "failed";
if (isError) {
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
if (text) errorMessage = text;