diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 56c198dd48..30dadda781 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -63,6 +63,7 @@ import { SessionEndReason, fireSessionStartHook, fireSessionEndHook, + generateAndSaveSummary, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import process from 'node:process'; @@ -312,6 +313,7 @@ export const AppContainer = (props: AppContainerProps) => { } })(); registerCleanup(async () => { + await generateAndSaveSummary(config); // Turn off mouse scroll. disableMouseEvents(); const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index c24dc4d225..ab65847137 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -15,6 +15,7 @@ import { } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; +import { stripUnsafeCharacters } from '../ui/utils/textUtils.js'; /** * Constant for the resume "latest" identifier. @@ -60,6 +61,8 @@ export interface SessionInfo { isCurrentSession: boolean; /** Display index in the list */ index: number; + /** AI-generated summary of the session (if available) */ + summary?: string; /** Full concatenated content (only loaded when needed for search) */ fullContent?: string; /** Processed messages with normalized roles (only loaded when needed) */ @@ -259,10 +262,13 @@ export const getAllSessionFiles = async ( startTime: content.startTime, lastUpdated: content.lastUpdated, messageCount: content.messages.length, - displayName: firstUserMessage, + displayName: content.summary + ? stripUnsafeCharacters(content.summary) + : firstUserMessage, firstUserMessage, isCurrentSession, index: 0, // Will be set after sorting valid sessions + summary: content.summary, fullContent, messages, }; diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts index 6be54e463c..665ddd3003 100644 --- a/packages/cli/src/utils/sessions.test.ts +++ b/packages/cli/src/utils/sessions.test.ts @@ -290,6 +290,40 @@ describe('listSessions', () => { expect.stringContaining(', current)'), ); }); + + it('should display summary as title when available instead of first user message', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-with-summary', + file: 'session-file', + fileName: 'session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 10, + displayName: 'Add dark mode to the app', // Summary + firstUserMessage: + 'How do I add dark mode to my React application with CSS variables?', + isCurrentSession: false, + index: 1, + summary: 'Add dark mode to the app', + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert: Should show the summary (displayName), not the first user message + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('1. Add dark mode to the app'), + ); + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining('How do I add dark mode to my React application'), + ); + }); }); describe('deleteSession', () => { diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index 9207069af0..4e7f3a811a 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -31,9 +31,9 @@ export async function listSessions(config: Config): Promise { const current = session.isCurrentSession ? ', current' : ''; const time = formatRelativeTime(session.lastUpdated); const title = - session.firstUserMessage.length > 100 - ? session.firstUserMessage.slice(0, 97) + '...' - : session.firstUserMessage; + session.displayName.length > 100 + ? session.displayName.slice(0, 97) + '...' + : session.displayName; console.log( ` ${index + 1}. ${title} (${time}${current}) [${session.id}]`, ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 235c459b2c..c06c0a8e84 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,6 +84,7 @@ export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; +export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; // Export IDE specific logic diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 5bd9533bf1..26186899f0 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -86,6 +86,7 @@ export interface ConversationRecord { startTime: string; lastUpdated: string; messages: MessageRecord[]; + summary?: string; } /** @@ -437,6 +438,36 @@ export class ChatRecordingService { this.writeConversation(conversation); } + /** + * Saves a summary for the current session. + */ + saveSummary(summary: string): void { + if (!this.conversationFile) return; + + try { + this.updateConversation((conversation) => { + conversation.summary = summary; + }); + } catch (error) { + debugLogger.error('Error saving summary to chat history.', error); + // Don't throw - we want graceful degradation + } + } + + /** + * Gets the current conversation data (for summary generation). + */ + getConversation(): ConversationRecord | null { + if (!this.conversationFile) return null; + + try { + return this.readConversation(); + } catch (error) { + debugLogger.error('Error reading conversation for summary.', error); + return null; + } + } + /** * Deletes a session file by session ID. */ diff --git a/packages/core/src/services/sessionSummaryService.test.ts b/packages/core/src/services/sessionSummaryService.test.ts new file mode 100644 index 0000000000..c3362a63c9 --- /dev/null +++ b/packages/core/src/services/sessionSummaryService.test.ts @@ -0,0 +1,938 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { SessionSummaryService } from './sessionSummaryService.js'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import type { MessageRecord } from './chatRecordingService.js'; +import type { GenerateContentResponse } from '@google/genai'; + +describe('SessionSummaryService', () => { + let service: SessionSummaryService; + let mockBaseLlmClient: BaseLlmClient; + let mockGenerateContent: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // Setup mock BaseLlmClient with generateContent + mockGenerateContent = vi.fn().mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Add dark mode to the app' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + mockBaseLlmClient = { + generateContent: mockGenerateContent, + } as unknown as BaseLlmClient; + + service = new SessionSummaryService(mockBaseLlmClient); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('Basic Functionality', () => { + it('should generate summary for valid conversation', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'How do I add dark mode to my app?' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [ + { + text: 'To add dark mode, you need to create a theme provider and toggle state...', + }, + ], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBe('Add dark mode to the app'); + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + expect(mockGenerateContent).toHaveBeenCalledWith( + expect.objectContaining({ + modelConfigKey: { model: 'summarizer-default' }, + contents: expect.arrayContaining([ + expect.objectContaining({ + role: 'user', + parts: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining('User: How do I add dark mode'), + }), + ]), + }), + ]), + promptId: 'session-summary-generation', + }), + ); + }); + + it('should return null for empty messages array', async () => { + const summary = await service.generateSummary({ messages: [] }); + + expect(summary).toBeNull(); + expect(mockGenerateContent).not.toHaveBeenCalled(); + }); + + it('should return null when all messages have empty content', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: ' ' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [{ text: '' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBeNull(); + expect(mockGenerateContent).not.toHaveBeenCalled(); + }); + + it('should handle maxMessages limit correctly', async () => { + const messages: MessageRecord[] = Array.from({ length: 30 }, (_, i) => ({ + id: `${i}`, + timestamp: '2025-12-03T00:00:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i}` }], + })); + + await service.generateSummary({ messages, maxMessages: 10 }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + // Count how many messages appear in the prompt (should be 10) + const messageCount = (promptText.match(/Message \d+/g) || []).length; + expect(messageCount).toBe(10); + }); + }); + + describe('Message Type Filtering', () => { + it('should include only user and gemini messages', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'User message' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [{ text: 'Gemini response' }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + expect(promptText).toContain('User: User message'); + expect(promptText).toContain('Assistant: Gemini response'); + }); + + it('should exclude info messages', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'User message' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'info', + content: [{ text: 'Info message should be excluded' }], + }, + { + id: '3', + timestamp: '2025-12-03T00:02:00Z', + type: 'gemini', + content: [{ text: 'Gemini response' }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + expect(promptText).toContain('User: User message'); + expect(promptText).toContain('Assistant: Gemini response'); + expect(promptText).not.toContain('Info message'); + }); + + it('should exclude error messages', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'User message' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'error', + content: [{ text: 'Error: something went wrong' }], + }, + { + id: '3', + timestamp: '2025-12-03T00:02:00Z', + type: 'gemini', + content: [{ text: 'Gemini response' }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + expect(promptText).not.toContain('Error: something went wrong'); + }); + + it('should exclude warning messages', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'User message' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'warning', + content: [{ text: 'Warning: deprecated API' }], + }, + { + id: '3', + timestamp: '2025-12-03T00:02:00Z', + type: 'gemini', + content: [{ text: 'Gemini response' }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + expect(promptText).not.toContain('Warning: deprecated API'); + }); + + it('should handle mixed message types correctly', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'info', + content: [{ text: 'System info' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'user', + content: [{ text: 'User question' }], + }, + { + id: '3', + timestamp: '2025-12-03T00:02:00Z', + type: 'error', + content: [{ text: 'Error occurred' }], + }, + { + id: '4', + timestamp: '2025-12-03T00:03:00Z', + type: 'gemini', + content: [{ text: 'Gemini answer' }], + }, + { + id: '5', + timestamp: '2025-12-03T00:04:00Z', + type: 'warning', + content: [{ text: 'Warning message' }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + expect(promptText).toContain('User: User question'); + expect(promptText).toContain('Assistant: Gemini answer'); + expect(promptText).not.toContain('System info'); + expect(promptText).not.toContain('Error occurred'); + expect(promptText).not.toContain('Warning message'); + }); + + it('should return null when only system messages present', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'info', + content: [{ text: 'Info message' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'error', + content: [{ text: 'Error message' }], + }, + { + id: '3', + timestamp: '2025-12-03T00:02:00Z', + type: 'warning', + content: [{ text: 'Warning message' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBeNull(); + expect(mockGenerateContent).not.toHaveBeenCalled(); + }); + }); + + describe('Timeout and Abort Handling', () => { + it('should timeout after specified duration', async () => { + // Mock implementation that respects abort signal + mockGenerateContent.mockImplementation( + ({ abortSignal }) => + new Promise((resolve, reject) => { + const timeoutId = setTimeout( + () => + resolve({ + candidates: [{ content: { parts: [{ text: 'Summary' }] } }], + }), + 10000, + ); + + abortSignal?.addEventListener('abort', () => { + clearTimeout(timeoutId); + const abortError = new Error('This operation was aborted'); + abortError.name = 'AbortError'; + reject(abortError); + }); + }), + ); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Hello' }], + }, + ]; + + const summaryPromise = service.generateSummary({ + messages, + timeout: 100, + }); + + // Advance timers past the timeout to trigger abort + await vi.advanceTimersByTimeAsync(100); + + const summary = await summaryPromise; + + expect(summary).toBeNull(); + }); + + it('should detect AbortError by name only (not message)', async () => { + const abortError = new Error('Different abort message'); + abortError.name = 'AbortError'; + mockGenerateContent.mockRejectedValue(abortError); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Hello' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBeNull(); + // Should handle it gracefully without throwing + }); + + it('should handle API errors gracefully', async () => { + mockGenerateContent.mockRejectedValue(new Error('API Error')); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Hello' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBeNull(); + }); + + it('should handle empty response from LLM', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: '' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Hello' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBeNull(); + }); + }); + + describe('Text Processing', () => { + it('should clean newlines and extra whitespace', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + text: 'Add dark mode\n\nto the app', + }, + ], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Hello' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBe('Add dark mode to the app'); + }); + + it('should remove surrounding quotes', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: '"Add dark mode to the app"' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Hello' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBe('Add dark mode to the app'); + }); + + it('should handle messages longer than 500 chars', async () => { + const longMessage = 'a'.repeat(1000); + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: longMessage }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [{ text: 'Response' }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + // Should be truncated to ~500 chars + "..." + expect(promptText).toContain('...'); + expect(promptText).not.toContain('a'.repeat(600)); + }); + + it('should preserve important content in truncation', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'How do I add dark mode?' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [ + { + text: 'Here is a detailed explanation...', + }, + ], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + // User question should be preserved + expect(promptText).toContain('User: How do I add dark mode?'); + expect(promptText).toContain('Assistant: Here is a detailed explanation'); + }); + }); + + describe('Sliding Window Message Selection', () => { + it('should return all messages when fewer than 20 exist', async () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ + id: `${i}`, + timestamp: '2025-12-03T00:00:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i}` }], + })); + + await service.generateSummary({ messages }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + const messageCount = (promptText.match(/Message \d+/g) || []).length; + expect(messageCount).toBe(5); + }); + + it('should select first 10 + last 10 from 50 messages', async () => { + const messages = Array.from({ length: 50 }, (_, i) => ({ + id: `${i}`, + timestamp: '2025-12-03T00:00:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i}` }], + })); + + await service.generateSummary({ messages }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + // Should include first 10 + expect(promptText).toContain('Message 0'); + expect(promptText).toContain('Message 9'); + + // Should skip middle + expect(promptText).not.toContain('Message 25'); + + // Should include last 10 + expect(promptText).toContain('Message 40'); + expect(promptText).toContain('Message 49'); + + const messageCount = (promptText.match(/Message \d+/g) || []).length; + expect(messageCount).toBe(20); + }); + + it('should return all messages when exactly 20 exist', async () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ + id: `${i}`, + timestamp: '2025-12-03T00:00:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i}` }], + })); + + await service.generateSummary({ messages }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + const messageCount = (promptText.match(/Message \d+/g) || []).length; + expect(messageCount).toBe(20); + }); + + it('should preserve message ordering in sliding window', async () => { + const messages = Array.from({ length: 30 }, (_, i) => ({ + id: `${i}`, + timestamp: '2025-12-03T00:00:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i}` }], + })); + + await service.generateSummary({ messages }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + const matches = promptText.match(/Message (\d+)/g) || []; + const indices = matches.map((m: string) => parseInt(m.split(' ')[1], 10)); + + // Verify ordering is preserved + for (let i = 1; i < indices.length; i++) { + expect(indices[i]).toBeGreaterThan(indices[i - 1]); + } + }); + + it('should not count system messages when calculating window', async () => { + const messages: MessageRecord[] = [ + // First 10 user/gemini messages + ...Array.from({ length: 10 }, (_, i) => ({ + id: `${i}`, + timestamp: '2025-12-03T00:00:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i}` }], + })), + // System messages (should be filtered out) + { + id: 'info1', + timestamp: '2025-12-03T00:10:00Z', + type: 'info' as const, + content: [{ text: 'Info' }], + }, + { + id: 'warn1', + timestamp: '2025-12-03T00:11:00Z', + type: 'warning' as const, + content: [{ text: 'Warning' }], + }, + // Last 40 user/gemini messages + ...Array.from({ length: 40 }, (_, i) => ({ + id: `${i + 10}`, + timestamp: '2025-12-03T00:12:00Z', + type: i % 2 === 0 ? ('user' as const) : ('gemini' as const), + content: [{ text: `Message ${i + 10}` }], + })), + ]; + + await service.generateSummary({ messages }); + + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + // Should include early messages + expect(promptText).toContain('Message 0'); + expect(promptText).toContain('Message 9'); + + // Should include late messages + expect(promptText).toContain('Message 40'); + expect(promptText).toContain('Message 49'); + + // Should not include system messages + expect(promptText).not.toContain('Info'); + expect(promptText).not.toContain('Warning'); + }); + }); + + describe('Edge Cases', () => { + it('should handle conversation with only user messages', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'First question' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'user', + content: [{ text: 'Second question' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).not.toBeNull(); + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + }); + + it('should handle conversation with only gemini messages', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'gemini', + content: [{ text: 'First response' }], + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [{ text: 'Second response' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).not.toBeNull(); + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + }); + + it('should handle very long individual messages (>500 chars)', async () => { + const longMessage = + `This is a very long message that contains a lot of text and definitely exceeds the 500 character limit. `.repeat( + 10, + ); + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: longMessage }], + }, + ]; + + await service.generateSummary({ messages }); + + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + const callArgs = mockGenerateContent.mock.calls[0][0]; + const promptText = callArgs.contents[0].parts[0].text; + + // Should contain the truncation marker + expect(promptText).toContain('...'); + }); + + it('should handle messages with special characters', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [ + { + text: 'How to use with props={value} & state?', + }, + ], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).not.toBeNull(); + expect(mockGenerateContent).toHaveBeenCalledTimes(1); + }); + + it('should handle malformed message content', async () => { + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [], // Empty parts array + }, + { + id: '2', + timestamp: '2025-12-03T00:01:00Z', + type: 'gemini', + content: [{ text: 'Valid response' }], + }, + ]; + + await service.generateSummary({ messages }); + + // Should handle gracefully and still process valid messages + expect(mockGenerateContent).toHaveBeenCalled(); + }); + }); + + describe('Internationalization Support', () => { + it('should preserve international characters (Chinese)', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: '添加深色模式到应用' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'How do I add dark mode?' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBe('添加深色模式到应用'); + }); + + it('should preserve international characters (Arabic)', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'إضافة الوضع الداكن' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'How do I add dark mode?' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBe('إضافة الوضع الداكن'); + }); + + it('should preserve accented characters', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'Añadir modo oscuro à la aplicación' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'How do I add dark mode?' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + expect(summary).toBe('Añadir modo oscuro à la aplicación'); + }); + + it('should preserve emojis in summaries', async () => { + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: '🌙 Add dark mode 🎨 to the app ✨' }], + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'How do I add dark mode?' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + // Emojis are preserved + expect(summary).toBe('🌙 Add dark mode 🎨 to the app ✨'); + expect(summary).toContain('🌙'); + expect(summary).toContain('🎨'); + expect(summary).toContain('✨'); + }); + + it('should preserve zero-width characters for language rendering', async () => { + // Arabic with Zero-Width Joiner (ZWJ) for proper ligatures + mockGenerateContent.mockResolvedValue({ + candidates: [ + { + content: { + parts: [{ text: 'كلمة\u200Dمتصلة' }], // Contains ZWJ + }, + }, + ], + } as unknown as GenerateContentResponse); + + const messages: MessageRecord[] = [ + { + id: '1', + timestamp: '2025-12-03T00:00:00Z', + type: 'user', + content: [{ text: 'Test' }], + }, + ]; + + const summary = await service.generateSummary({ messages }); + + // ZWJ is preserved (it's not considered whitespace) + expect(summary).toBe('كلمة\u200Dمتصلة'); + expect(summary).toContain('\u200D'); // ZWJ should be preserved + }); + }); +}); diff --git a/packages/core/src/services/sessionSummaryService.ts b/packages/core/src/services/sessionSummaryService.ts new file mode 100644 index 0000000000..98ffd66fca --- /dev/null +++ b/packages/core/src/services/sessionSummaryService.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MessageRecord } from './chatRecordingService.js'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import { partListUnionToString } from '../core/geminiRequest.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import type { Content } from '@google/genai'; +import { getResponseText } from '../utils/partUtils.js'; + +const DEFAULT_MAX_MESSAGES = 20; +const DEFAULT_TIMEOUT_MS = 5000; +const MAX_MESSAGE_LENGTH = 500; + +const SUMMARY_PROMPT = `Summarize the user's primary intent or goal in this conversation in ONE sentence (max 80 characters). +Focus on what the user was trying to accomplish. + +Examples: +- "Add dark mode to the app" +- "Fix authentication bug in login flow" +- "Understand how the API routing works" +- "Refactor database connection logic" +- "Debug memory leak in production" + +Conversation: +{conversation} + +Summary (max 80 chars):`; + +/** + * Options for generating a session summary. + */ +export interface GenerateSummaryOptions { + messages: MessageRecord[]; + maxMessages?: number; + timeout?: number; +} + +/** + * Service for generating AI summaries of chat sessions. + * Uses Gemini Flash Lite to create concise, user-intent-focused summaries. + */ +export class SessionSummaryService { + constructor(private readonly baseLlmClient: BaseLlmClient) {} + + /** + * Generate a 1-line summary of a chat session focusing on user intent. + * Returns null if generation fails for any reason. + */ + async generateSummary( + options: GenerateSummaryOptions, + ): Promise { + const { + messages, + maxMessages = DEFAULT_MAX_MESSAGES, + timeout = DEFAULT_TIMEOUT_MS, + } = options; + + try { + // Filter to user/gemini messages only (exclude system messages) + const filteredMessages = messages.filter((msg) => { + // Skip system messages (info, error, warning) + if (msg.type !== 'user' && msg.type !== 'gemini') { + return false; + } + const content = partListUnionToString(msg.content); + return content.trim().length > 0; + }); + + // Apply sliding window selection: first N + last N messages + let relevantMessages: MessageRecord[]; + if (filteredMessages.length <= maxMessages) { + // If fewer messages than max, include all + relevantMessages = filteredMessages; + } else { + // Sliding window: take the first and last messages. + const firstWindowSize = Math.ceil(maxMessages / 2); + const lastWindowSize = Math.floor(maxMessages / 2); + const firstMessages = filteredMessages.slice(0, firstWindowSize); + const lastMessages = filteredMessages.slice(-lastWindowSize); + relevantMessages = firstMessages.concat(lastMessages); + } + + if (relevantMessages.length === 0) { + debugLogger.debug('[SessionSummary] No messages to summarize'); + return null; + } + + // Format conversation for the prompt + const conversationText = relevantMessages + .map((msg) => { + const role = msg.type === 'user' ? 'User' : 'Assistant'; + const content = partListUnionToString(msg.content); + // Truncate very long messages to avoid token limit + const truncated = + content.length > MAX_MESSAGE_LENGTH + ? content.slice(0, MAX_MESSAGE_LENGTH) + '...' + : content; + return `${role}: ${truncated}`; + }) + .join('\n\n'); + + const prompt = SUMMARY_PROMPT.replace('{conversation}', conversationText); + + // Create abort controller with timeout + const abortController = new AbortController(); + const timeoutId = setTimeout(() => { + abortController.abort(); + }, timeout); + + try { + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: prompt }], + }, + ]; + + const response = await this.baseLlmClient.generateContent({ + modelConfigKey: { model: 'summarizer-default' }, + contents, + abortSignal: abortController.signal, + promptId: 'session-summary-generation', + }); + + const summary = getResponseText(response); + + if (!summary || summary.trim().length === 0) { + debugLogger.debug('[SessionSummary] Empty summary returned'); + return null; + } + + // Clean the summary + let cleanedSummary = summary + .replace(/\n+/g, ' ') // Collapse newlines to spaces + .replace(/\s+/g, ' ') // Normalize whitespace + .trim(); // Trim after all processing + + // Remove quotes if the model added them + cleanedSummary = cleanedSummary.replace(/^["']|["']$/g, ''); + + debugLogger.debug(`[SessionSummary] Generated: "${cleanedSummary}"`); + return cleanedSummary; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + // Log the error but don't throw - we want graceful degradation + if (error instanceof Error && error.name === 'AbortError') { + debugLogger.debug('[SessionSummary] Timeout generating summary'); + } else { + debugLogger.debug( + `[SessionSummary] Error generating summary: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return null; + } + } +} diff --git a/packages/core/src/services/sessionSummaryUtils.test.ts b/packages/core/src/services/sessionSummaryUtils.test.ts new file mode 100644 index 0000000000..d34c11b0a3 --- /dev/null +++ b/packages/core/src/services/sessionSummaryUtils.test.ts @@ -0,0 +1,392 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { generateAndSaveSummary } 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'; + +// Mock the SessionSummaryService module +vi.mock('./sessionSummaryService.js', () => ({ + SessionSummaryService: vi.fn().mockImplementation(() => ({ + generateSummary: vi.fn(), + })), +})); + +// Mock the BaseLlmClient module +vi.mock('../core/baseLlmClient.js', () => ({ + BaseLlmClient: vi.fn(), +})); + +describe('sessionSummaryUtils', () => { + let mockConfig: Config; + let mockChatRecordingService: ChatRecordingService; + let mockGeminiClient: GeminiClient; + let mockContentGenerator: ContentGenerator; + let mockGenerateSummary: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + // 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), + } as unknown as Config; + + // Setup mock generateSummary function + mockGenerateSummary = vi.fn().mockResolvedValue('Add dark mode to the app'); + + // Import the mocked module to access the constructor + const { SessionSummaryService } = await import( + './sessionSummaryService.js' + ); + ( + SessionSummaryService as unknown as ReturnType + ).mockImplementation(() => ({ + generateSummary: mockGenerateSummary, + })); + }); + + afterEach(() => { + 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', + ); + }); + + it('should skip if no chat recording service is available', async () => { + ( + mockGeminiClient.getChatRecordingService as ReturnType + ).mockReturnValue(undefined); + + await generateAndSaveSummary(mockConfig); + + expect(mockGeminiClient.getChatRecordingService).toHaveBeenCalledTimes(1); + expect(mockGenerateSummary).not.toHaveBeenCalled(); + expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + }); + + it('should skip if no conversation exists', async () => { + ( + mockChatRecordingService.getConversation as ReturnType + ).mockReturnValue(null); + + await generateAndSaveSummary(mockConfig); + + expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); + expect(mockGenerateSummary).not.toHaveBeenCalled(); + expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + }); + + 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 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: [], + }; + + ( + 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 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(); + }); + + 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')); + + // Should not throw + await expect(generateAndSaveSummary(mockConfig)).resolves.not.toThrow(); + + expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1); + expect(mockGenerateSummary).toHaveBeenCalledTimes(1); + expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + }); + }); + + 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 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); + + expect(mockGenerateSummary).toHaveBeenCalledTimes(1); + expect(mockGenerateSummary).toHaveBeenCalledWith({ + messages: mockMessages, + }); + }); + + 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 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); + + expect(mockGenerateSummary).toHaveBeenCalledTimes(1); + expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + }); + + 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); + + expect(mockGenerateSummary).toHaveBeenCalledTimes(1); + expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts new file mode 100644 index 0000000000..23e7567b89 --- /dev/null +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '../config/config.js'; +import { SessionSummaryService } from './sessionSummaryService.js'; +import { BaseLlmClient } from '../core/baseLlmClient.js'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * 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. + */ +export async function generateAndSaveSummary(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; + } + + // Get the current conversation + const conversation = chatRecordingService.getConversation(); + if (!conversation) { + debugLogger.debug('[SessionSummary] No conversation to summarize'); + return; + } + + // Skip if summary already exists (e.g., resumed session) + if (conversation.summary) { + debugLogger.debug('[SessionSummary] Summary already exists, skipping'); + return; + } + + // Skip if no messages + if (conversation.messages.length === 0) { + debugLogger.debug('[SessionSummary] No messages to summarize'); + 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, + }); + + // Save summary if generated successfully + if (summary) { + chatRecordingService.saveSummary(summary); + debugLogger.debug(`[SessionSummary] Saved summary: "${summary}"`); + } else { + debugLogger.warn('[SessionSummary] Failed to generate summary'); + } + } catch (error) { + // Log but don't throw - we want graceful degradation + debugLogger.warn( + `[SessionSummary] Error in generateAndSaveSummary: ${error instanceof Error ? error.message : String(error)}`, + ); + } +}