From a5430f010d16e249e4e24334a891f19163df942e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 18:36:50 -0700 Subject: [PATCH] Handle Gemini assistant message events in JSONL parser (#5143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, including agents > running the Gemini CLI (`gemini-local` adapter) > - The Gemini CLI emits a JSONL event stream during a run that the adapter > parses to extract the assistant's response text, tool results, and usage > - Recent versions of the Gemini CLI emit assistant responses as > `{ "type": "message", "role": "assistant", "content": ... }` events in > addition to the previously-handled event shapes > - The parser was not handling the new event type, so the assistant's actual > response text was being silently dropped from parsed output. Callers ended > up with empty assistant messages even when Gemini had successfully > responded > - This PR teaches the parser to recognize `{type: "message", role: > "assistant"}` events and extract their content text via the same > `collectMessageText` helper used for other message-shaped events > - The benefit is that Gemini runs surface the assistant's real response in > downstream consumers (issue comments, run logs, downstream agent context) > instead of vanishing ## What Changed - `packages/adapters/gemini-local/src/server/parse.ts`: in `parseGeminiJsonl(...)`, add a branch for `event.type === "message"` with `role === "assistant"` that calls `messages.push(...collectMessageText(event.content))`. - `packages/adapters/gemini-local/src/server/parse.test.ts`: ~19 lines of coverage for the new branch. ## Verification - `pnpm --filter @paperclipai/adapter-gemini-local test -- parse` - Manual QA: run a Gemini agent on an issue, confirm the assistant's response appears as the issue comment / run output. Before this fix the comment was empty even when the run completed successfully. ## Risks - Tightly scoped: 8 lines of production code in one parser branch. No effect on existing event shapes or other adapters. - If the Gemini CLI changes its event schema again, this branch may need to be revisited — but adding it is strictly additive over current behaviour. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## 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 - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../gemini-local/src/server/parse.test.ts | 46 +++++++++++++++++++ .../adapters/gemini-local/src/server/parse.ts | 13 ++++++ 2 files changed, 59 insertions(+) create mode 100644 packages/adapters/gemini-local/src/server/parse.test.ts diff --git a/packages/adapters/gemini-local/src/server/parse.test.ts b/packages/adapters/gemini-local/src/server/parse.test.ts new file mode 100644 index 00000000..e50f7795 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/parse.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { parseGeminiJsonl } from "./parse.js"; + +describe("parseGeminiJsonl", () => { + it("collects assistant text from message events with string content", () => { + const stdout = [ + '{"type":"init","session_id":"session-1"}', + '{"type":"message","role":"user","content":"Respond with hello."}', + '{"type":"message","role":"assistant","content":"hello","delta":true}', + '{"type":"result","status":"success"}', + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + + expect(parsed.sessionId).toBe("session-1"); + expect(parsed.summary).toBe("hello"); + expect(parsed.errorMessage).toBeNull(); + }); + + it("collects assistant text from message events with structured object content", () => { + const stdout = [ + '{"type":"init","session_id":"session-2"}', + '{"type":"message","role":"assistant","content":{"content":[{"type":"text","text":"first part"},{"type":"text","text":"second part"}]}}', + '{"type":"result","status":"success"}', + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + + expect(parsed.sessionId).toBe("session-2"); + expect(parsed.summary).toBe("first part\n\nsecond part"); + expect(parsed.errorMessage).toBeNull(); + }); + + it("ignores non-assistant message events", () => { + const stdout = [ + '{"type":"message","role":"user","content":"hidden user input"}', + '{"type":"message","role":"system","content":"hidden system note"}', + '{"type":"message","role":"assistant","content":"visible response"}', + '{"type":"result","status":"success"}', + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + + expect(parsed.summary).toBe("visible response"); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts index a9d3df9e..423e4db4 100644 --- a/packages/adapters/gemini-local/src/server/parse.ts +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -121,6 +121,19 @@ export function parseGeminiJsonl(stdout: string) { continue; } + 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; + } + if (type === "result") { resultEvent = event; accumulateUsage(usage, event.usage ?? event.usageMetadata);