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

@ -8,26 +8,24 @@ import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
describe("gemini_local parser", () => {
it("extracts session, summary, usage, cost, and terminal error message", () => {
it("extracts session, summary, usage, cost, and terminal error message from v0.38 stream-json output", () => {
const 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" }],
},
type: "message",
role: "assistant",
content: "hello",
}),
JSON.stringify({
type: "result",
subtype: "success",
status: "success",
session_id: "gemini-session-1",
usage: {
promptTokenCount: 12,
cachedContentTokenCount: 3,
candidatesTokenCount: 7,
stats: {
input_tokens: 12,
cached_input_tokens: 3,
output_tokens: 7,
},
total_cost_usd: 0.00123,
result: "done",
}),
JSON.stringify({ type: "error", message: "model access denied" }),
].join("\n");
@ -105,44 +103,34 @@ describe("gemini_local turn-limit detection", () => {
});
describe("gemini_local ui stdout parser", () => {
it("parses assistant, thinking, and result events", () => {
it("parses v0.38 assistant message and result events", () => {
const ts = "2026-03-08T00:00:00.000Z";
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I checked the repo." },
{ type: "thinking", text: "Reviewing adapter registry" },
{ type: "tool_call", name: "shell", input: { command: "ls -1" } },
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
],
},
type: "message",
role: "assistant",
content: "I checked the repo.",
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I checked the repo." },
{ kind: "thinking", ts, text: "Reviewing adapter registry" },
{ kind: "tool_call", ts, name: "shell", input: { command: "ls -1" } },
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
]);
expect(
parseGeminiStdoutLine(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
status: "success",
text: "Done",
stats: {
input_tokens: 10,
output_tokens: 5,
cached_input_tokens: 2,
},
total_cost_usd: 0.00042,
is_error: false,
}),
ts,
),
@ -168,7 +156,7 @@ function stripAnsi(value: string): string {
}
describe("gemini_local cli formatter", () => {
it("prints init, assistant, result, and error events", () => {
it("prints init, v0.38 assistant, result, and error events", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
let joined = "";
@ -179,19 +167,20 @@ describe("gemini_local cli formatter", () => {
);
printGeminiStreamEvent(
JSON.stringify({
type: "assistant",
message: { content: [{ type: "output_text", text: "hello" }] },
type: "message",
role: "assistant",
content: "hello",
}),
false,
);
printGeminiStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
usage: {
promptTokenCount: 10,
candidatesTokenCount: 5,
cachedContentTokenCount: 2,
status: "success",
stats: {
input_tokens: 10,
output_tokens: 5,
cached_input_tokens: 2,
},
total_cost_usd: 0.00042,
}),