diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index f678d9ad71..6795c2a1b0 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1189,6 +1189,39 @@ describe('resolveSessionId', () => { expect(sessionId).toBe('new-id'); expect(resumedSessionData).toBeUndefined(); }); + + it('should exit with FATAL_INPUT_ERROR when explicit resume session is missing', async () => { + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + resolveSession: vi + .fn() + .mockRejectedValue(SessionError.noSessionsFound()), + }) as unknown as InstanceType, + ); + + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + try { + await resolveSessionId('explicit-session-id'); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + + expect(emitFeedbackSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session:'), + ); + expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR); + + emitFeedbackSpy.mockRestore(); + processExitSpy.mockRestore(); + }); }); describe('gemini.tsx main function exit codes', () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index ab97f7f574..04fc5c2169 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -85,7 +85,11 @@ import { validateAuthMethod } from './config/auth.js'; import { runAcpClient } from './acp/acpStdioTransport.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { appEvents, AppEvent } from './utils/events.js'; -import { SessionError, SessionSelector } from './utils/sessionUtils.js'; +import { + RESUME_LATEST, + SessionError, + SessionSelector, +} from './utils/sessionUtils.js'; import { relaunchOnExitCode } from './utils/relaunch.js'; import { loadSandboxConfig } from './config/sandboxConfig.js'; @@ -309,8 +313,10 @@ export async function resolveSessionId( }; } catch (error) { if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') { - coreEvents.emitFeedback('warning', error.message); - return { sessionId: createSessionId() }; + if (resumeArg === RESUME_LATEST) { + coreEvents.emitFeedback('warning', error.message); + return { sessionId: createSessionId() }; + } } coreEvents.emitFeedback( 'error', diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 0bc1183e71..cfdadf795f 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -616,6 +616,120 @@ describe('SessionSelector', () => { expect(sessions.length).toBe(1); expect(sessions[0].id).toBe(mainSessionId); }); + + it('should list legacy session JSON without timestamps (regression #18593)', async () => { + const sessionId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId, + projectHash: 'test-hash', + messages: [ + { + type: 'user', + content: 'Legacy session message', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const filePath = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ); + await fs.writeFile(filePath, JSON.stringify(session, null, 2)); + const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z'); + await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp); + + const sessionSelector = new SessionSelector(storage); + const sessions = await sessionSelector.listSessions(); + + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(sessionId); + expect(sessions[0].startTime).toBe(fallbackTimestamp.toISOString()); + expect(sessions[0].lastUpdated).toBe(fallbackTimestamp.toISOString()); + }); + + it('should resolve legacy session JSON without timestamps by UUID (regression #18593)', async () => { + const sessionId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId, + projectHash: 'test-hash', + messages: [ + { + type: 'user', + content: 'Legacy session message', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const filePath = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ); + await fs.writeFile(filePath, JSON.stringify(session, null, 2)); + const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z'); + await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp); + + const sessionSelector = new SessionSelector(storage); + const result = await sessionSelector.resolveSession(sessionId); + + expect(result.sessionData.sessionId).toBe(sessionId); + expect(result.sessionData.startTime).toBe(fallbackTimestamp.toISOString()); + expect(result.sessionData.lastUpdated).toBe( + fallbackTimestamp.toISOString(), + ); + }); + + it('should throw INVALID_SESSION_IDENTIFIER for a UUID that does not exist on disk at all', async () => { + const existingSessionId = randomUUID(); + const nonExistentId = randomUUID(); + + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId: existingSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Hello', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${existingSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(session, null, 2), + ); + + const sessionSelector = new SessionSelector(storage); + + await expect(sessionSelector.findSession(nonExistentId)).rejects.toSatisfy( + (error) => { + expect(error).toBeInstanceOf(SessionError); + expect((error as SessionError).code).toBe('INVALID_SESSION_IDENTIFIER'); + return true; + }, + ); + }); }); describe('extractFirstUserMessage', () => { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 437e32c465..a2918eae3e 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -270,15 +270,23 @@ export const getAllSessionFiles = async ( } // Validate required fields - if ( - !content.sessionId || - !content.startTime || - !content.lastUpdated - ) { + if (!content.sessionId) { // Missing required fields - treat as corrupted return { fileName: file, sessionInfo: null }; } + const fileTimestamp = + !content.startTime || !content.lastUpdated + ? ( + await fs.stat(filePath).catch(() => undefined) + )?.mtime.toISOString() + : undefined; + const fallbackTimestamp = fileTimestamp ?? new Date().toISOString(); + const startTime = + content.startTime || content.lastUpdated || fallbackTimestamp; + const lastUpdated = + content.lastUpdated || content.startTime || fallbackTimestamp; + // Skip sessions that only contain system messages (info, error, warning) if (!content.hasUserOrAssistantMessage) { return { fileName: file, sessionInfo: null }; @@ -319,8 +327,8 @@ export const getAllSessionFiles = async ( id: content.sessionId, file: file.replace(/\.jsonl?$/, ''), fileName: file, - startTime: content.startTime, - lastUpdated: content.lastUpdated, + startTime, + lastUpdated, messageCount: content.messageCount ?? content.messages.length, displayName: content.summary ? stripUnsafeCharacters(content.summary) @@ -546,12 +554,17 @@ export class SessionSelector { if (!sessionData) { throw new Error('Failed to load session data'); } + const normalizedSessionData = { + ...sessionData, + startTime: sessionData.startTime || sessionInfo.startTime, + lastUpdated: sessionData.lastUpdated || sessionInfo.lastUpdated, + }; const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; return { sessionPath, - sessionData, + sessionData: normalizedSessionData, displayInfo, }; } catch (error) {