fix(cli): gracefully handle --resume when no sessions exist (#21429)

This commit is contained in:
Sandy Tao
2026-03-06 11:02:33 -08:00
committed by GitHub
parent 6f579934db
commit 42d367d72f
4 changed files with 109 additions and 15 deletions

View File

@@ -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<typeof SessionSelector>,
);
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<typeof import('./utils/sessionUtils.js')>();
return {
...original,
SessionSelector: vi.fn().mockImplementation(() => ({
resolveSession: vi
.fn()
.mockRejectedValue(new Error('Session not found')),
})),
};
});
process.env['GEMINI_API_KEY'] = 'test-key';
try {

View File

@@ -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);
}
}
}

View File

@@ -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<Config> 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();

View File

@@ -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)