feat(sessions): use 1-line generated session summary to describe sessions (#14467)

This commit is contained in:
Jack Wotherspoon
2025-12-05 12:20:15 -05:00
committed by GitHub
parent 8341256d1e
commit 616d6f6667
10 changed files with 1639 additions and 4 deletions

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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', () => {

View File

@@ -31,9 +31,9 @@ export async function listSessions(config: Config): Promise<void> {
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}]`,
);

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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<typeof vi.fn>;
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 <Component> 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
});
});
});

View File

@@ -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<string | null> {
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;
}
}
}

View File

@@ -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<typeof vi.fn>;
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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).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<typeof vi.fn>
).mockReturnValue(mockConversation);
mockGenerateSummary.mockRejectedValue(new Error('Generation failed'));
await generateAndSaveSummary(mockConfig);
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
});
});
});

View File

@@ -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<void> {
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)}`,
);
}
}