diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index a323c2aad9..1b5ac319d8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -22,6 +22,7 @@ import type { Settings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionManager } from './extension-manager.js'; +import { RESUME_LATEST } from '../utils/sessionUtils.js'; vi.mock('./trustedFolders.js', () => ({ isWorkspaceTrusted: vi @@ -482,6 +483,20 @@ describe('parseArguments', () => { } }); + it('should return RESUME_LATEST constant when --resume is passed without a value', async () => { + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; // Make it interactive to avoid validation error + process.argv = ['node', 'script.js', '--resume']; + + try { + const argv = await parseArguments({} as Settings); + expect(argv.resume).toBe(RESUME_LATEST); + expect(argv.resume).toBe('latest'); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }); + it('should support comma-separated values for --allowed-tools', async () => { process.argv = [ 'node', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index cca736179f..48ed54c5c4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -37,6 +37,7 @@ import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { resolvePath } from '../utils/resolvePath.js'; import { appEvents } from '../utils/events.js'; +import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createPolicyEngineConfig } from './policy.js'; @@ -61,7 +62,7 @@ export interface CliArgs { experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; - resume: string | 'latest' | undefined; + resume: string | typeof RESUME_LATEST | undefined; listSessions: boolean | undefined; deleteSession: string | undefined; includeDirectories: string[] | undefined; @@ -189,7 +190,7 @@ export async function parseArguments(settings: Settings): Promise { // When --resume not passed at all: this `coerce` function is not called at all, and // `yargsInstance.argv.resume` is undefined. if (value === '') { - return 'latest'; + return RESUME_LATEST; } return value; }, diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 3be8ca7a9f..5cad4303bc 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -16,6 +16,12 @@ import { import * as fs from 'node:fs/promises'; import path from 'node:path'; +/** + * Constant for the resume "latest" identifier. + * Used when --resume is passed without a value to select the most recent session. + */ +export const RESUME_LATEST = 'latest'; + /** * Session information for display and selection purposes. */ @@ -267,7 +273,7 @@ export class SessionSelector { async resolveSession(resumeArg: string): Promise { let selectedSession: SessionInfo; - if (resumeArg === 'latest') { + if (resumeArg === RESUME_LATEST) { const sessions = await this.listSessions(); if (sessions.length === 0) { diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts new file mode 100644 index 0000000000..12e43682e2 --- /dev/null +++ b/packages/cli/src/utils/sessions.test.ts @@ -0,0 +1,667 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Config } from '@google/gemini-cli-core'; +import { ChatRecordingService } from '@google/gemini-cli-core'; +import { listSessions, deleteSession } from './sessions.js'; +import { SessionSelector, type SessionInfo } from './sessionUtils.js'; + +// Mock the SessionSelector and ChatRecordingService +vi.mock('./sessionUtils.js', () => ({ + SessionSelector: vi.fn(), + formatRelativeTime: vi.fn(() => 'some time ago'), +})); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + ChatRecordingService: vi.fn(), + }; +}); + +describe('listSessions', () => { + let mockConfig: Config; + let mockListSessions: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + // Create mock config + mockConfig = { + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), + }, + getSessionId: vi.fn().mockReturnValue('current-session-id'), + } as unknown as Config; + + // Create mock listSessions method + mockListSessions = vi.fn(); + + // Mock SessionSelector constructor to return object with listSessions method + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + listSessions: mockListSessions, + }) as unknown as InstanceType, + ); + + // Spy on console.log + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleLogSpy.mockRestore(); + }); + + it('should display message when no previous sessions were found', async () => { + // Arrange: Return empty array from listSessions + mockListSessions.mockResolvedValue([]); + + // Act + await listSessions(mockConfig); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'No previous sessions found for this project.', + ); + }); + + it('should list sessions when sessions are found', async () => { + // Arrange: Create test sessions + const now = new Date('2025-01-20T12:00:00.000Z'); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-2025-01-18T12-00-00-session-1', + fileName: 'session-2025-01-18T12-00-00-session-1.json', + startTime: twoDaysAgo.toISOString(), + lastUpdated: twoDaysAgo.toISOString(), + firstUserMessage: 'First user message', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-2', + file: 'session-2025-01-20T11-00-00-session-2', + fileName: 'session-2025-01-20T11-00-00-session-2.json', + startTime: oneHourAgo.toISOString(), + lastUpdated: oneHourAgo.toISOString(), + firstUserMessage: 'Second user message', + isCurrentSession: false, + index: 2, + }, + { + id: 'current-session-id', + file: 'session-2025-01-20T12-00-00-current-s', + fileName: 'session-2025-01-20T12-00-00-current-s.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Current session', + isCurrentSession: true, + index: 3, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + + // Check that the header was displayed + expect(consoleLogSpy).toHaveBeenCalledWith( + '\nAvailable sessions for this project (3):\n', + ); + + // Check that each session was logged + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('1. First user message'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[session-1]'), + ); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('2. Second user message'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[session-2]'), + ); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('3. Current session'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(', current)'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[current-session-id]'), + ); + }); + + it('should sort sessions by start time (oldest first)', async () => { + // Arrange: Create sessions in non-chronological order + const session1Time = new Date('2025-01-18T12:00:00.000Z'); + const session2Time = new Date('2025-01-19T12:00:00.000Z'); + const session3Time = new Date('2025-01-20T12:00:00.000Z'); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-2', + file: 'session-2', + fileName: 'session-2.json', + startTime: session2Time.toISOString(), // Middle + lastUpdated: session2Time.toISOString(), + firstUserMessage: 'Middle session', + isCurrentSession: false, + index: 2, + }, + { + id: 'session-1', + file: 'session-1', + fileName: 'session-1.json', + startTime: session1Time.toISOString(), // Oldest + lastUpdated: session1Time.toISOString(), + firstUserMessage: 'Oldest session', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-3', + file: 'session-3', + fileName: 'session-3.json', + startTime: session3Time.toISOString(), // Newest + lastUpdated: session3Time.toISOString(), + firstUserMessage: 'Newest session', + isCurrentSession: false, + index: 3, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + // Get all the session log calls (skip the header) + const sessionCalls = consoleLogSpy.mock.calls.filter( + (call): call is [string] => + typeof call[0] === 'string' && + call[0].includes('[session-') && + !call[0].includes('Available sessions'), + ); + + // Verify they are sorted by start time (oldest first) + expect(sessionCalls[0][0]).toContain('1. Oldest session'); + expect(sessionCalls[1][0]).toContain('2. Middle session'); + expect(sessionCalls[2][0]).toContain('3. Newest session'); + }); + + it('should format session output with relative time and session ID', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'abc123def456', + file: 'session-file', + fileName: 'session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test message', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('1. Test message'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('some time ago'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[abc123def456]'), + ); + }); + + it('should handle single session', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'single-session', + file: 'session-file', + fileName: 'session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Only session', + isCurrentSession: true, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + expect(consoleLogSpy).toHaveBeenCalledWith( + '\nAvailable sessions for this project (1):\n', + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('1. Only session'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(', current)'), + ); + }); +}); + +describe('deleteSession', () => { + let mockConfig: Config; + let mockListSessions: ReturnType; + let mockDeleteSession: ReturnType; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + // Create mock config + mockConfig = { + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), + }, + getSessionId: vi.fn().mockReturnValue('current-session-id'), + } as unknown as Config; + + // Create mock methods + mockListSessions = vi.fn(); + mockDeleteSession = vi.fn(); + + // Mock SessionSelector constructor + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + listSessions: mockListSessions, + }) as unknown as InstanceType, + ); + + // Mock ChatRecordingService + vi.mocked(ChatRecordingService).mockImplementation( + () => + ({ + deleteSession: mockDeleteSession, + }) as unknown as InstanceType, + ); + + // Spy on console methods + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('should display error when no sessions are found', async () => { + // Arrange + mockListSessions.mockResolvedValue([]); + + // Act + await deleteSession(mockConfig, '1'); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'No sessions found for this project.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should delete session by UUID', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-uuid-123', + file: 'session-file-123', + fileName: 'session-file-123.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => {}); + + // Act + await deleteSession(mockConfig, 'session-uuid-123'); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(mockDeleteSession).toHaveBeenCalledWith('session-file-123'); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Deleted session 1: Test session (some time ago)', + ); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('should delete session by index', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: oneHourAgo.toISOString(), + lastUpdated: oneHourAgo.toISOString(), + firstUserMessage: 'First session', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-2', + file: 'session-file-2', + fileName: 'session-file-2.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Second session', + isCurrentSession: false, + index: 2, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => {}); + + // Act + await deleteSession(mockConfig, '2'); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(mockDeleteSession).toHaveBeenCalledWith('session-file-2'); + expect(consoleLogSpy).toHaveBeenCalledWith( + 'Deleted session 2: Second session (some time ago)', + ); + }); + + it('should display error for invalid session identifier (non-numeric)', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await deleteSession(mockConfig, 'invalid-id'); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Invalid session identifier "invalid-id". Use --list-sessions to see available sessions.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should display error for invalid session identifier (out of range)', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await deleteSession(mockConfig, '999'); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Invalid session identifier "999". Use --list-sessions to see available sessions.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should display error for invalid session identifier (zero)', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await deleteSession(mockConfig, '0'); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Invalid session identifier "0". Use --list-sessions to see available sessions.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should prevent deletion of current session', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'current-session-id', + file: 'current-session-file', + fileName: 'current-session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Current session', + isCurrentSession: true, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act - try to delete by index + await deleteSession(mockConfig, '1'); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Cannot delete the current active session.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should prevent deletion of current session by UUID', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'current-session-id', + file: 'current-session-file', + fileName: 'current-session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Current session', + isCurrentSession: true, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act - try to delete by UUID + await deleteSession(mockConfig, 'current-session-id'); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Cannot delete the current active session.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should handle deletion errors gracefully', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => { + throw new Error('File deletion failed'); + }); + + // Act + await deleteSession(mockConfig, '1'); + + // Assert + expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1'); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to delete session: File deletion failed', + ); + }); + + it('should handle non-Error deletion failures', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => { + // eslint-disable-next-line no-restricted-syntax + throw 'Unknown error type'; + }); + + // Act + await deleteSession(mockConfig, '1'); + + // Assert + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to delete session: Unknown error', + ); + }); + + it('should sort sessions before finding by index', async () => { + // Arrange: Create sessions in non-chronological order + const session1Time = new Date('2025-01-18T12:00:00.000Z'); + const session2Time = new Date('2025-01-19T12:00:00.000Z'); + const session3Time = new Date('2025-01-20T12:00:00.000Z'); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-3', + file: 'session-file-3', + fileName: 'session-file-3.json', + startTime: session3Time.toISOString(), // Newest + lastUpdated: session3Time.toISOString(), + firstUserMessage: 'Newest session', + isCurrentSession: false, + index: 3, + }, + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: session1Time.toISOString(), // Oldest + lastUpdated: session1Time.toISOString(), + firstUserMessage: 'Oldest session', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-2', + file: 'session-file-2', + fileName: 'session-file-2.json', + startTime: session2Time.toISOString(), // Middle + lastUpdated: session2Time.toISOString(), + firstUserMessage: 'Middle session', + isCurrentSession: false, + index: 2, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => {}); + + // Act - delete index 1 (should be oldest session after sorting) + await deleteSession(mockConfig, '1'); + + // Assert + expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1'); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Oldest session'), + ); + }); +});