forked from farhoodlabs/paperclip
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:
@@ -195,7 +195,7 @@ function readSessionId(parsed: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
function readUsage(parsed: Record<string, unknown>) {
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata) ?? asRecord(parsed.stats);
|
||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
||||
const source = usageMetadata ?? usage ?? {};
|
||||
return {
|
||||
@@ -203,7 +203,7 @@ function readUsage(parsed: Record<string, unknown>) {
|
||||
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
||||
cachedTokens: asNumber(
|
||||
source.cached_input_tokens,
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, asNumber(source.cached))),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -237,6 +237,19 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
return collectTextEntries(parsed.message, ts, "user");
|
||||
}
|
||||
|
||||
// Gemini CLI v0.38+ stream-json schema:
|
||||
// {"type":"message","role":"assistant"|"user","content":"...","delta":?true}
|
||||
if (type === "message") {
|
||||
const role = asString(parsed.role).trim().toLowerCase();
|
||||
if (role === "assistant") {
|
||||
return parseAssistantMessage(parsed.content, ts);
|
||||
}
|
||||
if (role === "user") {
|
||||
return collectTextEntries(parsed.content, ts, "user");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (type === "thinking") {
|
||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
||||
@@ -248,7 +261,10 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
|
||||
if (type === "result") {
|
||||
const usage = readUsage(parsed);
|
||||
const errors = parsed.is_error === true
|
||||
const status = asString(parsed.status).toLowerCase();
|
||||
const isError =
|
||||
parsed.is_error === true || status === "error" || status === "failed";
|
||||
const errors = isError
|
||||
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
||||
: [];
|
||||
return [{
|
||||
@@ -259,8 +275,8 @@ export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry
|
||||
outputTokens: usage.outputTokens,
|
||||
cachedTokens: usage.cachedTokens,
|
||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
||||
subtype: asString(parsed.subtype, "result"),
|
||||
isError: parsed.is_error === true,
|
||||
subtype: asString(parsed.subtype, status || "result"),
|
||||
isError,
|
||||
errors,
|
||||
}];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user