Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
33 changes: 33 additions & 0 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof SessionSelector>,
);

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', () => {
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand Down
114 changes: 114 additions & 0 deletions packages/cli/src/utils/sessionUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/src/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Loading