diff --git a/src/cli/adapters/gemini-cli.ts b/src/cli/adapters/gemini-cli.ts index 59a7a1e6..eee17607 100644 --- a/src/cli/adapters/gemini-cli.ts +++ b/src/cli/adapters/gemini-cli.ts @@ -13,7 +13,7 @@ import type { PlatformAdapter } from '../types.js'; * Notification → observation (system events like ToolPermission) * * Agent: - * BeforeAgent → user-message (captures user prompt) + * BeforeAgent → session-init (initializes session, captures user prompt) * AfterAgent → observation (full agent response) * * Tool: diff --git a/src/cli/handlers/summarize.ts b/src/cli/handlers/summarize.ts index 25a1f227..ee99e378 100644 --- a/src/cli/handlers/summarize.ts +++ b/src/cli/handlers/summarize.ts @@ -18,6 +18,7 @@ import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-util import { logger } from '../../utils/logger.js'; import { extractLastMessage } from '../../shared/transcript-parser.js'; import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js'; +import { normalizePlatformSource } from '../../shared/platform-source.js'; const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT); const POLL_INTERVAL_MS = 500; @@ -66,13 +67,16 @@ export const summarizeHandler: EventHandler = { hasLastAssistantMessage: !!lastAssistantMessage }); + const platformSource = normalizePlatformSource(input.platform); + // 1. Queue summarize request — worker returns immediately with { status: 'queued' } const response = await workerHttpRequest('/api/sessions/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contentSessionId: sessionId, - last_assistant_message: lastAssistantMessage + last_assistant_message: lastAssistantMessage, + platformSource }), timeoutMs: SUMMARIZE_TIMEOUT_MS }); diff --git a/src/services/integrations/GeminiCliHooksInstaller.ts b/src/services/integrations/GeminiCliHooksInstaller.ts index 4ec749ab..e9aab464 100644 --- a/src/services/integrations/GeminiCliHooksInstaller.ts +++ b/src/services/integrations/GeminiCliHooksInstaller.ts @@ -80,7 +80,7 @@ const HOOK_TIMEOUT_MS = 10000; */ const GEMINI_EVENT_TO_INTERNAL_EVENT: Record = { 'SessionStart': 'context', - 'BeforeAgent': 'user-message', + 'BeforeAgent': 'session-init', 'AfterAgent': 'observation', 'BeforeTool': 'observation', 'AfterTool': 'observation', diff --git a/src/shared/transcript-parser.ts b/src/shared/transcript-parser.ts index d85995de..d255c0ee 100644 --- a/src/shared/transcript-parser.ts +++ b/src/shared/transcript-parser.ts @@ -3,7 +3,37 @@ import { logger } from '../utils/logger.js'; import { SYSTEM_REMINDER_REGEX } from '../utils/tag-stripping.js'; /** - * Extract last message of specified role from transcript JSONL file + * Detect whether a transcript file is in Gemini CLI JSON document format. + * + * Gemini CLI 0.37.0 writes a single JSON document with a top-level `messages` + * array instead of JSONL. Assistant entries use `type: "gemini"` rather than + * `type: "assistant"`. + * + * Example Gemini format: + * { "messages": [{ "type": "user", "content": "..." }, { "type": "gemini", "content": "..." }] } + * + * Claude Code format (JSONL): + * {"type":"assistant","message":{"content":[{"type":"text","text":"..."}]}} + */ +function isGeminiTranscriptFormat(content: string): { isGemini: true; messages: any[] } | { isGemini: false } { + try { + const parsed = JSON.parse(content); + if (parsed && Array.isArray(parsed.messages)) { + return { isGemini: true, messages: parsed.messages }; + } + } catch { + // Not a valid single JSON object — assume JSONL + } + return { isGemini: false }; +} + +/** + * Extract last message of specified role from transcript file. + * + * Supports two transcript formats: + * - JSONL (Claude Code): one JSON object per line, `type: "assistant"` or `type: "user"` + * - JSON document (Gemini CLI 0.37.0+): `{ messages: [{ type: "gemini"|"user", content: string }] }` + * * @param transcriptPath Path to transcript file * @param role 'user' or 'assistant' * @param stripSystemReminders Whether to remove tags (for assistant) @@ -24,6 +54,52 @@ export function extractLastMessage( return ''; } + // Gemini CLI 0.37.0 writes a JSON document rather than JSONL. + // Detect and handle it before falling through to the JSONL parser. + const geminiCheck = isGeminiTranscriptFormat(content); + if (geminiCheck.isGemini) { + return extractLastMessageFromGeminiTranscript(geminiCheck.messages, role, stripSystemReminders); + } + + return extractLastMessageFromJsonl(content, role, stripSystemReminders); +} + +/** + * Extract last message from Gemini CLI JSON document transcript. + * Maps `type: "gemini"` → assistant role; `type: "user"` → user role. + */ +function extractLastMessageFromGeminiTranscript( + messages: any[], + role: 'user' | 'assistant', + stripSystemReminders: boolean +): string { + // "gemini" entries are assistant turns; "user" entries are user turns + const geminiRole = role === 'assistant' ? 'gemini' : 'user'; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg?.type === geminiRole && typeof msg.content === 'string') { + let text = msg.content; + if (stripSystemReminders) { + text = text.replace(SYSTEM_REMINDER_REGEX, ''); + text = text.replace(/\n{3,}/g, '\n\n').trim(); + } + return text; + } + } + + return ''; +} + +/** + * Extract last message from Claude Code JSONL transcript. + * Each line is an independent JSON object with `type: "assistant"` or `type: "user"`. + */ +function extractLastMessageFromJsonl( + content: string, + role: 'user' | 'assistant', + stripSystemReminders: boolean +): string { const lines = content.split('\n'); let foundMatchingRole = false; diff --git a/tests/gemini-cli-compat.test.ts b/tests/gemini-cli-compat.test.ts new file mode 100644 index 00000000..4fa50c57 --- /dev/null +++ b/tests/gemini-cli-compat.test.ts @@ -0,0 +1,237 @@ +/** + * Tests for Gemini CLI 0.37.0 compatibility fixes (Issue #1664) + * + * Validates: + * 1. BeforeAgent is mapped to session-init (not user-message) + * 2. Transcript parser handles Gemini JSON document format (type: "gemini") + * 3. Summarize handler includes platformSource in the request body + */ +import { describe, it, expect } from 'bun:test'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +// --------------------------------------------------------------------------- +// 1. BeforeAgent event mapping +// --------------------------------------------------------------------------- + +describe('GeminiCliHooksInstaller - event mapping', () => { + it('should map BeforeAgent to session-init, not user-message', async () => { + // Import the module to access the constant indirectly by inspecting + // the generated command string through the installer's internal mapping. + // The constant GEMINI_EVENT_TO_INTERNAL_EVENT is module-private, but we + // can verify the effect by checking that the installer installs the + // correct internal event name. + // + // Strategy: read the source file and assert the mapping directly. + const { readFileSync } = await import('fs'); + const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8'); + + // BeforeAgent must map to 'session-init' + expect(src).toContain("'BeforeAgent': 'session-init'"); + // BeforeAgent must NOT map to 'user-message' + expect(src).not.toContain("'BeforeAgent': 'user-message'"); + }); + + it('should map SessionStart to context (unchanged)', async () => { + const { readFileSync } = await import('fs'); + const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8'); + expect(src).toContain("'SessionStart': 'context'"); + }); + + it('should map SessionEnd to session-complete (unchanged)', async () => { + const { readFileSync } = await import('fs'); + const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8'); + expect(src).toContain("'SessionEnd': 'session-complete'"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Transcript parser — Gemini JSON document format +// --------------------------------------------------------------------------- + +describe('extractLastMessage - Gemini CLI 0.37.0 transcript format', () => { + let tmpDir: string; + + // Helper: write a temp transcript file and return its path + const writeTranscript = (name: string, content: string): string => { + const filePath = join(tmpDir, name); + writeFileSync(filePath, content, 'utf-8'); + return filePath; + }; + + // Set up / tear down a fresh temp directory per suite + const setup = () => { + tmpDir = join(tmpdir(), `gemini-transcript-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + }; + const teardown = () => { + try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }; + + describe('Gemini JSON document format', () => { + it('extracts last assistant message from Gemini transcript (type: "gemini")', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const transcript = JSON.stringify({ + messages: [ + { type: 'user', content: 'Hello Gemini' }, + { type: 'gemini', content: 'Hi there! How can I help you today?' }, + { type: 'user', content: 'What is 2+2?' }, + { type: 'gemini', content: 'The answer is 4.' }, + ] + }); + const filePath = writeTranscript('gemini.json', transcript); + + const result = extractLastMessage(filePath, 'assistant'); + expect(result).toBe('The answer is 4.'); + } finally { + teardown(); + } + }); + + it('extracts last user message from Gemini transcript', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const transcript = JSON.stringify({ + messages: [ + { type: 'user', content: 'First message' }, + { type: 'gemini', content: 'First reply' }, + { type: 'user', content: 'Second message' }, + ] + }); + const filePath = writeTranscript('gemini-user.json', transcript); + + const result = extractLastMessage(filePath, 'user'); + expect(result).toBe('Second message'); + } finally { + teardown(); + } + }); + + it('returns empty string when no assistant message exists in Gemini transcript', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const transcript = JSON.stringify({ + messages: [ + { type: 'user', content: 'Just a user message' }, + ] + }); + const filePath = writeTranscript('gemini-no-assistant.json', transcript); + + const result = extractLastMessage(filePath, 'assistant'); + expect(result).toBe(''); + } finally { + teardown(); + } + }); + + it('strips system reminders from Gemini assistant messages when requested', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const content = 'Real answer here.ignore this'; + const transcript = JSON.stringify({ + messages: [ + { type: 'user', content: 'Question' }, + { type: 'gemini', content }, + ] + }); + const filePath = writeTranscript('gemini-strip.json', transcript); + + const result = extractLastMessage(filePath, 'assistant', true); + expect(result).toContain('Real answer here.'); + expect(result).not.toContain('system-reminder'); + expect(result).not.toContain('ignore this'); + } finally { + teardown(); + } + }); + + it('handles single-turn Gemini transcript', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const transcript = JSON.stringify({ + messages: [ + { type: 'user', content: 'Hello' }, + { type: 'gemini', content: 'Hello! I am Gemini.' }, + ] + }); + const filePath = writeTranscript('gemini-single.json', transcript); + + const result = extractLastMessage(filePath, 'assistant'); + expect(result).toBe('Hello! I am Gemini.'); + } finally { + teardown(); + } + }); + }); + + describe('JSONL format (Claude Code) — no regression', () => { + it('still extracts assistant messages from JSONL transcripts', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const lines = [ + JSON.stringify({ type: 'user', message: { content: [{ type: 'text', text: 'user msg' }] } }), + JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'assistant reply' }] } }), + ].join('\n'); + const filePath = writeTranscript('jsonl.jsonl', lines); + + const result = extractLastMessage(filePath, 'assistant'); + expect(result).toBe('assistant reply'); + } finally { + teardown(); + } + }); + + it('still extracts string content from JSONL transcripts', async () => { + setup(); + try { + const { extractLastMessage } = await import('../src/shared/transcript-parser.js'); + + const lines = [ + JSON.stringify({ type: 'assistant', message: { content: 'plain string response' } }), + ].join('\n'); + const filePath = writeTranscript('jsonl-string.jsonl', lines); + + const result = extractLastMessage(filePath, 'assistant'); + expect(result).toBe('plain string response'); + } finally { + teardown(); + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Summarize handler includes platformSource +// --------------------------------------------------------------------------- + +describe('Summarize handler - platformSource in request body', () => { + it('should include platformSource import in summarize.ts', async () => { + const { readFileSync } = await import('fs'); + const src = readFileSync('src/cli/handlers/summarize.ts', 'utf-8'); + expect(src).toContain('normalizePlatformSource'); + expect(src).toContain('platform-source'); + }); + + it('should pass platformSource in the summarize request body', async () => { + const { readFileSync } = await import('fs'); + const src = readFileSync('src/cli/handlers/summarize.ts', 'utf-8'); + // The body must include platformSource + expect(src).toContain('platformSource'); + // It must appear in the JSON.stringify call for the summarize endpoint + expect(src).toContain('/api/sessions/summarize'); + }); +});