mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
feat(sessions): use 1-line generated session summary to describe sessions (#14467)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}]`,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
938
packages/core/src/services/sessionSummaryService.test.ts
Normal file
938
packages/core/src/services/sessionSummaryService.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
162
packages/core/src/services/sessionSummaryService.ts
Normal file
162
packages/core/src/services/sessionSummaryService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
392
packages/core/src/services/sessionSummaryUtils.test.ts
Normal file
392
packages/core/src/services/sessionSummaryUtils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
packages/core/src/services/sessionSummaryUtils.ts
Normal file
69
packages/core/src/services/sessionSummaryUtils.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user