a5430f010d
## 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
304 lines
9.9 KiB
TypeScript
304 lines
9.9 KiB
TypeScript
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
|
|
|
function collectMessageText(message: unknown): string[] {
|
|
if (typeof message === "string") {
|
|
const trimmed = message.trim();
|
|
return trimmed ? [trimmed] : [];
|
|
}
|
|
|
|
const record = parseObject(message);
|
|
const direct = asString(record.text, "").trim();
|
|
const lines: string[] = direct ? [direct] : [];
|
|
const content = Array.isArray(record.content) ? record.content : [];
|
|
|
|
for (const partRaw of content) {
|
|
const part = parseObject(partRaw);
|
|
const type = asString(part.type, "").trim();
|
|
if (type === "output_text" || type === "text" || type === "content") {
|
|
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
|
|
if (text) lines.push(text);
|
|
}
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
function readSessionId(event: Record<string, unknown>): string | null {
|
|
return (
|
|
asString(event.session_id, "").trim() ||
|
|
asString(event.sessionId, "").trim() ||
|
|
asString(event.sessionID, "").trim() ||
|
|
asString(event.checkpoint_id, "").trim() ||
|
|
asString(event.thread_id, "").trim() ||
|
|
null
|
|
);
|
|
}
|
|
|
|
function asErrorText(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
const rec = parseObject(value);
|
|
const message =
|
|
asString(rec.message, "") ||
|
|
asString(rec.error, "") ||
|
|
asString(rec.code, "") ||
|
|
asString(rec.detail, "");
|
|
if (message) return message;
|
|
try {
|
|
return JSON.stringify(rec);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function accumulateUsage(
|
|
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
|
|
usageRaw: unknown,
|
|
) {
|
|
const usage = parseObject(usageRaw);
|
|
const usageMetadata = parseObject(usage.usageMetadata);
|
|
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
|
|
|
|
target.inputTokens += asNumber(
|
|
source.input_tokens,
|
|
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
|
|
);
|
|
target.cachedInputTokens += asNumber(
|
|
source.cached_input_tokens,
|
|
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
|
);
|
|
target.outputTokens += asNumber(
|
|
source.output_tokens,
|
|
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
|
|
);
|
|
}
|
|
|
|
export function parseGeminiJsonl(stdout: string) {
|
|
let sessionId: string | null = null;
|
|
const messages: string[] = [];
|
|
let errorMessage: string | null = null;
|
|
let costUsd: number | null = null;
|
|
let resultEvent: Record<string, unknown> | null = null;
|
|
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
|
const usage = {
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
};
|
|
|
|
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line) continue;
|
|
|
|
const event = parseJson(line);
|
|
if (!event) continue;
|
|
|
|
const foundSessionId = readSessionId(event);
|
|
if (foundSessionId) sessionId = foundSessionId;
|
|
|
|
const type = asString(event.type, "").trim();
|
|
|
|
if (type === "assistant") {
|
|
messages.push(...collectMessageText(event.message));
|
|
const messageObj = parseObject(event.message);
|
|
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
|
for (const partRaw of content) {
|
|
const part = parseObject(partRaw);
|
|
if (asString(part.type, "").trim() === "question") {
|
|
question = {
|
|
prompt: asString(part.prompt, "").trim(),
|
|
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
|
const choice = parseObject(choiceRaw);
|
|
return {
|
|
key: asString(choice.key, "").trim(),
|
|
label: asString(choice.label, "").trim(),
|
|
description: asString(choice.description, "").trim() || undefined,
|
|
};
|
|
}),
|
|
};
|
|
break; // only one question per message
|
|
}
|
|
}
|
|
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);
|
|
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";
|
|
if (isError) {
|
|
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
|
if (text) errorMessage = text;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (type === "error") {
|
|
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
|
if (text) errorMessage = text;
|
|
continue;
|
|
}
|
|
|
|
if (type === "system") {
|
|
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
|
if (subtype === "error") {
|
|
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
|
if (text) errorMessage = text;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (type === "text") {
|
|
const part = parseObject(event.part);
|
|
const text = asString(part.text, "").trim();
|
|
if (text) messages.push(text);
|
|
continue;
|
|
}
|
|
|
|
if (type === "step_finish" || event.usage || event.usageMetadata) {
|
|
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
|
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return {
|
|
sessionId,
|
|
summary: messages.join("\n\n").trim(),
|
|
usage,
|
|
costUsd,
|
|
errorMessage,
|
|
resultEvent,
|
|
question,
|
|
};
|
|
}
|
|
|
|
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
|
|
const haystack = `${stdout}\n${stderr}`
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
|
|
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
|
|
haystack,
|
|
);
|
|
}
|
|
|
|
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
|
|
const messages: string[] = [];
|
|
const errorMsg = asString(parsed.error, "").trim();
|
|
if (errorMsg) messages.push(errorMsg);
|
|
|
|
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
for (const entry of raw) {
|
|
if (typeof entry === "string") {
|
|
const msg = entry.trim();
|
|
if (msg) messages.push(msg);
|
|
continue;
|
|
}
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
const obj = entry as Record<string, unknown>;
|
|
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
|
if (msg) {
|
|
messages.push(msg);
|
|
continue;
|
|
}
|
|
try {
|
|
messages.push(JSON.stringify(obj));
|
|
} catch {
|
|
// skip non-serializable entry
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
|
|
const status = asString(parsed.status, "");
|
|
const errors = extractGeminiErrorMessages(parsed);
|
|
|
|
const detail = errors[0] ?? "";
|
|
const parts = ["Gemini run failed"];
|
|
if (status) parts.push(`status=${status}`);
|
|
if (detail) parts.push(detail);
|
|
return parts.length > 1 ? parts.join(": ") : null;
|
|
}
|
|
|
|
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
|
|
const GEMINI_QUOTA_EXHAUSTED_RE =
|
|
/(?:resource_exhausted|quota|rate[-\s]?limit|too many requests|\b429\b|billing details)/i;
|
|
|
|
export function detectGeminiAuthRequired(input: {
|
|
parsed: Record<string, unknown> | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}): { requiresAuth: boolean } {
|
|
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
|
const messages = [...errors, input.stdout, input.stderr]
|
|
.join("\n")
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
|
|
return { requiresAuth };
|
|
}
|
|
|
|
export function detectGeminiQuotaExhausted(input: {
|
|
parsed: Record<string, unknown> | null;
|
|
stdout: string;
|
|
stderr: string;
|
|
}): { exhausted: boolean } {
|
|
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
|
const messages = [...errors, input.stdout, input.stderr]
|
|
.join("\n")
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
const exhausted = messages.some((line) => GEMINI_QUOTA_EXHAUSTED_RE.test(line));
|
|
return { exhausted };
|
|
}
|
|
|
|
export function isGeminiTurnLimitResult(
|
|
parsed: Record<string, unknown> | null | undefined,
|
|
exitCode?: number | null,
|
|
): boolean {
|
|
if (exitCode === 53) return true;
|
|
if (!parsed) return false;
|
|
|
|
const structuredStopReasons = [
|
|
parsed.status,
|
|
parsed.stopReason,
|
|
parsed.stop_reason,
|
|
parsed.errorCode,
|
|
parsed.error_code,
|
|
].map((value) => asString(value, "").trim().toLowerCase());
|
|
|
|
return structuredStopReasons.some((reason) =>
|
|
reason === "turn_limit" ||
|
|
reason === "max_turns" ||
|
|
reason === "max_turns_exhausted" ||
|
|
reason === "turn_limit_exhausted",
|
|
);
|
|
}
|