From 3bdaa1aa4302a8c525c6e9400b22b9937d3d0e1b Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Sat, 6 Dec 2025 17:21:24 -0500 Subject: [PATCH 1/4] chore: improve summary generation for sessions --- packages/cli/src/ui/AppContainer.tsx | 8 +- .../src/services/sessionSummaryUtils.test.ts | 428 ++++++------------ .../core/src/services/sessionSummaryUtils.ts | 180 ++++++-- 3 files changed, 273 insertions(+), 343 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 30dadda781b..a3e618db7f9 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -63,7 +63,7 @@ import { SessionEndReason, fireSessionStartHook, fireSessionEndHook, - generateAndSaveSummary, + generateSummary, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -311,9 +311,13 @@ export const AppContainer = (props: AppContainerProps) => { : SessionStartSource.Startup; await fireSessionStartHook(hookMessageBus, sessionStartSource); } + + // Fire-and-forget: generate summary for previous session in background + generateSummary(config).catch((e) => { + debugLogger.warn('Background summary generation failed:', e); + }); })(); registerCleanup(async () => { - await generateAndSaveSummary(config); // Turn off mouse scroll. disableMouseEvents(); const ideClient = await IdeClient.getInstance(); diff --git a/packages/core/src/services/sessionSummaryUtils.test.ts b/packages/core/src/services/sessionSummaryUtils.test.ts index d34c11b0a38..8864ea6ea26 100644 --- a/packages/core/src/services/sessionSummaryUtils.test.ts +++ b/packages/core/src/services/sessionSummaryUtils.test.ts @@ -5,11 +5,15 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { generateAndSaveSummary } from './sessionSummaryUtils.js'; +import { generateSummary, getPreviousSession } from './sessionSummaryUtils.js'; import type { Config } from '../config/config.js'; -import type { ChatRecordingService } from './chatRecordingService.js'; import type { ContentGenerator } from '../core/contentGenerator.js'; -import type { GeminiClient } from '../core/client.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +// Mock fs/promises +vi.mock('node:fs/promises'); +const mockReaddir = fs.readdir as unknown as ReturnType; // Mock the SessionSummaryService module vi.mock('./sessionSummaryService.js', () => ({ @@ -25,8 +29,6 @@ vi.mock('../core/baseLlmClient.js', () => ({ describe('sessionSummaryUtils', () => { let mockConfig: Config; - let mockChatRecordingService: ChatRecordingService; - let mockGeminiClient: GeminiClient; let mockContentGenerator: ContentGenerator; let mockGenerateSummary: ReturnType; @@ -36,23 +38,12 @@ describe('sessionSummaryUtils', () => { // Setup mock content generator mockContentGenerator = {} as ContentGenerator; - // Setup mock chat recording service - mockChatRecordingService = { - getConversation: vi.fn(), - saveSummary: vi.fn(), - } as unknown as ChatRecordingService; - - // Setup mock gemini client - mockGeminiClient = { - getChatRecordingService: vi - .fn() - .mockReturnValue(mockChatRecordingService), - } as unknown as GeminiClient; - // Setup mock config mockConfig = { getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'), + }, } as unknown as Config; // Setup mock generateSummary function @@ -73,320 +64,157 @@ describe('sessionSummaryUtils', () => { vi.restoreAllMocks(); }); - describe('Integration Tests', () => { - it('should generate and save summary successfully', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'How do I add dark mode?' }], - }, - { - id: '2', - timestamp: '2025-12-03T00:01:00Z', - type: 'gemini' as const, - content: [{ text: 'To add dark mode...' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - - await generateAndSaveSummary(mockConfig); - - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).toHaveBeenCalledWith({ - messages: mockConversation.messages, - }); - expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith( - 'Add dark mode to the app', - ); + describe('getPreviousSession', () => { + it('should return null if chats directory does not exist', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + + const result = await getPreviousSession(mockConfig); + + expect(result).toBeNull(); }); - it('should skip if no chat recording service is available', async () => { - ( - mockGeminiClient.getChatRecordingService as ReturnType - ).mockReturnValue(undefined); + it('should return null if no session files exist', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue([]); - await generateAndSaveSummary(mockConfig); + const result = await getPreviousSession(mockConfig); - expect(mockGeminiClient.getChatRecordingService).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).not.toHaveBeenCalled(); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + expect(result).toBeNull(); }); - it('should skip if no conversation exists', async () => { - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(null); + it('should return null if most recent session already has summary', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + sessionId: 'session-id', + summary: 'Existing summary', + messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], + }), + ); - await generateAndSaveSummary(mockConfig); + const result = await getPreviousSession(mockConfig); - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).not.toHaveBeenCalled(); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + expect(result).toBeNull(); }); - it('should skip if summary already exists', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - summary: 'Existing summary', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - - await generateAndSaveSummary(mockConfig); - - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).not.toHaveBeenCalled(); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); - }); + it('should return null if most recent session has no messages', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + sessionId: 'session-id', + messages: [], + }), + ); - it('should skip if no messages present', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [], - }; + const result = await getPreviousSession(mockConfig); - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); + expect(result).toBeNull(); + }); - await generateAndSaveSummary(mockConfig); + it('should return path if most recent session needs summary', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + sessionId: 'session-id', + messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], + }), + ); - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).not.toHaveBeenCalled(); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); - }); + const result = await getPreviousSession(mockConfig); - it('should handle generateSummary failure gracefully', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - mockGenerateSummary.mockResolvedValue(null); - - await generateAndSaveSummary(mockConfig); - - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + expect(result).toBe( + path.join( + '/tmp/project', + 'chats', + 'session-2024-01-01T10-00-abc12345.json', + ), + ); }); - it('should catch and log errors without throwing', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - mockGenerateSummary.mockRejectedValue(new Error('API Error')); + it('should select most recently created session by filename', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue([ + 'session-2024-01-01T10-00-older000.json', + 'session-2024-01-02T10-00-newer000.json', + ]); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + sessionId: 'newer-session', + messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], + }), + ); - // Should not throw - await expect(generateAndSaveSummary(mockConfig)).resolves.not.toThrow(); + const result = await getPreviousSession(mockConfig); - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + expect(result).toBe( + path.join( + '/tmp/project', + 'chats', + 'session-2024-01-02T10-00-newer000.json', + ), + ); }); - }); - describe('Mock Verification Tests', () => { - it('should call getConversation() once', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - - await generateAndSaveSummary(mockConfig); - - expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.getConversation).toHaveBeenCalledWith(); + it('should return null if most recent session file is corrupted', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue('invalid json'); + + const result = await getPreviousSession(mockConfig); + + expect(result).toBeNull(); }); + }); - it('should call generateSummary() with correct messages', async () => { - const mockMessages = [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'How do I add dark mode?' }], - }, - { - id: '2', - timestamp: '2025-12-03T00:01:00Z', - type: 'gemini' as const, - content: [{ text: 'To add dark mode...' }], - }, - ]; - - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: mockMessages, - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - - await generateAndSaveSummary(mockConfig); + describe('generateSummary', () => { + it('should not throw if getPreviousSession returns null', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); - expect(mockGenerateSummary).toHaveBeenCalledTimes(1); - expect(mockGenerateSummary).toHaveBeenCalledWith({ - messages: mockMessages, - }); + await expect(generateSummary(mockConfig)).resolves.not.toThrow(); }); - it('should call saveSummary() with generated summary', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - mockGenerateSummary.mockResolvedValue('Test summary'); - - await generateAndSaveSummary(mockConfig); - - expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith( - 'Test summary', + it('should generate and save summary for session needing one', async () => { + const sessionPath = path.join( + '/tmp/project', + 'chats', + 'session-2024-01-01T10-00-abc12345.json', ); - }); - it('should not call saveSummary() if generation fails', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - mockGenerateSummary.mockResolvedValue(null); - - await generateAndSaveSummary(mockConfig); + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + sessionId: 'session-id', + messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], + }), + ); + vi.mocked(fs.writeFile).mockResolvedValue(undefined); + + await generateSummary(mockConfig); expect(mockGenerateSummary).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + expect(fs.writeFile).toHaveBeenCalledTimes(1); + expect(fs.writeFile).toHaveBeenCalledWith( + sessionPath, + expect.stringContaining('Add dark mode to the app'), + ); }); - it('should not call saveSummary() if generateSummary throws', async () => { - const mockConversation = { - sessionId: 'test-session', - projectHash: 'test-hash', - startTime: '2025-12-03T00:00:00Z', - lastUpdated: '2025-12-03T00:10:00Z', - messages: [ - { - id: '1', - timestamp: '2025-12-03T00:00:00Z', - type: 'user' as const, - content: [{ text: 'Hello' }], - }, - ], - }; - - ( - mockChatRecordingService.getConversation as ReturnType - ).mockReturnValue(mockConversation); - mockGenerateSummary.mockRejectedValue(new Error('Generation failed')); - - await generateAndSaveSummary(mockConfig); + it('should handle errors gracefully without throwing', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + sessionId: 'session-id', + messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], + }), + ); + mockGenerateSummary.mockRejectedValue(new Error('API Error')); - expect(mockGenerateSummary).toHaveBeenCalledTimes(1); - expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + await expect(generateSummary(mockConfig)).resolves.not.toThrow(); }); }); }); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index 23e7567b89d..04f73bca3ca 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -8,62 +8,160 @@ import type { Config } from '../config/config.js'; import { SessionSummaryService } from './sessionSummaryService.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { + SESSION_FILE_PREFIX, + type ConversationRecord, +} from './chatRecordingService.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; /** - * Generates and saves a summary for the current session. - * This is called during session cleanup and is non-blocking - errors are logged but don't prevent exit. + * Generates and saves a summary for a session file. */ -export async function generateAndSaveSummary(config: Config): Promise { +async function generateAndSaveSummary( + config: Config, + sessionPath: string, +): Promise { + // Read session file + const content = await fs.readFile(sessionPath, 'utf-8'); + const conversation: ConversationRecord = JSON.parse(content); + + // Skip if summary already exists + if (conversation.summary) { + debugLogger.debug( + `[SessionSummary] Summary already exists for ${sessionPath}, skipping`, + ); + return; + } + + // Skip if no messages + if (conversation.messages.length === 0) { + debugLogger.debug( + `[SessionSummary] No messages to summarize in ${sessionPath}`, + ); + return; + } + + // Create summary service + const contentGenerator = config.getContentGenerator(); + const baseLlmClient = new BaseLlmClient(contentGenerator, config); + const summaryService = new SessionSummaryService(baseLlmClient); + + // Generate summary + const summary = await summaryService.generateSummary({ + messages: conversation.messages, + }); + + if (!summary) { + debugLogger.warn( + `[SessionSummary] Failed to generate summary for ${sessionPath}`, + ); + return; + } + + // Re-read the file before writing to handle race conditions + const freshContent = await fs.readFile(sessionPath, 'utf-8'); + const freshConversation: ConversationRecord = JSON.parse(freshContent); + + // Check if summary was added by another process + if (freshConversation.summary) { + debugLogger.debug( + `[SessionSummary] Summary was added by another process for ${sessionPath}`, + ); + return; + } + + // Add summary and write back + freshConversation.summary = summary; + freshConversation.lastUpdated = new Date().toISOString(); + await fs.writeFile(sessionPath, JSON.stringify(freshConversation, null, 2)); + debugLogger.debug( + `[SessionSummary] Saved summary for ${sessionPath}: "${summary}"`, + ); +} + +/** + * Finds the most recently created session that needs a summary. + * Returns the path if it needs a summary, null otherwise. + */ +export async function getPreviousSession( + config: Config, +): Promise { try { - // Get the chat recording service from config - const chatRecordingService = config - .getGeminiClient() - ?.getChatRecordingService(); - if (!chatRecordingService) { - debugLogger.debug('[SessionSummary] No chat recording service available'); - return; - } + const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); - // Get the current conversation - const conversation = chatRecordingService.getConversation(); - if (!conversation) { - debugLogger.debug('[SessionSummary] No conversation to summarize'); - return; + // Check if chats directory exists + try { + await fs.access(chatsDir); + } catch { + debugLogger.debug('[SessionSummary] No chats directory found'); + return null; } - // Skip if summary already exists (e.g., resumed session) - if (conversation.summary) { - debugLogger.debug('[SessionSummary] Summary already exists, skipping'); - return; + // List session files + const allFiles = await fs.readdir(chatsDir); + const sessionFiles = allFiles.filter( + (f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'), + ); + + if (sessionFiles.length === 0) { + debugLogger.debug('[SessionSummary] No session files found'); + return null; } - // Skip if no messages - if (conversation.messages.length === 0) { - debugLogger.debug('[SessionSummary] No messages to summarize'); - return; + // Sort by filename descending (most recently created first) + // Filename format: session-YYYY-MM-DDTHH-MM-XXXXXXXX.json + sessionFiles.sort((a, b) => b.localeCompare(a)); + + // Check the most recently created session + const mostRecentFile = sessionFiles[0]; + const filePath = path.join(chatsDir, mostRecentFile); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + const conversation: ConversationRecord = JSON.parse(content); + + if (conversation.summary) { + debugLogger.debug( + '[SessionSummary] Most recent session already has summary', + ); + return null; + } + + if (conversation.messages.length === 0) { + debugLogger.debug( + '[SessionSummary] Most recent session has no messages', + ); + return null; + } + + return filePath; + } catch { + debugLogger.debug('[SessionSummary] Could not read most recent session'); + return null; } + } catch (error) { + debugLogger.debug( + `[SessionSummary] Error finding previous session: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} - // Create summary service - const contentGenerator = config.getContentGenerator(); - const baseLlmClient = new BaseLlmClient(contentGenerator, config); - const summaryService = new SessionSummaryService(baseLlmClient); - - // Generate summary - const summary = await summaryService.generateSummary({ - messages: conversation.messages, - }); - - // Save summary if generated successfully - if (summary) { - chatRecordingService.saveSummary(summary); - debugLogger.debug(`[SessionSummary] Saved summary: "${summary}"`); - } else { - debugLogger.warn('[SessionSummary] Failed to generate summary'); +/** + * Generates summary for the previous session if it lacks one. + * This is designed to be called fire-and-forget on startup. + */ +export async function generateSummary(config: Config): Promise { + try { + const sessionPath = await getPreviousSession(config); + if (sessionPath) { + await generateAndSaveSummary(config, sessionPath); } } catch (error) { // Log but don't throw - we want graceful degradation debugLogger.warn( - `[SessionSummary] Error in generateAndSaveSummary: ${error instanceof Error ? error.message : String(error)}`, + `[SessionSummary] Error generating summary: ${error instanceof Error ? error.message : String(error)}`, ); } } From 26387328eb3d30b3b5bb0b34d4ac1cff59c37714 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Sun, 7 Dec 2025 16:32:46 -0500 Subject: [PATCH 2/4] fix(sessions): move session summary generating to startup --- packages/cli/src/utils/sessions.test.ts | 1 + packages/cli/src/utils/sessions.ts | 9 +++- .../src/services/sessionSummaryUtils.test.ts | 51 +++++++++---------- .../core/src/services/sessionSummaryUtils.ts | 8 ++- 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts index 665ddd3003b..fe7a0ea2355 100644 --- a/packages/cli/src/utils/sessions.test.ts +++ b/packages/cli/src/utils/sessions.test.ts @@ -21,6 +21,7 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actual, ChatRecordingService: vi.fn(), + generateSummary: vi.fn().mockResolvedValue(undefined), }; }); diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index 4e7f3a811a7..3d9866f337d 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatRecordingService, type Config } from '@google/gemini-cli-core'; +import { + ChatRecordingService, + generateSummary, + type Config, +} from '@google/gemini-cli-core'; import { formatRelativeTime, SessionSelector, @@ -12,6 +16,9 @@ import { } from './sessionUtils.js'; export async function listSessions(config: Config): Promise { + // Generate summary for most recent session if needed + await generateSummary(config); + const sessionSelector = new SessionSelector(config); const sessions = await sessionSelector.listSessions(); diff --git a/packages/core/src/services/sessionSummaryUtils.test.ts b/packages/core/src/services/sessionSummaryUtils.test.ts index 8864ea6ea26..5b062377ff5 100644 --- a/packages/core/src/services/sessionSummaryUtils.test.ts +++ b/packages/core/src/services/sessionSummaryUtils.test.ts @@ -27,6 +27,22 @@ vi.mock('../core/baseLlmClient.js', () => ({ BaseLlmClient: vi.fn(), })); +// Helper to create a session with N user messages +function createSessionWithUserMessages( + count: number, + options: { summary?: string; sessionId?: string } = {}, +) { + return JSON.stringify({ + sessionId: options.sessionId ?? 'session-id', + summary: options.summary, + messages: Array.from({ length: count }, (_, i) => ({ + id: String(i + 1), + type: 'user', + content: [{ text: `Message ${i + 1}` }], + })), + }); +} + describe('sessionSummaryUtils', () => { let mockConfig: Config; let mockContentGenerator: ContentGenerator; @@ -86,11 +102,7 @@ describe('sessionSummaryUtils', () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - sessionId: 'session-id', - summary: 'Existing summary', - messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], - }), + createSessionWithUserMessages(5, { summary: 'Existing summary' }), ); const result = await getPreviousSession(mockConfig); @@ -98,14 +110,11 @@ describe('sessionSummaryUtils', () => { expect(result).toBeNull(); }); - it('should return null if most recent session has no messages', async () => { + it('should return null if most recent session has fewer than 5 user messages', async () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - sessionId: 'session-id', - messages: [], - }), + createSessionWithUserMessages(4), ); const result = await getPreviousSession(mockConfig); @@ -113,14 +122,11 @@ describe('sessionSummaryUtils', () => { expect(result).toBeNull(); }); - it('should return path if most recent session needs summary', async () => { + it('should return path if most recent session has 5+ user messages and no summary', async () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - sessionId: 'session-id', - messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], - }), + createSessionWithUserMessages(5), ); const result = await getPreviousSession(mockConfig); @@ -141,10 +147,7 @@ describe('sessionSummaryUtils', () => { 'session-2024-01-02T10-00-newer000.json', ]); vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - sessionId: 'newer-session', - messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], - }), + createSessionWithUserMessages(5), ); const result = await getPreviousSession(mockConfig); @@ -186,10 +189,7 @@ describe('sessionSummaryUtils', () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - sessionId: 'session-id', - messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], - }), + createSessionWithUserMessages(5), ); vi.mocked(fs.writeFile).mockResolvedValue(undefined); @@ -207,10 +207,7 @@ describe('sessionSummaryUtils', () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - JSON.stringify({ - sessionId: 'session-id', - messages: [{ id: '1', type: 'user', content: [{ text: 'Hello' }] }], - }), + createSessionWithUserMessages(5), ); mockGenerateSummary.mockRejectedValue(new Error('API Error')); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index 04f73bca3ca..c8fb67e44ea 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -128,9 +128,13 @@ export async function getPreviousSession( return null; } - if (conversation.messages.length === 0) { + // Only generate summaries for sessions with 5+ user messages + const userMessageCount = conversation.messages.filter( + (m) => m.type === 'user', + ).length; + if (userMessageCount < 5) { debugLogger.debug( - '[SessionSummary] Most recent session has no messages', + `[SessionSummary] Most recent session has ${userMessageCount} user messages, skipping (minimum 5)`, ); return null; } From d53ed683f245092a8dc3c9740d493411b1641b01 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Mon, 8 Dec 2025 15:59:24 -0500 Subject: [PATCH 3/4] chore: change to more than 1 message as summary threshold --- .../core/src/services/sessionSummaryUtils.test.ts | 14 +++++++------- packages/core/src/services/sessionSummaryUtils.ts | 8 +++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/core/src/services/sessionSummaryUtils.test.ts b/packages/core/src/services/sessionSummaryUtils.test.ts index 5b062377ff5..2314b7ca066 100644 --- a/packages/core/src/services/sessionSummaryUtils.test.ts +++ b/packages/core/src/services/sessionSummaryUtils.test.ts @@ -110,11 +110,11 @@ describe('sessionSummaryUtils', () => { expect(result).toBeNull(); }); - it('should return null if most recent session has fewer than 5 user messages', async () => { + it('should return null if most recent session has 1 or fewer user messages', async () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - createSessionWithUserMessages(4), + createSessionWithUserMessages(1), ); const result = await getPreviousSession(mockConfig); @@ -122,11 +122,11 @@ describe('sessionSummaryUtils', () => { expect(result).toBeNull(); }); - it('should return path if most recent session has 5+ user messages and no summary', async () => { + it('should return path if most recent session has more than 1 user message and no summary', async () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - createSessionWithUserMessages(5), + createSessionWithUserMessages(2), ); const result = await getPreviousSession(mockConfig); @@ -147,7 +147,7 @@ describe('sessionSummaryUtils', () => { 'session-2024-01-02T10-00-newer000.json', ]); vi.mocked(fs.readFile).mockResolvedValue( - createSessionWithUserMessages(5), + createSessionWithUserMessages(2), ); const result = await getPreviousSession(mockConfig); @@ -189,7 +189,7 @@ describe('sessionSummaryUtils', () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - createSessionWithUserMessages(5), + createSessionWithUserMessages(2), ); vi.mocked(fs.writeFile).mockResolvedValue(undefined); @@ -207,7 +207,7 @@ describe('sessionSummaryUtils', () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); vi.mocked(fs.readFile).mockResolvedValue( - createSessionWithUserMessages(5), + createSessionWithUserMessages(2), ); mockGenerateSummary.mockRejectedValue(new Error('API Error')); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index c8fb67e44ea..514d5b286fa 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -15,6 +15,8 @@ import { import fs from 'node:fs/promises'; import path from 'node:path'; +const MIN_MESSAGES_FOR_SUMMARY = 1; + /** * Generates and saves a summary for a session file. */ @@ -128,13 +130,13 @@ export async function getPreviousSession( return null; } - // Only generate summaries for sessions with 5+ user messages + // Only generate summaries for sessions with more than 1 user message const userMessageCount = conversation.messages.filter( (m) => m.type === 'user', ).length; - if (userMessageCount < 5) { + if (userMessageCount <= MIN_MESSAGES_FOR_SUMMARY) { debugLogger.debug( - `[SessionSummary] Most recent session has ${userMessageCount} user messages, skipping (minimum 5)`, + `[SessionSummary] Most recent session has ${userMessageCount} user message(s), skipping (need more than ${MIN_MESSAGES_FOR_SUMMARY})`, ); return null; } From aa7f796486bd08e079e4ffdf92252ab96e863b52 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 9 Dec 2025 14:48:33 -0500 Subject: [PATCH 4/4] chore: update auth for --list-sessions --- packages/cli/src/gemini.tsx | 14 ++++++++++++++ packages/core/src/services/sessionSummaryUtils.ts | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index a20fef8613b..99b18f9b457 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -498,6 +498,20 @@ export async function main() { // Handle --list-sessions flag if (config.getListSessions()) { + // Attempt auth for summary generation (gracefully skips if not configured) + const authType = settings.merged.security?.auth?.selectedType; + if (authType) { + try { + await config.refreshAuth(authType); + } catch (e) { + // Auth failed - continue without summary generation capability + debugLogger.debug( + 'Auth failed for --list-sessions, summaries may not be generated:', + e, + ); + } + } + await listSessions(config); await runExitCleanup(); process.exit(ExitCodes.SUCCESS); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index 514d5b286fa..ed51cecd2ba 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -46,6 +46,12 @@ async function generateAndSaveSummary( // Create summary service const contentGenerator = config.getContentGenerator(); + if (!contentGenerator) { + debugLogger.debug( + '[SessionSummary] Content generator not available, skipping summary generation', + ); + return; + } const baseLlmClient = new BaseLlmClient(contentGenerator, config); const summaryService = new SessionSummaryService(baseLlmClient);