mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
refactor(sessions): move session summary generation to startup (#14691)
This commit is contained in:
@@ -498,6 +498,20 @@ export async function main() {
|
|||||||
|
|
||||||
// Handle --list-sessions flag
|
// Handle --list-sessions flag
|
||||||
if (config.getListSessions()) {
|
if (config.getListSessions()) {
|
||||||
|
// Attempt auth for summary generation (gracefully skips if not configured)
|
||||||
|
const authType = settings.merged.security?.auth?.selectedType;
|
||||||
|
if (authType) {
|
||||||
|
try {
|
||||||
|
await config.refreshAuth(authType);
|
||||||
|
} catch (e) {
|
||||||
|
// Auth failed - continue without summary generation capability
|
||||||
|
debugLogger.debug(
|
||||||
|
'Auth failed for --list-sessions, summaries may not be generated:',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await listSessions(config);
|
await listSessions(config);
|
||||||
await runExitCleanup();
|
await runExitCleanup();
|
||||||
process.exit(ExitCodes.SUCCESS);
|
process.exit(ExitCodes.SUCCESS);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import {
|
|||||||
SessionEndReason,
|
SessionEndReason,
|
||||||
fireSessionStartHook,
|
fireSessionStartHook,
|
||||||
fireSessionEndHook,
|
fireSessionEndHook,
|
||||||
generateAndSaveSummary,
|
generateSummary,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { validateAuthMethod } from '../config/auth.js';
|
import { validateAuthMethod } from '../config/auth.js';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -312,9 +312,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
: SessionStartSource.Startup;
|
: SessionStartSource.Startup;
|
||||||
await fireSessionStartHook(hookMessageBus, sessionStartSource);
|
await fireSessionStartHook(hookMessageBus, sessionStartSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget: generate summary for previous session in background
|
||||||
|
generateSummary(config).catch((e) => {
|
||||||
|
debugLogger.warn('Background summary generation failed:', e);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
registerCleanup(async () => {
|
registerCleanup(async () => {
|
||||||
await generateAndSaveSummary(config);
|
|
||||||
// Turn off mouse scroll.
|
// Turn off mouse scroll.
|
||||||
disableMouseEvents();
|
disableMouseEvents();
|
||||||
const ideClient = await IdeClient.getInstance();
|
const ideClient = await IdeClient.getInstance();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ vi.mock('@google/gemini-cli-core', async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
ChatRecordingService: vi.fn(),
|
ChatRecordingService: vi.fn(),
|
||||||
|
generateSummary: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ChatRecordingService, type Config } from '@google/gemini-cli-core';
|
import {
|
||||||
|
ChatRecordingService,
|
||||||
|
generateSummary,
|
||||||
|
type Config,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
SessionSelector,
|
SessionSelector,
|
||||||
@@ -12,6 +16,9 @@ import {
|
|||||||
} from './sessionUtils.js';
|
} from './sessionUtils.js';
|
||||||
|
|
||||||
export async function listSessions(config: Config): Promise<void> {
|
export async function listSessions(config: Config): Promise<void> {
|
||||||
|
// Generate summary for most recent session if needed
|
||||||
|
await generateSummary(config);
|
||||||
|
|
||||||
const sessionSelector = new SessionSelector(config);
|
const sessionSelector = new SessionSelector(config);
|
||||||
const sessions = await sessionSelector.listSessions();
|
const sessions = await sessionSelector.listSessions();
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { generateAndSaveSummary } from './sessionSummaryUtils.js';
|
import { generateSummary, getPreviousSession } from './sessionSummaryUtils.js';
|
||||||
import type { Config } from '../config/config.js';
|
import type { Config } from '../config/config.js';
|
||||||
import type { ChatRecordingService } from './chatRecordingService.js';
|
|
||||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||||
import type { GeminiClient } from '../core/client.js';
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
// Mock fs/promises
|
||||||
|
vi.mock('node:fs/promises');
|
||||||
|
const mockReaddir = fs.readdir as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
// Mock the SessionSummaryService module
|
// Mock the SessionSummaryService module
|
||||||
vi.mock('./sessionSummaryService.js', () => ({
|
vi.mock('./sessionSummaryService.js', () => ({
|
||||||
@@ -23,10 +27,24 @@ vi.mock('../core/baseLlmClient.js', () => ({
|
|||||||
BaseLlmClient: vi.fn(),
|
BaseLlmClient: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Helper to create a session with N user messages
|
||||||
|
function createSessionWithUserMessages(
|
||||||
|
count: number,
|
||||||
|
options: { summary?: string; sessionId?: string } = {},
|
||||||
|
) {
|
||||||
|
return JSON.stringify({
|
||||||
|
sessionId: options.sessionId ?? 'session-id',
|
||||||
|
summary: options.summary,
|
||||||
|
messages: Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: String(i + 1),
|
||||||
|
type: 'user',
|
||||||
|
content: [{ text: `Message ${i + 1}` }],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('sessionSummaryUtils', () => {
|
describe('sessionSummaryUtils', () => {
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
let mockChatRecordingService: ChatRecordingService;
|
|
||||||
let mockGeminiClient: GeminiClient;
|
|
||||||
let mockContentGenerator: ContentGenerator;
|
let mockContentGenerator: ContentGenerator;
|
||||||
let mockGenerateSummary: ReturnType<typeof vi.fn>;
|
let mockGenerateSummary: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
@@ -36,23 +54,12 @@ describe('sessionSummaryUtils', () => {
|
|||||||
// Setup mock content generator
|
// Setup mock content generator
|
||||||
mockContentGenerator = {} as ContentGenerator;
|
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
|
// Setup mock config
|
||||||
mockConfig = {
|
mockConfig = {
|
||||||
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
|
||||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
storage: {
|
||||||
|
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
|
||||||
|
},
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
// Setup mock generateSummary function
|
// Setup mock generateSummary function
|
||||||
@@ -73,320 +80,138 @@ describe('sessionSummaryUtils', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Integration Tests', () => {
|
describe('getPreviousSession', () => {
|
||||||
it('should generate and save summary successfully', async () => {
|
it('should return null if chats directory does not exist', async () => {
|
||||||
const mockConversation = {
|
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||||
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...' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
const result = await getPreviousSession(mockConfig);
|
||||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(mockConversation);
|
|
||||||
|
|
||||||
await generateAndSaveSummary(mockConfig);
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
it('should return null if no session files exist', async () => {
|
||||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
expect(mockGenerateSummary).toHaveBeenCalledWith({
|
mockReaddir.mockResolvedValue([]);
|
||||||
messages: mockConversation.messages,
|
|
||||||
});
|
const result = await getPreviousSession(mockConfig);
|
||||||
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockChatRecordingService.saveSummary).toHaveBeenCalledWith(
|
expect(result).toBeNull();
|
||||||
'Add dark mode to the app',
|
});
|
||||||
|
|
||||||
|
it('should return null if most recent session already has summary', async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
createSessionWithUserMessages(5, { summary: 'Existing summary' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getPreviousSession(mockConfig);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if most recent session has 1 or fewer user messages', async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
createSessionWithUserMessages(1),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getPreviousSession(mockConfig);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return path if most recent session has more than 1 user message and no summary', async () => {
|
||||||
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
|
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
createSessionWithUserMessages(2),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await getPreviousSession(mockConfig);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
path.join(
|
||||||
|
'/tmp/project',
|
||||||
|
'chats',
|
||||||
|
'session-2024-01-01T10-00-abc12345.json',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if no chat recording service is available', async () => {
|
it('should select most recently created session by filename', async () => {
|
||||||
(
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
mockGeminiClient.getChatRecordingService as ReturnType<typeof vi.fn>
|
mockReaddir.mockResolvedValue([
|
||||||
).mockReturnValue(undefined);
|
'session-2024-01-01T10-00-older000.json',
|
||||||
|
'session-2024-01-02T10-00-newer000.json',
|
||||||
|
]);
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
createSessionWithUserMessages(2),
|
||||||
|
);
|
||||||
|
|
||||||
await generateAndSaveSummary(mockConfig);
|
const result = await getPreviousSession(mockConfig);
|
||||||
|
|
||||||
expect(mockGeminiClient.getChatRecordingService).toHaveBeenCalledTimes(1);
|
expect(result).toBe(
|
||||||
expect(mockGenerateSummary).not.toHaveBeenCalled();
|
path.join(
|
||||||
expect(mockChatRecordingService.saveSummary).not.toHaveBeenCalled();
|
'/tmp/project',
|
||||||
|
'chats',
|
||||||
|
'session-2024-01-02T10-00-newer000.json',
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if no conversation exists', async () => {
|
it('should return null if most recent session file is corrupted', async () => {
|
||||||
(
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||||
).mockReturnValue(null);
|
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
|
||||||
|
|
||||||
await generateAndSaveSummary(mockConfig);
|
const result = await getPreviousSession(mockConfig);
|
||||||
|
|
||||||
expect(mockChatRecordingService.getConversation).toHaveBeenCalledTimes(1);
|
expect(result).toBeNull();
|
||||||
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', () => {
|
describe('generateSummary', () => {
|
||||||
it('should call getConversation() once', async () => {
|
it('should not throw if getPreviousSession returns null', async () => {
|
||||||
const mockConversation = {
|
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
|
||||||
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' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
await expect(generateSummary(mockConfig)).resolves.not.toThrow();
|
||||||
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 () => {
|
it('should generate and save summary for session needing one', async () => {
|
||||||
const mockMessages = [
|
const sessionPath = path.join(
|
||||||
{
|
'/tmp/project',
|
||||||
id: '1',
|
'chats',
|
||||||
timestamp: '2025-12-03T00:00:00Z',
|
'session-2024-01-01T10-00-abc12345.json',
|
||||||
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 = {
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
sessionId: 'test-session',
|
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||||
projectHash: 'test-hash',
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
startTime: '2025-12-03T00:00:00Z',
|
createSessionWithUserMessages(2),
|
||||||
lastUpdated: '2025-12-03T00:10:00Z',
|
);
|
||||||
messages: mockMessages,
|
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||||
};
|
|
||||||
|
|
||||||
(
|
await generateSummary(mockConfig);
|
||||||
mockChatRecordingService.getConversation as ReturnType<typeof vi.fn>
|
|
||||||
).mockReturnValue(mockConversation);
|
|
||||||
|
|
||||||
await generateAndSaveSummary(mockConfig);
|
|
||||||
|
|
||||||
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
expect(mockGenerateSummary).toHaveBeenCalledTimes(1);
|
||||||
expect(mockGenerateSummary).toHaveBeenCalledWith({
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
messages: mockMessages,
|
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||||
});
|
sessionPath,
|
||||||
});
|
expect.stringContaining('Add dark mode to the app'),
|
||||||
|
|
||||||
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 () => {
|
it('should handle errors gracefully without throwing', async () => {
|
||||||
const mockConversation = {
|
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||||
sessionId: 'test-session',
|
mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']);
|
||||||
projectHash: 'test-hash',
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
startTime: '2025-12-03T00:00:00Z',
|
createSessionWithUserMessages(2),
|
||||||
lastUpdated: '2025-12-03T00:10:00Z',
|
);
|
||||||
messages: [
|
mockGenerateSummary.mockRejectedValue(new Error('API Error'));
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
timestamp: '2025-12-03T00:00:00Z',
|
|
||||||
type: 'user' as const,
|
|
||||||
content: [{ text: 'Hello' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
(
|
await expect(generateSummary(mockConfig)).resolves.not.toThrow();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,62 +8,172 @@ import type { Config } from '../config/config.js';
|
|||||||
import { SessionSummaryService } from './sessionSummaryService.js';
|
import { SessionSummaryService } from './sessionSummaryService.js';
|
||||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||||
import { debugLogger } from '../utils/debugLogger.js';
|
import { debugLogger } from '../utils/debugLogger.js';
|
||||||
|
import {
|
||||||
|
SESSION_FILE_PREFIX,
|
||||||
|
type ConversationRecord,
|
||||||
|
} from './chatRecordingService.js';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const MIN_MESSAGES_FOR_SUMMARY = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates and saves a summary for the current session.
|
* Generates and saves a summary for a session file.
|
||||||
* 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> {
|
async function generateAndSaveSummary(
|
||||||
|
config: Config,
|
||||||
|
sessionPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Read session file
|
||||||
|
const content = await fs.readFile(sessionPath, 'utf-8');
|
||||||
|
const conversation: ConversationRecord = JSON.parse(content);
|
||||||
|
|
||||||
|
// Skip if summary already exists
|
||||||
|
if (conversation.summary) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[SessionSummary] Summary already exists for ${sessionPath}, skipping`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no messages
|
||||||
|
if (conversation.messages.length === 0) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[SessionSummary] No messages to summarize in ${sessionPath}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create summary service
|
||||||
|
const contentGenerator = config.getContentGenerator();
|
||||||
|
if (!contentGenerator) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[SessionSummary] Content generator not available, skipping summary generation',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
|
||||||
|
const summaryService = new SessionSummaryService(baseLlmClient);
|
||||||
|
|
||||||
|
// Generate summary
|
||||||
|
const summary = await summaryService.generateSummary({
|
||||||
|
messages: conversation.messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
debugLogger.warn(
|
||||||
|
`[SessionSummary] Failed to generate summary for ${sessionPath}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read the file before writing to handle race conditions
|
||||||
|
const freshContent = await fs.readFile(sessionPath, 'utf-8');
|
||||||
|
const freshConversation: ConversationRecord = JSON.parse(freshContent);
|
||||||
|
|
||||||
|
// Check if summary was added by another process
|
||||||
|
if (freshConversation.summary) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[SessionSummary] Summary was added by another process for ${sessionPath}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add summary and write back
|
||||||
|
freshConversation.summary = summary;
|
||||||
|
freshConversation.lastUpdated = new Date().toISOString();
|
||||||
|
await fs.writeFile(sessionPath, JSON.stringify(freshConversation, null, 2));
|
||||||
|
debugLogger.debug(
|
||||||
|
`[SessionSummary] Saved summary for ${sessionPath}: "${summary}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the most recently created session that needs a summary.
|
||||||
|
* Returns the path if it needs a summary, null otherwise.
|
||||||
|
*/
|
||||||
|
export async function getPreviousSession(
|
||||||
|
config: Config,
|
||||||
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// Get the chat recording service from config
|
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||||
const chatRecordingService = config
|
|
||||||
.getGeminiClient()
|
// Check if chats directory exists
|
||||||
?.getChatRecordingService();
|
try {
|
||||||
if (!chatRecordingService) {
|
await fs.access(chatsDir);
|
||||||
debugLogger.debug('[SessionSummary] No chat recording service available');
|
} catch {
|
||||||
return;
|
debugLogger.debug('[SessionSummary] No chats directory found');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current conversation
|
// List session files
|
||||||
const conversation = chatRecordingService.getConversation();
|
const allFiles = await fs.readdir(chatsDir);
|
||||||
if (!conversation) {
|
const sessionFiles = allFiles.filter(
|
||||||
debugLogger.debug('[SessionSummary] No conversation to summarize');
|
(f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'),
|
||||||
return;
|
);
|
||||||
|
|
||||||
|
if (sessionFiles.length === 0) {
|
||||||
|
debugLogger.debug('[SessionSummary] No session files found');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if summary already exists (e.g., resumed session)
|
// Sort by filename descending (most recently created first)
|
||||||
if (conversation.summary) {
|
// Filename format: session-YYYY-MM-DDTHH-MM-XXXXXXXX.json
|
||||||
debugLogger.debug('[SessionSummary] Summary already exists, skipping');
|
sessionFiles.sort((a, b) => b.localeCompare(a));
|
||||||
return;
|
|
||||||
|
// Check the most recently created session
|
||||||
|
const mostRecentFile = sessionFiles[0];
|
||||||
|
const filePath = path.join(chatsDir, mostRecentFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const conversation: ConversationRecord = JSON.parse(content);
|
||||||
|
|
||||||
|
if (conversation.summary) {
|
||||||
|
debugLogger.debug(
|
||||||
|
'[SessionSummary] Most recent session already has summary',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only generate summaries for sessions with more than 1 user message
|
||||||
|
const userMessageCount = conversation.messages.filter(
|
||||||
|
(m) => m.type === 'user',
|
||||||
|
).length;
|
||||||
|
if (userMessageCount <= MIN_MESSAGES_FOR_SUMMARY) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[SessionSummary] Most recent session has ${userMessageCount} user message(s), skipping (need more than ${MIN_MESSAGES_FOR_SUMMARY})`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
} catch {
|
||||||
|
debugLogger.debug('[SessionSummary] Could not read most recent session');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`[SessionSummary] Error finding previous session: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if no messages
|
/**
|
||||||
if (conversation.messages.length === 0) {
|
* Generates summary for the previous session if it lacks one.
|
||||||
debugLogger.debug('[SessionSummary] No messages to summarize');
|
* This is designed to be called fire-and-forget on startup.
|
||||||
return;
|
*/
|
||||||
}
|
export async function generateSummary(config: Config): Promise<void> {
|
||||||
|
try {
|
||||||
// Create summary service
|
const sessionPath = await getPreviousSession(config);
|
||||||
const contentGenerator = config.getContentGenerator();
|
if (sessionPath) {
|
||||||
const baseLlmClient = new BaseLlmClient(contentGenerator, config);
|
await generateAndSaveSummary(config, sessionPath);
|
||||||
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) {
|
} catch (error) {
|
||||||
// Log but don't throw - we want graceful degradation
|
// Log but don't throw - we want graceful degradation
|
||||||
debugLogger.warn(
|
debugLogger.warn(
|
||||||
`[SessionSummary] Error in generateAndSaveSummary: ${error instanceof Error ? error.message : String(error)}`,
|
`[SessionSummary] Error generating summary: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user