From 974ab66b7ad1314f3a9454df400e7f4e6825b2c7 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Mon, 6 Oct 2025 13:34:00 -0600 Subject: [PATCH] feat(sessions): Add automatic session cleanup and retention policy (#7662) --- packages/cli/src/config/config.test.ts | 4 + packages/cli/src/config/settings.ts | 14 + packages/cli/src/config/settingsSchema.ts | 50 + packages/cli/src/gemini.tsx | 11 +- .../SettingsDialog.test.tsx.snap | 80 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 3 + packages/cli/src/ui/hooks/useGeminiStream.ts | 18 + .../utils/sessionCleanup.integration.test.ts | 251 +++ packages/cli/src/utils/sessionCleanup.test.ts | 1631 +++++++++++++++++ packages/cli/src/utils/sessionCleanup.ts | 299 +++ packages/cli/src/utils/sessionUtils.ts | 120 ++ packages/core/src/core/client.ts | 4 + packages/core/src/core/geminiChat.ts | 28 + .../core/src/services/chatRecordingService.ts | 6 +- 14 files changed, 2473 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/utils/sessionCleanup.integration.test.ts create mode 100644 packages/cli/src/utils/sessionCleanup.test.ts create mode 100644 packages/cli/src/utils/sessionCleanup.ts create mode 100644 packages/cli/src/utils/sessionUtils.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 6de5c27865..af2e0ff658 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -28,6 +28,10 @@ vi.mock('./trustedFolders.js', () => ({ .mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted })); +vi.mock('./sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('fs', async (importOriginal) => { const actualFs = await importOriginal(); const pathMod = await import('node:path'); diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 197ef0fb5a..26b67c5556 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -170,6 +170,20 @@ export interface AccessibilitySettings { screenReader?: boolean; } +export interface SessionRetentionSettings { + /** Enable automatic session cleanup */ + enabled?: boolean; + + /** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */ + maxAge?: string; + + /** Alternative: Maximum number of sessions to keep (most recent) */ + maxCount?: number; + + /** Minimum retention period (safety limit, defaults to "1d") */ + minRetention?: string; +} + export interface SettingsError { message: string; path: string; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0814141f68..8765b9d0e9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -16,6 +16,8 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from '@google/gemini-cli-core'; import type { CustomTheme } from '../ui/themes/theme.js'; +import type { SessionRetentionSettings } from './settings.js'; +import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js'; export type SettingsType = | 'boolean' @@ -185,6 +187,54 @@ const SETTINGS_SCHEMA = { description: 'Enable debug logging of keystrokes to the console.', showInDialog: true, }, + sessionRetention: { + type: 'object', + label: 'Session Retention', + category: 'General', + requiresRestart: false, + default: undefined as SessionRetentionSettings | undefined, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Session Cleanup', + category: 'General', + requiresRestart: false, + default: false, + description: 'Enable automatic session cleanup', + showInDialog: true, + }, + maxAge: { + type: 'string', + label: 'Max Session Age', + category: 'General', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w")', + showInDialog: false, + }, + maxCount: { + type: 'number', + label: 'Max Session Count', + category: 'General', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Alternative: Maximum number of sessions to keep (most recent)', + showInDialog: false, + }, + minRetention: { + type: 'string', + label: 'Min Retention Period', + category: 'General', + requiresRestart: false, + default: DEFAULT_MIN_RETENTION, + description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, + showInDialog: false, + }, + }, + description: 'Settings for automatic session cleanup.', + }, }, }, output: { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index a2cea7c18d..40813d7364 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -46,6 +46,8 @@ import { } from './core/initializer.js'; import { validateAuthMethod } from './config/auth.js'; import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; +import { runZedIntegration } from './zed-integration/zedIntegration.js'; +import { cleanupExpiredSessions } from './utils/sessionCleanup.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; @@ -62,6 +64,8 @@ import { relaunchAppInChildProcess, relaunchOnExitCode, } from './utils/relaunch.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js'; export function validateDnsResolutionOrder( order: string | undefined, @@ -111,10 +115,6 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { return []; } -import { runZedIntegration } from './zed-integration/zedIntegration.js'; -import { loadSandboxConfig } from './config/sandboxConfig.js'; -import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js'; - export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; process.on('unhandledRejection', (reason, _promise) => { @@ -351,6 +351,9 @@ export async function main() { argv, ); + // Cleanup sessions after config initialization + await cleanupExpiredSessions(config, settings.merged); + if (config.getListExtensions()) { console.log('Installed extensions:'); for (const extension of extensions) { diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 5e528375fd..e371e38da3 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -49,14 +49,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -84,14 +84,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -119,14 +119,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Debug Keystroke Logging false* │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false* │ -│ │ │ ▼ │ │ │ │ │ @@ -154,14 +154,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -189,14 +189,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Debug Keystroke Logging (Modified in Workspace) false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -224,14 +224,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -259,14 +259,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -294,14 +294,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Debug Keystroke Logging false │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips false │ -│ │ │ ▼ │ │ │ │ │ @@ -329,14 +329,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Debug Keystroke Logging true* │ │ │ +│ Session Retention undefined │ +│ │ +│ Enable Session Cleanup false │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ │ │ -│ Show Status in Title false │ -│ │ -│ Hide Tips true* │ -│ │ │ ▼ │ │ │ │ │ diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index bdbc1d922d..b0421a7f92 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -50,6 +50,9 @@ const MockedGeminiClientClass = vi.hoisted(() => this.startChat = mockStartChat; this.sendMessageStream = mockSendMessageStream; this.addHistory = vi.fn(); + this.getChat = vi.fn().mockReturnValue({ + recordCompletedToolCalls: vi.fn(), + }); this.getChatRecordingService = vi.fn().mockReturnValue({ recordThought: vi.fn(), initialize: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 3e38af0cc7..96d5e49f1e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -137,6 +137,24 @@ export const useGeminiStream = ( Date.now(), ); + // Record tool calls with full metadata before sending responses. + try { + const currentModel = + config.getGeminiClient().getCurrentSequenceModel() ?? + config.getModel(); + config + .getGeminiClient() + .getChat() + .recordCompletedToolCalls( + currentModel, + completedToolCallsFromScheduler, + ); + } catch (error) { + console.error( + `Error recording completed tool call information: ${error}`, + ); + } + // Handle tool response submission immediately when tools complete await handleCompletedTools( completedToolCallsFromScheduler as TrackedToolCall[], diff --git a/packages/cli/src/utils/sessionCleanup.integration.test.ts b/packages/cli/src/utils/sessionCleanup.integration.test.ts new file mode 100644 index 0000000000..2b02ea97bc --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.integration.test.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { cleanupExpiredSessions } from './sessionCleanup.js'; +import type { Settings } from '../config/settings.js'; +import { SESSION_FILE_PREFIX, type Config } from '@google/gemini-cli-core'; + +// Create a mock config for integration testing +function createTestConfig(): Config { + return { + storage: { + getProjectTempDir: () => '/tmp/nonexistent-test-dir', + }, + getSessionId: () => 'test-session-id', + getDebugMode: () => false, + initialize: async () => undefined, + } as unknown as Config; +} + +describe('Session Cleanup Integration', () => { + it('should gracefully handle non-existent directories', async () => { + const config = createTestConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // Should return empty result for non-existent directory + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should not impact startup when disabled', async () => { + const config = createTestConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: false, + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should handle missing sessionRetention configuration', async () => { + // Create test session files to verify they are NOT deleted when config is missing + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create an old session file that would normally be deleted + const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // 60 days ago + const sessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-00-test123.json`, + ); + await fs.writeFile( + sessionFile, + JSON.stringify({ + sessionId: 'test123', + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + const config = createTestConfig(); + config.storage.getProjectTempDir = vi.fn().mockReturnValue(tempDir); + + const settings: Settings = {}; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); // Should not even scan when config is missing + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + + // Verify the session file still exists (was not deleted) + const filesAfter = await fs.readdir(chatsDir); + expect(filesAfter).toContain( + `${SESSION_FILE_PREFIX}2024-01-01T10-00-00-test123.json`, + ); + + // Cleanup + await fs.rm(tempDir, { recursive: true }); + }); + + it('should validate configuration and fail gracefully', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const config = createTestConfig(); + + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: 'invalid-format', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + + // Verify error logging provides visibility into the validation failure + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Session cleanup disabled: Error: Invalid retention period format', + ), + ); + + errorSpy.mockRestore(); + }); + + it('should clean up expired sessions when they exist', async () => { + // Create a temporary directory with test sessions + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create test session files with different ages + const now = new Date(); + const oldDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000); // 35 days ago + const recentDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago + + // Create an old session file that should be deleted + const oldSessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-12-01T10-00-00-old12345.json`, + ); + await fs.writeFile( + oldSessionFile, + JSON.stringify({ + sessionId: 'old12345', + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + // Create a recent session file that should be kept + const recentSessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2025-01-15T10-00-00-recent789.json`, + ); + await fs.writeFile( + recentSessionFile, + JSON.stringify({ + sessionId: 'recent789', + messages: [], + startTime: recentDate.toISOString(), + lastUpdated: recentDate.toISOString(), + }), + ); + + // Create a current session file that should always be kept + const currentSessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-current123.json`, + ); + await fs.writeFile( + currentSessionFile, + JSON.stringify({ + sessionId: 'current123', + messages: [], + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + }), + ); + + // Configure test with real temp directory + const config: Config = { + storage: { + getProjectTempDir: () => tempDir, + }, + getSessionId: () => 'current123', + getDebugMode: () => false, + initialize: async () => undefined, + } as unknown as Config; + + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', // Keep sessions for 30 days + }, + }, + }; + + try { + const result = await cleanupExpiredSessions(config, settings); + + // Verify the result + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(3); // Should scan all 3 sessions + expect(result.deleted).toBe(1); // Should delete the old session (35 days old) + expect(result.skipped).toBe(2); // Should keep recent and current sessions + expect(result.failed).toBe(0); + + // Verify files on disk + const remainingFiles = await fs.readdir(chatsDir); + expect(remainingFiles).toHaveLength(2); // Only 2 files should remain + expect(remainingFiles).toContain( + `${SESSION_FILE_PREFIX}2025-01-15T10-00-00-recent789.json`, + ); + expect(remainingFiles).toContain( + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-current123.json`, + ); + expect(remainingFiles).not.toContain( + `${SESSION_FILE_PREFIX}2024-12-01T10-00-00-old12345.json`, + ); + } finally { + // Clean up test directory + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts new file mode 100644 index 0000000000..01939df5ea --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -0,0 +1,1631 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { SESSION_FILE_PREFIX, type Config } from '@google/gemini-cli-core'; +import type { Settings } from '../config/settings.js'; +import { cleanupExpiredSessions } from './sessionCleanup.js'; +import { type SessionInfo, getAllSessionFiles } from './sessionUtils.js'; + +// Mock the fs module +vi.mock('fs/promises'); +vi.mock('./sessionUtils.js', () => ({ + getAllSessionFiles: vi.fn(), +})); + +const mockFs = vi.mocked(fs); +const mockGetAllSessionFiles = vi.mocked(getAllSessionFiles); + +// Create mock config +function createMockConfig(overrides: Partial = {}): Config { + return { + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), + }, + getSessionId: vi.fn().mockReturnValue('current123'), + getDebugMode: vi.fn().mockReturnValue(false), + initialize: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as Config; +} + +// Create test session data +function createTestSessions(): SessionInfo[] { + const now = new Date(); + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + return [ + { + id: 'current123', + fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, + lastUpdated: now.toISOString(), + isCurrentSession: true, + }, + { + id: 'recent456', + fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`, + lastUpdated: oneWeekAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'old789abc', + fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`, + lastUpdated: twoWeeksAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'ancient12', + fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`, + lastUpdated: oneMonthAgo.toISOString(), + isCurrentSession: false, + }, + ]; +} + +describe('Session Cleanup', () => { + beforeEach(() => { + vi.clearAllMocks(); + // By default, return all test sessions as valid + const sessions = createTestSessions(); + mockGetAllSessionFiles.mockResolvedValue( + sessions.map((session) => ({ + fileName: session.fileName, + sessionInfo: session, + })), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('cleanupExpiredSessions', () => { + it('should return early when cleanup is disabled', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: false } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should return early when sessionRetention is not configured', async () => { + const config = createMockConfig(); + const settings: Settings = {}; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + }); + + it('should handle invalid maxAge configuration', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: 'invalid-format', + }, + }, + }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Session cleanup disabled: Error: Invalid retention period format', + ), + ); + + errorSpy.mockRestore(); + }); + + it('should delete sessions older than maxAge', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '10d', // 10 days + }, + }, + }; + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(4); + expect(result.deleted).toBe(2); // Should delete the 2-week-old and 1-month-old sessions + expect(result.skipped).toBe(2); // Current session + recent session should be skipped + expect(result.failed).toBe(0); + }); + + it('should never delete current session', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', // Very short retention + }, + }, + }; + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + // Should delete all sessions except the current one + expect(result.disabled).toBe(false); + expect(result.deleted).toBe(3); + + // Verify that unlink was never called with the current session file + const unlinkCalls = mockFs.unlink.mock.calls; + const currentSessionPath = path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, + ); + expect( + unlinkCalls.find((call) => call[0] === currentSessionPath), + ).toBeUndefined(); + }); + + it('should handle count-based retention', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 2, // Keep only 2 most recent sessions + }, + }, + }; + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(4); + expect(result.deleted).toBe(2); // Should delete 2 oldest sessions (after skipping the current one) + expect(result.skipped).toBe(2); // Current session + 1 recent session should be kept + }); + + it('should handle file system errors gracefully', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', + }, + }, + }; + + // Mock file operations to succeed for access and readFile but fail for unlink + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockRejectedValue(new Error('Permission denied')); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(4); + expect(result.deleted).toBe(0); + expect(result.failed).toBeGreaterThan(0); + }); + + it('should handle empty sessions directory', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should handle global errors gracefully', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', + }, + }, + }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Mock getSessionFiles to throw an error + mockGetAllSessionFiles.mockRejectedValue( + new Error('Directory access failed'), + ); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.failed).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + 'Session cleanup failed: Directory access failed', + ); + + errorSpy.mockRestore(); + }); + + it('should respect minRetention configuration', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '12h', // Less than 1 day minimum + minRetention: '1d', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // Should disable cleanup due to minRetention violation + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + }); + + it('should log debug information when enabled', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '10d', + }, + }, + }; + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + await cleanupExpiredSessions(config, settings); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Session cleanup: deleted'), + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Deleted expired session:'), + ); + + debugSpy.mockRestore(); + }); + }); + + describe('Specific cleanup scenarios', () => { + it('should delete sessions that exceed the cutoff date', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', // Keep sessions for 7 days + }, + }, + }; + + // Create sessions with specific dates + const now = new Date(); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000); + const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); + + const testSessions: SessionInfo[] = [ + { + id: 'current', + fileName: `${SESSION_FILE_PREFIX}current.json`, + lastUpdated: now.toISOString(), + isCurrentSession: true, + }, + { + id: 'session5d', + fileName: `${SESSION_FILE_PREFIX}5d.json`, + lastUpdated: fiveDaysAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session8d', + fileName: `${SESSION_FILE_PREFIX}8d.json`, + lastUpdated: eightDaysAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session15d', + fileName: `${SESSION_FILE_PREFIX}15d.json`, + lastUpdated: fifteenDaysAgo.toISOString(), + isCurrentSession: false, + }, + ]; + + mockGetAllSessionFiles.mockResolvedValue( + testSessions.map((session) => ({ + fileName: session.fileName, + sessionInfo: session, + })), + ); + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + // Should delete sessions older than 7 days (8d and 15d sessions) + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(4); + expect(result.deleted).toBe(2); + expect(result.skipped).toBe(2); // Current + 5d session + + // Verify which files were deleted + const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}8d.json`, + ), + ); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}15d.json`, + ), + ); + expect(unlinkCalls).not.toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}5d.json`, + ), + ); + }); + + it('should NOT delete sessions within the cutoff date', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '14d', // Keep sessions for 14 days + }, + }, + }; + + // Create sessions all within the retention period + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const thirteenDaysAgo = new Date( + now.getTime() - 13 * 24 * 60 * 60 * 1000, + ); + + const testSessions: SessionInfo[] = [ + { + id: 'current', + fileName: `${SESSION_FILE_PREFIX}current.json`, + lastUpdated: now.toISOString(), + isCurrentSession: true, + }, + { + id: 'session1d', + fileName: `${SESSION_FILE_PREFIX}1d.json`, + lastUpdated: oneDayAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session7d', + fileName: `${SESSION_FILE_PREFIX}7d.json`, + lastUpdated: sevenDaysAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session13d', + fileName: `${SESSION_FILE_PREFIX}13d.json`, + lastUpdated: thirteenDaysAgo.toISOString(), + isCurrentSession: false, + }, + ]; + + mockGetAllSessionFiles.mockResolvedValue( + testSessions.map((session) => ({ + fileName: session.fileName, + sessionInfo: session, + })), + ); + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + // Should NOT delete any sessions as all are within 14 days + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(4); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(4); + expect(result.failed).toBe(0); + + // Verify no files were deleted + expect(mockFs.unlink).not.toHaveBeenCalled(); + }); + + it('should keep N most recent deletable sessions', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 3, // Keep only 3 most recent sessions + }, + }, + }; + + // Create 6 sessions with different timestamps + const now = new Date(); + const sessions: SessionInfo[] = [ + { + id: 'current', + fileName: `${SESSION_FILE_PREFIX}current.json`, + lastUpdated: now.toISOString(), + isCurrentSession: true, + }, + ]; + + // Add 5 more sessions with decreasing timestamps + for (let i = 1; i <= 5; i++) { + const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); + sessions.push({ + id: `session${i}`, + fileName: `${SESSION_FILE_PREFIX}${i}d.json`, + lastUpdated: daysAgo.toISOString(), + isCurrentSession: false, + }); + } + + mockGetAllSessionFiles.mockResolvedValue( + sessions.map((session) => ({ + fileName: session.fileName, + sessionInfo: session, + })), + ); + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + // Should keep current + 2 most recent (1d and 2d), delete 3d, 4d, 5d + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(6); + expect(result.deleted).toBe(3); + expect(result.skipped).toBe(3); + + // Verify which files were deleted (should be the 3 oldest) + const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}3d.json`, + ), + ); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}4d.json`, + ), + ); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}5d.json`, + ), + ); + + // Verify which files were NOT deleted + expect(unlinkCalls).not.toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}current.json`, + ), + ); + expect(unlinkCalls).not.toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}1d.json`, + ), + ); + expect(unlinkCalls).not.toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}2d.json`, + ), + ); + }); + + it('should handle combined maxAge and maxCount retention (most restrictive wins)', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '10d', // Keep sessions for 10 days + maxCount: 2, // But also keep only 2 most recent + }, + }, + }; + + // Create sessions where maxCount is more restrictive + const now = new Date(); + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000); + + const testSessions: SessionInfo[] = [ + { + id: 'current', + fileName: `${SESSION_FILE_PREFIX}current.json`, + lastUpdated: now.toISOString(), + isCurrentSession: true, + }, + { + id: 'session3d', + fileName: `${SESSION_FILE_PREFIX}3d.json`, + lastUpdated: threeDaysAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session5d', + fileName: `${SESSION_FILE_PREFIX}5d.json`, + lastUpdated: fiveDaysAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session7d', + fileName: `${SESSION_FILE_PREFIX}7d.json`, + lastUpdated: sevenDaysAgo.toISOString(), + isCurrentSession: false, + }, + { + id: 'session12d', + fileName: `${SESSION_FILE_PREFIX}12d.json`, + lastUpdated: twelveDaysAgo.toISOString(), + isCurrentSession: false, + }, + ]; + + mockGetAllSessionFiles.mockResolvedValue( + testSessions.map((session) => ({ + fileName: session.fileName, + sessionInfo: session, + })), + ); + + // Mock successful file operations + mockFs.access.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ + sessionId: 'test', + messages: [], + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + }), + ); + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + // Should delete: + // - session12d (exceeds maxAge of 10d) + // - session7d and session5d (exceed maxCount of 2, keeping current + 3d) + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(5); + expect(result.deleted).toBe(3); + expect(result.skipped).toBe(2); // Current + 3d session + + // Verify which files were deleted + const unlinkCalls = mockFs.unlink.mock.calls.map((call) => call[0]); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}5d.json`, + ), + ); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}7d.json`, + ), + ); + expect(unlinkCalls).toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}12d.json`, + ), + ); + + // Verify which files were NOT deleted + expect(unlinkCalls).not.toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}current.json`, + ), + ); + expect(unlinkCalls).not.toContain( + path.join( + '/tmp/test-project', + 'chats', + `${SESSION_FILE_PREFIX}3d.json`, + ), + ); + }); + }); + + describe('parseRetentionPeriod format validation', () => { + // Test all supported formats + it.each([ + ['1h', 60 * 60 * 1000], + ['24h', 24 * 60 * 60 * 1000], + ['168h', 168 * 60 * 60 * 1000], + ['1d', 24 * 60 * 60 * 1000], + ['7d', 7 * 24 * 60 * 60 * 1000], + ['30d', 30 * 24 * 60 * 60 * 1000], + ['365d', 365 * 24 * 60 * 60 * 1000], + ['1w', 7 * 24 * 60 * 60 * 1000], + ['2w', 14 * 24 * 60 * 60 * 1000], + ['4w', 28 * 24 * 60 * 60 * 1000], + ['52w', 364 * 24 * 60 * 60 * 1000], + ['1m', 30 * 24 * 60 * 60 * 1000], + ['3m', 90 * 24 * 60 * 60 * 1000], + ['6m', 180 * 24 * 60 * 60 * 1000], + ['12m', 360 * 24 * 60 * 60 * 1000], + ])('should correctly parse valid format %s', async (input) => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: input, + // Set minRetention to 1h to allow testing of hour-based maxAge values + minRetention: '1h', + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + // If it parses correctly, cleanup should proceed without error + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + expect(result.failed).toBe(0); + }); + + // Test invalid formats + it.each([ + '30', // Missing unit + '30x', // Invalid unit + 'd', // No number + '1.5d', // Decimal not supported + '-5d', // Negative number + '1 d', // Space in format + '1dd', // Double unit + 'abc', // Non-numeric + '30s', // Unsupported unit (seconds) + '30y', // Unsupported unit (years) + '0d', // Zero value (technically valid regex but semantically invalid) + ])('should reject invalid format %s', async (input) => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: input, + }, + }, + }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + input === '0d' + ? 'Invalid retention period: 0d. Value must be greater than 0' + : `Invalid retention period format: ${input}`, + ), + ); + + errorSpy.mockRestore(); + }); + + // Test special case - empty string + it('should reject empty string', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '', + }, + }, + }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + // Empty string means no valid retention method specified + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Either maxAge or maxCount must be specified'), + ); + + errorSpy.mockRestore(); + }); + + // Test edge cases + it('should handle very large numbers', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '9999d', // Very large number + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + expect(result.failed).toBe(0); + }); + + it('should validate minRetention format', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '5d', + minRetention: 'invalid-format', // Invalid minRetention + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + // Should fall back to default minRetention and proceed + const result = await cleanupExpiredSessions(config, settings); + + // Since maxAge (5d) > default minRetention (1d), this should succeed + expect(result.disabled).toBe(false); + expect(result.failed).toBe(0); + }); + }); + + describe('Configuration validation', () => { + it('should require either maxAge or maxCount', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + // Neither maxAge nor maxCount specified + }, + }, + }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Either maxAge or maxCount must be specified'), + ); + + errorSpy.mockRestore(); + }); + + it('should validate maxCount range', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 0, // Invalid count + }, + }, + }; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('maxCount must be at least 1'), + ); + + errorSpy.mockRestore(); + }); + + describe('maxAge format validation', () => { + it('should reject invalid maxAge format - no unit', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30', // Missing unit + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format: 30'), + ); + + errorSpy.mockRestore(); + }); + + it('should reject invalid maxAge format - invalid unit', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30x', // Invalid unit 'x' + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format: 30x'), + ); + + errorSpy.mockRestore(); + }); + + it('should reject invalid maxAge format - no number', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: 'd', // No number + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format: d'), + ); + + errorSpy.mockRestore(); + }); + + it('should reject invalid maxAge format - decimal number', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1.5d', // Decimal not supported + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format: 1.5d'), + ); + + errorSpy.mockRestore(); + }); + + it('should reject invalid maxAge format - negative number', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '-5d', // Negative not allowed + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format: -5d'), + ); + + errorSpy.mockRestore(); + }); + + it('should accept valid maxAge format - hours', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '48h', // Valid: 48 hours + maxCount: 10, // Need at least one valid retention method + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should accept valid maxAge format - days', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', // Valid: 7 days + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should accept valid maxAge format - weeks', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '2w', // Valid: 2 weeks + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should accept valid maxAge format - months', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '3m', // Valid: 3 months + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + }); + + describe('minRetention validation', () => { + it('should reject maxAge less than default minRetention (1d)', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '12h', // Less than default 1d minRetention + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'maxAge cannot be less than minRetention (1d)', + ), + ); + + errorSpy.mockRestore(); + }); + + it('should reject maxAge less than custom minRetention', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '2d', + minRetention: '3d', // maxAge < minRetention + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'maxAge cannot be less than minRetention (3d)', + ), + ); + + errorSpy.mockRestore(); + }); + + it('should accept maxAge equal to minRetention', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '2d', + minRetention: '2d', // maxAge == minRetention (edge case) + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should accept maxAge greater than minRetention', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', + minRetention: '2d', // maxAge > minRetention + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should handle invalid minRetention format gracefully', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '5d', + minRetention: 'invalid', // Invalid format + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + // When minRetention is invalid, it should default to 1d + // Since maxAge (5d) > default minRetention (1d), this should be valid + const result = await cleanupExpiredSessions(config, settings); + + // Should not reject due to minRetention (falls back to default) + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + }); + + describe('maxCount boundary validation', () => { + it('should accept maxCount = 1 (minimum valid)', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 1, // Minimum valid value + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should accept the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should accept maxCount = 1000 (maximum valid)', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 1000, // Maximum valid value + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should accept the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should reject negative maxCount', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: -1, // Negative value + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('maxCount must be at least 1'), + ); + + errorSpy.mockRestore(); + }); + + it('should accept valid maxCount in normal range', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 50, // Normal valid value + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should accept the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + }); + + describe('combined configuration validation', () => { + it('should accept valid maxAge and maxCount together', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', + maxCount: 10, + }, + }, + }; + + mockGetAllSessionFiles.mockResolvedValue([]); + + const result = await cleanupExpiredSessions(config, settings); + + // Should accept the configuration + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should reject if both maxAge and maxCount are invalid', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: 'invalid', + maxCount: 0, + }, + }, + }; + + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + // Should fail on first validation error (maxAge format) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format'), + ); + + errorSpy.mockRestore(); + }); + + it('should reject if maxAge is invalid even when maxCount is valid', async () => { + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: 'invalid', // Invalid format + maxCount: 5, // Valid count + }, + }, + }; + + // The validation logic rejects invalid maxAge format even if maxCount is valid + const errorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const result = await cleanupExpiredSessions(config, settings); + + // Should reject due to invalid maxAge format + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format'), + ); + + errorSpy.mockRestore(); + }); + }); + + it('should never throw an exception, always returning a result', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', + }, + }, + }; + + // Mock getSessionFiles to throw an error + mockGetAllSessionFiles.mockRejectedValue( + new Error('Failed to read directory'), + ); + + // Should not throw, should return a result with errors + const result = await cleanupExpiredSessions(config, settings); + + expect(result).toBeDefined(); + expect(result.disabled).toBe(false); + expect(result.failed).toBe(1); + }); + + it('should delete corrupted session files', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', + }, + }, + }; + + // Mock getAllSessionFiles to return both valid and corrupted files + const validSession = createTestSessions()[0]; + mockGetAllSessionFiles.mockResolvedValue([ + { fileName: validSession.fileName, sessionInfo: validSession }, + { + fileName: `${SESSION_FILE_PREFIX}2025-01-02T10-00-00-corrupt1.json`, + sessionInfo: null, + }, + { + fileName: `${SESSION_FILE_PREFIX}2025-01-03T10-00-00-corrupt2.json`, + sessionInfo: null, + }, + ]); + + mockFs.unlink.mockResolvedValue(undefined); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(3); // 1 valid + 2 corrupted + expect(result.deleted).toBe(2); // Should delete the 2 corrupted files + expect(result.skipped).toBe(1); // The valid session is kept + + // Verify corrupted files were deleted + expect(mockFs.unlink).toHaveBeenCalledWith( + expect.stringContaining('corrupt1.json'), + ); + expect(mockFs.unlink).toHaveBeenCalledWith( + expect.stringContaining('corrupt2.json'), + ); + }); + + it('should handle unexpected errors without throwing', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '7d', + }, + }, + }; + + // Mock getSessionFiles to throw a non-Error object + mockGetAllSessionFiles.mockRejectedValue('String error'); + + // Should not throw, should return a result with errors + const result = await cleanupExpiredSessions(config, settings); + + expect(result).toBeDefined(); + expect(result.disabled).toBe(false); + expect(result.failed).toBe(1); + }); + }); +}); diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts new file mode 100644 index 0000000000..456d499574 --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -0,0 +1,299 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { type Config } from '@google/gemini-cli-core'; +import type { Settings, SessionRetentionSettings } from '../config/settings.js'; +import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js'; + +// Constants +export const DEFAULT_MIN_RETENTION = '1d' as string; +const MIN_MAX_COUNT = 1; +const MULTIPLIERS = { + h: 60 * 60 * 1000, // hours to ms + d: 24 * 60 * 60 * 1000, // days to ms + w: 7 * 24 * 60 * 60 * 1000, // weeks to ms + m: 30 * 24 * 60 * 60 * 1000, // months (30 days) to ms +}; + +/** + * Result of session cleanup operation + */ +export interface CleanupResult { + disabled: boolean; + scanned: number; + deleted: number; + skipped: number; + failed: number; +} + +/** + * Main entry point for session cleanup during CLI startup + */ +export async function cleanupExpiredSessions( + config: Config, + settings: Settings, +): Promise { + const result: CleanupResult = { + disabled: false, + scanned: 0, + deleted: 0, + skipped: 0, + failed: 0, + }; + + try { + // Early exit if cleanup is disabled + if (!settings.general?.sessionRetention?.enabled) { + return { ...result, disabled: true }; + } + + const retentionConfig = settings.general.sessionRetention; + const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); + + // Validate retention configuration + const validationErrorMessage = validateRetentionConfig( + config, + retentionConfig, + ); + if (validationErrorMessage) { + // Log validation errors to console for visibility + console.error(`Session cleanup disabled: ${validationErrorMessage}`); + return { ...result, disabled: true }; + } + + // Get all session files (including corrupted ones) for this project + const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId()); + result.scanned = allFiles.length; + + if (allFiles.length === 0) { + return result; + } + + // Determine which sessions to delete (corrupted and expired) + const sessionsToDelete = await identifySessionsToDelete( + allFiles, + retentionConfig, + ); + + // Delete all sessions that need to be deleted + for (const sessionToDelete of sessionsToDelete) { + try { + const sessionPath = path.join(chatsDir, sessionToDelete.fileName); + await fs.unlink(sessionPath); + + if (config.getDebugMode()) { + if (sessionToDelete.sessionInfo === null) { + console.debug( + `Deleted corrupted session file: ${sessionToDelete.fileName}`, + ); + } else { + console.debug( + `Deleted expired session: ${sessionToDelete.sessionInfo.id} (${sessionToDelete.sessionInfo.lastUpdated})`, + ); + } + } + result.deleted++; + } catch (error) { + // Ignore ENOENT errors (file already deleted) + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + // File already deleted, do nothing. + } else { + // Log error directly to console + const sessionId = + sessionToDelete.sessionInfo === null + ? sessionToDelete.fileName + : sessionToDelete.sessionInfo.id; + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + console.error( + `Failed to delete session ${sessionId}: ${errorMessage}`, + ); + result.failed++; + } + } + } + + result.skipped = result.scanned - result.deleted - result.failed; + + if (config.getDebugMode() && result.deleted > 0) { + console.debug( + `Session cleanup: deleted ${result.deleted}, skipped ${result.skipped}, failed ${result.failed}`, + ); + } + } catch (error) { + // Global error handler - don't let cleanup failures break startup + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + console.error(`Session cleanup failed: ${errorMessage}`); + result.failed++; + } + + return result; +} + +/** + * Identifies sessions that should be deleted (corrupted or expired based on retention policy) + */ +async function identifySessionsToDelete( + allFiles: SessionFileEntry[], + retentionConfig: SessionRetentionSettings, +): Promise { + const sessionsToDelete: SessionFileEntry[] = []; + + // All corrupted files should be deleted + sessionsToDelete.push( + ...allFiles.filter((entry) => entry.sessionInfo === null), + ); + + // Now handle valid sessions based on retention policy + const validSessions = allFiles.filter((entry) => entry.sessionInfo !== null); + if (validSessions.length === 0) { + return sessionsToDelete; + } + + const now = new Date(); + + // Calculate cutoff date for age-based retention + let cutoffDate: Date | null = null; + if (retentionConfig.maxAge) { + try { + const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + cutoffDate = new Date(now.getTime() - maxAgeMs); + } catch { + // This should not happen as validation should have caught it, + // but handle gracefully just in case + cutoffDate = null; + } + } + + // Sort valid sessions by lastUpdated (newest first) for count-based retention + const sortedValidSessions = [...validSessions].sort( + (a, b) => + new Date(b.sessionInfo!.lastUpdated).getTime() - + new Date(a.sessionInfo!.lastUpdated).getTime(), + ); + + // Separate deletable sessions from the active session + const deletableSessions = sortedValidSessions.filter( + (entry) => !entry.sessionInfo!.isCurrentSession, + ); + + // Calculate how many deletable sessions to keep (accounting for the active session) + const hasActiveSession = sortedValidSessions.some( + (e) => e.sessionInfo!.isCurrentSession, + ); + const maxDeletableSessions = + retentionConfig.maxCount && hasActiveSession + ? Math.max(0, retentionConfig.maxCount - 1) + : retentionConfig.maxCount; + + for (let i = 0; i < deletableSessions.length; i++) { + const entry = deletableSessions[i]; + const session = entry.sessionInfo!; + + let shouldDelete = false; + + // Age-based retention check + if (cutoffDate && new Date(session.lastUpdated) < cutoffDate) { + shouldDelete = true; + } + + // Count-based retention check (keep only N most recent deletable sessions) + if (maxDeletableSessions !== undefined && i >= maxDeletableSessions) { + shouldDelete = true; + } + + if (shouldDelete) { + sessionsToDelete.push(entry); + } + } + + return sessionsToDelete; +} + +/** + * Parses retention period strings like "30d", "7d", "24h" into milliseconds + * @throws {Error} If the format is invalid + */ +function parseRetentionPeriod(period: string): number { + const match = period.match(/^(\d+)([dhwm])$/); + if (!match) { + throw new Error( + `Invalid retention period format: ${period}. Expected format: where unit is h, d, w, or m`, + ); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + // Reject zero values as they're semantically invalid + if (value === 0) { + throw new Error( + `Invalid retention period: ${period}. Value must be greater than 0`, + ); + } + + return value * MULTIPLIERS[unit as keyof typeof MULTIPLIERS]; +} + +/** + * Validates retention configuration + */ +function validateRetentionConfig( + config: Config, + retentionConfig: SessionRetentionSettings, +): string | null { + if (!retentionConfig.enabled) { + return 'Retention not enabled'; + } + + // Validate maxAge if provided + if (retentionConfig.maxAge) { + let maxAgeMs: number; + try { + maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + } catch (error) { + return (error as Error | string).toString(); + } + + // Enforce minimum retention period + const minRetention = retentionConfig.minRetention || DEFAULT_MIN_RETENTION; + let minRetentionMs: number; + try { + minRetentionMs = parseRetentionPeriod(minRetention); + } catch (error) { + // If minRetention format is invalid, fall back to default + if (config.getDebugMode()) { + console.error(`Failed to parse minRetention: ${error}`); + } + minRetentionMs = parseRetentionPeriod(DEFAULT_MIN_RETENTION); + } + + if (maxAgeMs < minRetentionMs) { + return `maxAge cannot be less than minRetention (${minRetention})`; + } + } + + // Validate maxCount if provided + if (retentionConfig.maxCount !== undefined) { + if (retentionConfig.maxCount < MIN_MAX_COUNT) { + return `maxCount must be at least ${MIN_MAX_COUNT}`; + } + } + + // At least one retention method must be specified + if (!retentionConfig.maxAge && retentionConfig.maxCount === undefined) { + return 'Either maxAge or maxCount must be specified'; + } + + return null; +} diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts new file mode 100644 index 0000000000..44843ae323 --- /dev/null +++ b/packages/cli/src/utils/sessionUtils.ts @@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SESSION_FILE_PREFIX, + type ConversationRecord, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import path from 'node:path'; + +/** + * Session information for display and selection purposes. + */ +export interface SessionInfo { + /** Unique session identifier (filename without .json) */ + id: string; + /** Full filename including .json extension */ + fileName: string; + /** ISO timestamp when session was last updated */ + lastUpdated: string; + /** Whether this is the currently active session */ + isCurrentSession: boolean; +} + +/** + * Represents a session file, which may be valid or corrupted. + */ +export interface SessionFileEntry { + /** Full filename including .json extension */ + fileName: string; + /** Parsed session info if valid, null if corrupted */ + sessionInfo: SessionInfo | null; +} + +/** + * Loads all session files (including corrupted ones) from the chats directory. + * @returns Array of session file entries, with sessionInfo null for corrupted files + */ +export const getAllSessionFiles = async ( + chatsDir: string, + currentSessionId?: string, +): Promise => { + try { + const files = await fs.readdir(chatsDir); + const sessionFiles = files + .filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json')) + .sort(); // Sort by filename, which includes timestamp + + const sessionPromises = sessionFiles.map( + async (file): Promise => { + const filePath = path.join(chatsDir, file); + try { + const content: ConversationRecord = JSON.parse( + await fs.readFile(filePath, 'utf8'), + ); + + // Validate required fields + if ( + !content.sessionId || + !content.messages || + !Array.isArray(content.messages) || + !content.startTime || + !content.lastUpdated + ) { + // Missing required fields - treat as corrupted + return { fileName: file, sessionInfo: null }; + } + + const isCurrentSession = currentSessionId + ? file.includes(currentSessionId.slice(0, 8)) + : false; + + const sessionInfo: SessionInfo = { + id: content.sessionId, + fileName: file, + lastUpdated: content.lastUpdated, + isCurrentSession, + }; + + return { fileName: file, sessionInfo }; + } catch { + // File is corrupted (can't read or parse JSON) + return { fileName: file, sessionInfo: null }; + } + }, + ); + return await Promise.all(sessionPromises); + } catch (error) { + // It's expected that the directory might not exist, which is not an error. + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return []; + } + // For other errors (e.g., permissions), re-throw to be handled by the caller. + throw error; + } +}; + +/** + * Loads all valid session files from the chats directory and converts them to SessionInfo. + * Corrupted files are automatically filtered out. + */ +export const getSessionFiles = async ( + chatsDir: string, + currentSessionId?: string, +): Promise => { + const allFiles = await getAllSessionFiles(chatsDir, currentSessionId); + + // Filter out corrupted files and extract SessionInfo + const validSessions = allFiles + .filter( + (entry): entry is { fileName: string; sessionInfo: SessionInfo } => + entry.sessionInfo !== null, + ) + .map((entry) => entry.sessionInfo); + + return validSessions; +}; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 74d2005011..df36d796f3 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -207,6 +207,10 @@ export class GeminiClient { return this.loopDetector; } + getCurrentSequenceModel(): string | null { + return this.currentSequenceModel; + } + async addDirectoryContext(): Promise { if (!this.chat) { return; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 0eff9d9d0f..0c70cd1d2f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -26,6 +26,7 @@ import { } from '../config/models.js'; import { hasCycleInSchema, MUTATOR_KINDS } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; +import type { CompletedToolCall } from './coreToolScheduler.js'; import { logContentRetry, logContentRetryFailure, @@ -584,6 +585,33 @@ export class GeminiChat { return this.chatRecordingService; } + /** + * Records completed tool calls with full metadata. + * This is called by external components when tool calls complete, before sending responses to Gemini. + */ + recordCompletedToolCalls( + model: string, + toolCalls: CompletedToolCall[], + ): void { + const toolCallRecords = toolCalls.map((call) => { + const resultDisplayRaw = call.response?.resultDisplay; + const resultDisplay = + typeof resultDisplayRaw === 'string' ? resultDisplayRaw : undefined; + + return { + id: call.request.callId, + name: call.request.name, + args: call.request.args, + result: call.response?.responseParts || null, + status: call.status as 'error' | 'success' | 'cancelled', + timestamp: new Date().toISOString(), + resultDisplay, + }; + }); + + this.chatRecordingService.recordToolCalls(model, toolCallRecords); + } + /** * Extracts and records thought from thought content. */ diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 52ae8e7e31..9817941c92 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -16,6 +16,8 @@ import type { GenerateContentResponseUsageMetadata, } from '@google/genai'; +export const SESSION_FILE_PREFIX = 'session-'; + /** * Token usage summary for a message or conversation. */ @@ -149,7 +151,7 @@ export class ChatRecordingService { .toISOString() .slice(0, 16) .replace(/:/g, '-'); - const filename = `session-${timestamp}-${this.sessionId.slice( + const filename = `${SESSION_FILE_PREFIX}${timestamp}-${this.sessionId.slice( 0, 8, )}.json`; @@ -195,7 +197,7 @@ export class ChatRecordingService { * Records a message in the conversation. */ recordMessage(message: { - model: string; + model: string | undefined; type: ConversationRecordExtra['type']; content: PartListUnion; }): void {