Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 66 additions & 7 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 19 additions & 7 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/utils/sessionUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,32 @@ 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.toThrow(
SessionError,
);

try {
await sessionSelector.resolveSession('latest');
} catch (error) {
expect(error).toBeInstanceOf(SessionError);
expect((error as SessionError).code).toBe('NO_SESSIONS_FOUND');
}
});

it('should not list sessions with only system messages', async () => {
const sessionIdWithUser = randomUUID();
const sessionIdSystemOnly = randomUUID();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading