323b6220cc
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Local adapters turn Paperclip runs into unattended CLI invocations. > - The `gemini_local` adapter depends on Gemini CLI behaving non-interactively in headless worker sessions. > - When Gemini CLI falls back to browser-based auth, the process can stall at startup instead of producing stream-json output. > - The adapter should make headless intent explicit and turn missing auth into a classified, fast failure. > - This pull request hardens the Gemini child-process environment and parser classification around that failure mode. > - The benefit is that operators get actionable `gemini_auth_required` failures instead of silent hung runs. ## Linked Issues or Issue Description No exact public issue exists for this runtime stall. Related: #2344 covers a Gemini CLI adapter environment/auth probe failure. This PR addresses a different runtime path: unattended `gemini_local` executions that can stall when Gemini CLI attempts interactive browser auth in a headless session. Bug summary: - Adapter: `gemini_local` - Symptom: child Gemini CLI process starts but produces no stream-json output when auth requires interactive browser flow - Expected: unattended runs either produce stream-json output or fail quickly with a classified auth error - Actual: the run can hang at invocation until an external watchdog kills it - Scope: Gemini adapter process env, auth-required parsing, and adapter docs/tests Duplicate search performed: - `gh pr list --state all --search "gemini headless invocation stall"` found only this PR. - `gh pr list --state all --search "gemini NO_BROWSER NO_COLOR"` found only this PR. - `gh issue list --state all --search "gemini headless authentication"` found related issue #2344 but no exact duplicate. ## What Changed - Set a headless-safe Gemini invocation env at the final child-process boundary: `TERM=xterm-256color`, `COLORTERM=truecolor`, and `NO_BROWSER=1`. - Delete inherited `NO_COLOR` from the child env so Gemini CLI keeps color-capable terminal behavior when deciding whether it can run non-interactively. - Classify Gemini `FatalAuthenticationError: Manual authorization is required...` failures as `gemini_auth_required`. - Update Gemini adapter docs to describe the non-interactive `--prompt` path and headless env behavior. - Add/extend focused parser and remote execution tests for the auth-required and env invariants. ## Verification - `pnpm exec vitest run packages/adapters/gemini-local/src/server/parse.test.ts packages/adapters/gemini-local/src/server/execute.remote.test.ts` — 23 tests passed. - `pnpm --filter @paperclipai/adapter-gemini-local typecheck` — passed. - Local Gemini CLI probe confirmed `NO_BROWSER=1` turns the browser-auth stall into a fast auth error instead of an interactive wait. ## Risks Low risk. The change is limited to `gemini_local` invocation env, parsing, docs, and tests. It does not touch schemas, API routes, persisted data, or other adapters. Operational risk: environments that intentionally rely on `NO_COLOR` being inherited by Gemini child processes will no longer pass that variable through. That is intentional here because Gemini CLI auth/headless behavior should not depend on inherited color suppression. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-5 Codex via Codex CLI, with shell/file-editing tool use for repository inspection, code edits, tests, and GitHub PR maintenance. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [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 - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge /cc @codex — please review.
216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
detectGeminiAuthRequired,
|
|
isGeminiTransientNetworkError,
|
|
isGeminiSessionUnrecoverableError,
|
|
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");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
it("classifies non-interactive manual authorization failures as auth required", () => {
|
|
const result = detectGeminiAuthRequired({
|
|
parsed: null,
|
|
stdout: "",
|
|
stderr:
|
|
"Error authenticating: FatalAuthenticationError: Manual authorization is required but the current session is non-interactive.",
|
|
});
|
|
|
|
expect(result.requiresAuth).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("isGeminiSessionUnrecoverableError", () => {
|
|
it("matches 'unknown session'", () => {
|
|
expect(isGeminiSessionUnrecoverableError("", "Error: unknown session 'abc-123'")).toBe(true);
|
|
});
|
|
|
|
it("matches 'session ... not found'", () => {
|
|
expect(isGeminiSessionUnrecoverableError("", "Resumed session abc-123 not found on disk")).toBe(true);
|
|
});
|
|
|
|
it("matches 'exceeds the maximum number of tokens' (compression overflow)", () => {
|
|
const stderr =
|
|
'_ApiError: {"error":{"code":400,"message":"The input token count exceeds the maximum number of tokens allowed 1048576","status":"INVALID_ARGUMENT"}} at ChatCompressionService.compress';
|
|
expect(isGeminiSessionUnrecoverableError("", stderr)).toBe(true);
|
|
});
|
|
|
|
it("matches 'input token count exceeds'", () => {
|
|
expect(
|
|
isGeminiSessionUnrecoverableError("", "input token count exceeds maximum"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("does not match unrelated stderr", () => {
|
|
expect(isGeminiSessionUnrecoverableError("", "Some other error")).toBe(false);
|
|
});
|
|
|
|
it("does not match transient network errors (those go to isGeminiTransientNetworkError)", () => {
|
|
expect(
|
|
isGeminiSessionUnrecoverableError(
|
|
"",
|
|
"_GaxiosError: getaddrinfo ENOTFOUND oauth2.googleapis.com",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isGeminiTransientNetworkError", () => {
|
|
it("matches DNS failure on oauth2.googleapis.com", () => {
|
|
const stderr =
|
|
"_GaxiosError: request to https://oauth2.googleapis.com/token failed, reason: getaddrinfo ENOTFOUND oauth2.googleapis.com";
|
|
expect(isGeminiTransientNetworkError("", stderr)).toBe(true);
|
|
});
|
|
|
|
it("matches EAI_AGAIN", () => {
|
|
expect(
|
|
isGeminiTransientNetworkError("", "Error: getaddrinfo EAI_AGAIN sts.googleapis.com"),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("matches _UserRefreshClient ENOTFOUND", () => {
|
|
const stderr =
|
|
"at _UserRefreshClient.refreshTokenNoCache (.../google-auth-library/...)\n" +
|
|
" caused by: ENOTFOUND oauth2.googleapis.com";
|
|
expect(isGeminiTransientNetworkError("", stderr)).toBe(true);
|
|
});
|
|
|
|
it("does not match unrelated stderr", () => {
|
|
expect(isGeminiTransientNetworkError("", "Some other error")).toBe(false);
|
|
});
|
|
|
|
it("does not match unknown-session errors (those go to isGeminiSessionUnrecoverableError)", () => {
|
|
expect(
|
|
isGeminiTransientNetworkError("", "Error: unknown session 'abc-123'"),
|
|
).toBe(false);
|
|
});
|
|
});
|