diff --git a/test/helpers/providers/gemini.test.ts b/test/helpers/providers/gemini.test.ts index 421e00c64..a0f404e29 100644 --- a/test/helpers/providers/gemini.test.ts +++ b/test/helpers/providers/gemini.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from 'bun:test'; -import { parseGeminiStreamJson } from './gemini'; +import { parseGeminiStreamJson, resultFromGeminiStream } from './gemini'; -// Current CLI shape (gemini ≥ ~1.58): content + role, tokens under result.stats +// Current CLI shape (gemini ≥ ~0.11 / stream-json): content + role, tokens under result.stats +// Documented at https://geminicli.com/docs/cli/headless/ and gemini-cli PR #10883. const CURRENT_CLI_FIXTURE = [ '{"type":"init","timestamp":"2026-03-20T15:14:46.455Z","session_id":"test-session-123","model":"gemini-2.5-pro"}', '{"type":"message","role":"user","content":"Reply with exactly the word: PONG"}', @@ -93,3 +94,45 @@ describe('parseGeminiStreamJson', () => { expect(parseGeminiStreamJson(raw).modelUsed).toBe('from-result'); }); }); + +describe('resultFromGeminiStream (adapter post-CLI e2e)', () => { + test('current CLI fixture → success row with PONG + tokens (not $0)', () => { + const result = resultFromGeminiStream(CURRENT_CLI_FIXTURE, { durationMs: 42 }); + expect(result.error).toBeUndefined(); + expect(result.output).toBe('PONG'); + expect(result.tokens.input).toBe(24946); + expect(result.tokens.output).toBe(32); + expect(result.modelUsed).toBe('gemini-2.5-pro'); + expect(result.durationMs).toBe(42); + // Cost signal: non-zero tokens means estimateCost will not be $0. + expect(result.tokens.input + result.tokens.output).toBeGreaterThan(0); + }); + + test('legacy text-only success still works', () => { + const result = resultFromGeminiStream(LEGACY_FIXTURE, { durationMs: 10 }); + expect(result.error).toBeUndefined(); + expect(result.output).toBe('hello'); + expect(result.toolCalls).toBe(1); + }); + + test('empty exit-0 stream → error row, never silent $0 success (#2159)', () => { + const emptySuccess = [ + '{"type":"init","model":"gemini-2.5-pro"}', + '{"type":"message","role":"user","content":"hi"}', + '{"type":"result","status":"success","stats":{"input_tokens":0,"output_tokens":0}}', + ].join('\n'); + const result = resultFromGeminiStream(emptySuccess, { durationMs: 5 }); + expect(result.error).toBeDefined(); + expect(result.error!.code).toBe('unknown'); + expect(result.error!.reason).toContain('empty output'); + expect(result.output).toBe(''); + expect(result.modelUsed).toBe('gemini-2.5-pro'); + }); + + test('pre-fix bug shape (text field missing, only content) would have been empty — now succeeds', () => { + // This is the exact failure mode from #2159: content present, no text. + const result = resultFromGeminiStream(CURRENT_CLI_FIXTURE); + expect(result.error).toBeUndefined(); + expect(result.output).toBe('PONG'); + }); +}); diff --git a/test/helpers/providers/gemini.ts b/test/helpers/providers/gemini.ts index cf3ad333b..84b3059ab 100644 --- a/test/helpers/providers/gemini.ts +++ b/test/helpers/providers/gemini.ts @@ -62,6 +62,37 @@ export function parseGeminiStreamJson(raw: string): GeminiStreamParse { return { output, tokens: { input, output: out }, toolCalls, modelUsed }; } +/** + * Map a raw stream-json dump to a RunResult, including the empty-success + * hardening from #2159. Exported so adapter e2e can exercise the full + * post-CLI path without a live gemini binary. + */ +export function resultFromGeminiStream( + raw: string, + opts: { model?: string; durationMs?: number } = {}, +): RunResult { + const parsed = parseGeminiStreamJson(raw); + const modelUsed = parsed.modelUsed || opts.model || 'gemini-2.5-pro'; + const durationMs = opts.durationMs ?? 0; + if (!parsed.output.trim()) { + return { + output: '', + tokens: { input: 0, output: 0 }, + durationMs, + toolCalls: 0, + modelUsed, + error: { code: 'unknown', reason: 'empty output from gemini CLI (exit 0)' }, + }; + } + return { + output: parsed.output, + tokens: parsed.tokens, + durationMs, + toolCalls: parsed.toolCalls, + modelUsed, + }; +} + /** * Gemini adapter — wraps the `gemini` CLI. * @@ -83,9 +114,14 @@ export class GeminiAdapter implements ProviderAdapter { const newCfgDir = path.join(os.homedir(), '.gemini'); const newOauth = path.join(newCfgDir, 'oauth_creds.json'); const hasCfg = fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth); - const hasKey = !!process.env.GOOGLE_API_KEY; + // CLI accepts either name; Google AI Studio keys are usually GEMINI_API_KEY. + const hasKey = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY); if (!hasCfg && !hasKey) { - return { ok: false, reason: 'No Gemini auth found. Log in via `gemini login` or export GOOGLE_API_KEY.' }; + return { + ok: false, + reason: + 'No Gemini auth found. Export GEMINI_API_KEY (or GOOGLE_API_KEY) from https://aistudio.google.com/app/apikey — personal OAuth free-tier is no longer supported by gemini CLI.', + }; } return { ok: true }; } @@ -104,25 +140,15 @@ export class GeminiAdapter implements ProviderAdapter { timeout: opts.timeoutMs, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024, + env: { + ...process.env, + // Prefer GEMINI_API_KEY when only that is set (CLI reads both). + ...(process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY + ? { GOOGLE_API_KEY: process.env.GEMINI_API_KEY } + : {}), + }, }); - const parsed = parseGeminiStreamJson(out); - const modelUsed = parsed.modelUsed || opts.model || 'gemini-2.5-pro'; - // Empty-success is indistinguishable from a healthy cheap run in the - // comparison table — never report it as a $0 success row (#2159). - if (!parsed.output.trim()) { - return this.emptyResult( - Date.now() - start, - { code: 'unknown', reason: 'empty output from gemini CLI (exit 0)' }, - modelUsed, - ); - } - return { - output: parsed.output, - tokens: parsed.tokens, - durationMs: Date.now() - start, - toolCalls: parsed.toolCalls, - modelUsed, - }; + return resultFromGeminiStream(out, { model: opts.model, durationMs: Date.now() - start }); } catch (err: unknown) { const durationMs = Date.now() - start; const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string }; @@ -130,7 +156,7 @@ export class GeminiAdapter implements ProviderAdapter { if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') { return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model); } - if (/unauthorized|auth|login|api key/i.test(stderr)) { + if (/unauthorized|auth|login|api key|ineligibletier|no longer supported/i.test(stderr)) { return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model); } if (/rate[- ]?limit|429|quota/i.test(stderr)) {