diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 2784c5694a..90c63651e7 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -747,6 +747,60 @@ describe('gemini.tsx main function kitty protocol', () => { emitFeedbackSpy.mockRestore(); }); + it('should start normally with a warning when no sessions found for resume', async () => { + const { SessionSelector, SessionError } = await import( + './utils/sessionUtils.js' + ); + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + resolveSession: vi + .fn() + .mockRejectedValue(SessionError.noSessionsFound()), + }) as unknown as InstanceType, + ); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + promptInteractive: false, + resume: 'latest', + } as unknown as CliArgs); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + + await main(); + + // Should NOT have crashed + expect(processExitSpy).not.toHaveBeenCalled(); + // Should NOT have emitted a feedback error + expect(emitFeedbackSpy).not.toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session'), + ); + processExitSpy.mockRestore(); + emitFeedbackSpy.mockRestore(); + }); + it.skip('should log error when cleanupExpiredSessions fails', async () => { const { cleanupExpiredSessions } = await import( './utils/sessionCleanup.js' @@ -959,13 +1013,18 @@ describe('gemini.tsx main function exit codes', () => { resume: 'invalid-session', } as unknown as CliArgs); - vi.mock('./utils/sessionUtils.js', () => ({ - SessionSelector: vi.fn().mockImplementation(() => ({ - resolveSession: vi - .fn() - .mockRejectedValue(new Error('Session not found')), - })), - })); + vi.mock('./utils/sessionUtils.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + SessionSelector: vi.fn().mockImplementation(() => ({ + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + })), + }; + }); process.env['GEMINI_API_KEY'] = 'test-key'; try { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 6071488542..331ec0c018 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -84,7 +84,7 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; -import { SessionSelector } from './utils/sessionUtils.js'; +import { SessionError, SessionSelector } from './utils/sessionUtils.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { MouseProvider } from './ui/contexts/MouseContext.js'; import { StreamingState } from './ui/types.js'; @@ -706,12 +706,24 @@ export async function main() { // Use the existing session ID to continue recording to the same session config.setSessionId(resumedSessionData.conversation.sessionId); } catch (error) { - coreEvents.emitFeedback( - 'error', - `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - await runExitCleanup(); - process.exit(ExitCodes.FATAL_INPUT_ERROR); + if ( + error instanceof SessionError && + error.code === 'NO_SESSIONS_FOUND' + ) { + // No sessions to resume — start a fresh session with a warning + startupWarnings.push({ + id: 'resume-no-sessions', + message: error.message, + priority: WarningPriority.High, + }); + } else { + coreEvents.emitFeedback( + 'error', + `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } } } diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 8491f748bd..bcf7c19dfe 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -341,6 +341,29 @@ describe('SessionSelector', () => { ); }); + it('should throw SessionError with NO_SESSIONS_FOUND when resolving latest with no sessions', async () => { + // Empty chats directory — no session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const emptyConfig = { + storage: { + getProjectTempDir: () => tmpDir, + }, + getSessionId: () => 'current-session-id', + } as Partial as Config; + + const sessionSelector = new SessionSelector(emptyConfig); + + await expect(sessionSelector.resolveSession('latest')).rejects.toSatisfy( + (error) => { + expect(error).toBeInstanceOf(SessionError); + expect((error as SessionError).code).toBe('NO_SESSIONS_FOUND'); + return true; + }, + ); + }); + it('should not list sessions with only system messages', async () => { const sessionIdWithUser = randomUUID(); const sessionIdSystemOnly = randomUUID(); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 7bf05fe94a..ac6987f933 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -463,7 +463,7 @@ export class SessionSelector { const sessions = await this.listSessions(); if (sessions.length === 0) { - throw new Error('No previous sessions found for this project.'); + throw SessionError.noSessionsFound(); } // Sort by startTime (oldest first, so newest sessions get highest numbers)