mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
237
tests/gemini-cli-compat.test.ts
Normal file
237
tests/gemini-cli-compat.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user