fix: resolve Gemini CLI 0.37.0 session capture failures (#1664) (#1692)

Three root causes prevented Gemini sessions from persisting prompts,
observations, and summaries:

1. BeforeAgent was mapped to user-message (display-only) instead of
   session-init (which initialises the session and starts the SDK agent).

2. The transcript parser expected Claude Code JSONL (type: "assistant")
   but Gemini CLI 0.37.0 writes a JSON document with a messages array
   where assistant entries carry type: "gemini". extractLastMessage now
   detects the format and routes to the correct parser, preserving
   full backward compatibility with Claude Code JSONL transcripts.

3. The summarize handler omitted platformSource from the
   /api/sessions/summarize request body, causing sessions to be recorded
   without the gemini-cli source tag.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ben Younes
2026-04-15 09:58:20 +02:00
committed by GitHub
parent 544e9d39f5
commit 983be42998
5 changed files with 321 additions and 4 deletions

View File

@@ -13,7 +13,7 @@ import type { PlatformAdapter } from '../types.js';
* Notification → observation (system events like ToolPermission) * Notification → observation (system events like ToolPermission)
* *
* Agent: * Agent:
* BeforeAgent → user-message (captures user prompt) * BeforeAgent → session-init (initializes session, captures user prompt)
* AfterAgent → observation (full agent response) * AfterAgent → observation (full agent response)
* *
* Tool: * Tool:

View File

@@ -18,6 +18,7 @@ import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-util
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { extractLastMessage } from '../../shared/transcript-parser.js'; import { extractLastMessage } from '../../shared/transcript-parser.js';
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.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 SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
@@ -66,13 +67,16 @@ export const summarizeHandler: EventHandler = {
hasLastAssistantMessage: !!lastAssistantMessage hasLastAssistantMessage: !!lastAssistantMessage
}); });
const platformSource = normalizePlatformSource(input.platform);
// 1. Queue summarize request — worker returns immediately with { status: 'queued' } // 1. Queue summarize request — worker returns immediately with { status: 'queued' }
const response = await workerHttpRequest('/api/sessions/summarize', { const response = await workerHttpRequest('/api/sessions/summarize', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
contentSessionId: sessionId, contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage last_assistant_message: lastAssistantMessage,
platformSource
}), }),
timeoutMs: SUMMARIZE_TIMEOUT_MS timeoutMs: SUMMARIZE_TIMEOUT_MS
}); });

View File

@@ -80,7 +80,7 @@ const HOOK_TIMEOUT_MS = 10000;
*/ */
const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = { const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
'SessionStart': 'context', 'SessionStart': 'context',
'BeforeAgent': 'user-message', 'BeforeAgent': 'session-init',
'AfterAgent': 'observation', 'AfterAgent': 'observation',
'BeforeTool': 'observation', 'BeforeTool': 'observation',
'AfterTool': 'observation', 'AfterTool': 'observation',

View File

@@ -3,7 +3,37 @@ import { logger } from '../utils/logger.js';
import { SYSTEM_REMINDER_REGEX } from '../utils/tag-stripping.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 transcriptPath Path to transcript file
* @param role 'user' or 'assistant' * @param role 'user' or 'assistant'
* @param stripSystemReminders Whether to remove <system-reminder> tags (for assistant) * @param stripSystemReminders Whether to remove <system-reminder> tags (for assistant)
@@ -24,6 +54,52 @@ export function extractLastMessage(
return ''; 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'); const lines = content.split('\n');
let foundMatchingRole = false; let foundMatchingRole = false;

View File

@@ -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.<system-reminder>ignore this</system-reminder>';
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');
});
});