Skip to content

Commit 11a9edc

Browse files
fix(cli): restore resume for legacy sessions (#26577)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
1 parent 24b98ad commit 11a9edc

4 files changed

Lines changed: 177 additions & 11 deletions

File tree

packages/cli/src/gemini.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,39 @@ describe('resolveSessionId', () => {
11891189
expect(sessionId).toBe('new-id');
11901190
expect(resumedSessionData).toBeUndefined();
11911191
});
1192+
1193+
it('should exit with FATAL_INPUT_ERROR when explicit resume session is missing', async () => {
1194+
vi.mocked(SessionSelector).mockImplementation(
1195+
() =>
1196+
({
1197+
resolveSession: vi
1198+
.fn()
1199+
.mockRejectedValue(SessionError.noSessionsFound()),
1200+
}) as unknown as InstanceType<typeof SessionSelector>,
1201+
);
1202+
1203+
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
1204+
const processExitSpy = vi
1205+
.spyOn(process, 'exit')
1206+
.mockImplementation((code) => {
1207+
throw new MockProcessExitError(code);
1208+
});
1209+
1210+
try {
1211+
await resolveSessionId('explicit-session-id');
1212+
} catch (e) {
1213+
if (!(e instanceof MockProcessExitError)) throw e;
1214+
}
1215+
1216+
expect(emitFeedbackSpy).toHaveBeenCalledWith(
1217+
'error',
1218+
expect.stringContaining('Error resuming session:'),
1219+
);
1220+
expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
1221+
1222+
emitFeedbackSpy.mockRestore();
1223+
processExitSpy.mockRestore();
1224+
});
11921225
});
11931226

11941227
describe('gemini.tsx main function exit codes', () => {

packages/cli/src/gemini.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ import { validateAuthMethod } from './config/auth.js';
8585
import { runAcpClient } from './acp/acpStdioTransport.js';
8686
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
8787
import { appEvents, AppEvent } from './utils/events.js';
88-
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
88+
import {
89+
RESUME_LATEST,
90+
SessionError,
91+
SessionSelector,
92+
} from './utils/sessionUtils.js';
8993

9094
import { relaunchOnExitCode } from './utils/relaunch.js';
9195
import { loadSandboxConfig } from './config/sandboxConfig.js';
@@ -309,8 +313,10 @@ export async function resolveSessionId(
309313
};
310314
} catch (error) {
311315
if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') {
312-
coreEvents.emitFeedback('warning', error.message);
313-
return { sessionId: createSessionId() };
316+
if (resumeArg === RESUME_LATEST) {
317+
coreEvents.emitFeedback('warning', error.message);
318+
return { sessionId: createSessionId() };
319+
}
314320
}
315321
coreEvents.emitFeedback(
316322
'error',

packages/cli/src/utils/sessionUtils.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,120 @@ describe('SessionSelector', () => {
616616
expect(sessions.length).toBe(1);
617617
expect(sessions[0].id).toBe(mainSessionId);
618618
});
619+
620+
it('should list legacy session JSON without timestamps (regression #18593)', async () => {
621+
const sessionId = randomUUID();
622+
623+
const chatsDir = path.join(tmpDir, 'chats');
624+
await fs.mkdir(chatsDir, { recursive: true });
625+
626+
const session = {
627+
sessionId,
628+
projectHash: 'test-hash',
629+
messages: [
630+
{
631+
type: 'user',
632+
content: 'Legacy session message',
633+
id: 'msg1',
634+
timestamp: '2024-01-01T10:00:00.000Z',
635+
},
636+
],
637+
};
638+
639+
const filePath = path.join(
640+
chatsDir,
641+
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
642+
);
643+
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
644+
const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z');
645+
await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp);
646+
647+
const sessionSelector = new SessionSelector(storage);
648+
const sessions = await sessionSelector.listSessions();
649+
650+
expect(sessions.length).toBe(1);
651+
expect(sessions[0].id).toBe(sessionId);
652+
expect(sessions[0].startTime).toBe(fallbackTimestamp.toISOString());
653+
expect(sessions[0].lastUpdated).toBe(fallbackTimestamp.toISOString());
654+
});
655+
656+
it('should resolve legacy session JSON without timestamps by UUID (regression #18593)', async () => {
657+
const sessionId = randomUUID();
658+
659+
const chatsDir = path.join(tmpDir, 'chats');
660+
await fs.mkdir(chatsDir, { recursive: true });
661+
662+
const session = {
663+
sessionId,
664+
projectHash: 'test-hash',
665+
messages: [
666+
{
667+
type: 'user',
668+
content: 'Legacy session message',
669+
id: 'msg1',
670+
timestamp: '2024-01-01T10:00:00.000Z',
671+
},
672+
],
673+
};
674+
675+
const filePath = path.join(
676+
chatsDir,
677+
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
678+
);
679+
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
680+
const fallbackTimestamp = new Date('2024-01-01T10:30:00.000Z');
681+
await fs.utimes(filePath, fallbackTimestamp, fallbackTimestamp);
682+
683+
const sessionSelector = new SessionSelector(storage);
684+
const result = await sessionSelector.resolveSession(sessionId);
685+
686+
expect(result.sessionData.sessionId).toBe(sessionId);
687+
expect(result.sessionData.startTime).toBe(fallbackTimestamp.toISOString());
688+
expect(result.sessionData.lastUpdated).toBe(
689+
fallbackTimestamp.toISOString(),
690+
);
691+
});
692+
693+
it('should throw INVALID_SESSION_IDENTIFIER for a UUID that does not exist on disk at all', async () => {
694+
const existingSessionId = randomUUID();
695+
const nonExistentId = randomUUID();
696+
697+
const chatsDir = path.join(tmpDir, 'chats');
698+
await fs.mkdir(chatsDir, { recursive: true });
699+
700+
const session = {
701+
sessionId: existingSessionId,
702+
projectHash: 'test-hash',
703+
startTime: '2024-01-01T10:00:00.000Z',
704+
lastUpdated: '2024-01-01T10:30:00.000Z',
705+
messages: [
706+
{
707+
type: 'user',
708+
content: 'Hello',
709+
id: 'msg1',
710+
timestamp: '2024-01-01T10:00:00.000Z',
711+
},
712+
],
713+
};
714+
715+
await fs.writeFile(
716+
path.join(
717+
chatsDir,
718+
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${existingSessionId.slice(0, 8)}.json`,
719+
),
720+
JSON.stringify(session, null, 2),
721+
);
722+
723+
const sessionSelector = new SessionSelector(storage);
724+
725+
await expect(sessionSelector.findSession(nonExistentId)).rejects.toSatisfy(
726+
(error) => {
727+
expect(error).toBeInstanceOf(SessionError);
728+
expect((error as SessionError).code).toBe('INVALID_SESSION_IDENTIFIER');
729+
return true;
730+
},
731+
);
732+
});
619733
});
620734

621735
describe('extractFirstUserMessage', () => {

packages/cli/src/utils/sessionUtils.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -270,15 +270,23 @@ export const getAllSessionFiles = async (
270270
}
271271

272272
// Validate required fields
273-
if (
274-
!content.sessionId ||
275-
!content.startTime ||
276-
!content.lastUpdated
277-
) {
273+
if (!content.sessionId) {
278274
// Missing required fields - treat as corrupted
279275
return { fileName: file, sessionInfo: null };
280276
}
281277

278+
const fileTimestamp =
279+
!content.startTime || !content.lastUpdated
280+
? (
281+
await fs.stat(filePath).catch(() => undefined)
282+
)?.mtime.toISOString()
283+
: undefined;
284+
const fallbackTimestamp = fileTimestamp ?? new Date().toISOString();
285+
const startTime =
286+
content.startTime || content.lastUpdated || fallbackTimestamp;
287+
const lastUpdated =
288+
content.lastUpdated || content.startTime || fallbackTimestamp;
289+
282290
// Skip sessions that only contain system messages (info, error, warning)
283291
if (!content.hasUserOrAssistantMessage) {
284292
return { fileName: file, sessionInfo: null };
@@ -319,8 +327,8 @@ export const getAllSessionFiles = async (
319327
id: content.sessionId,
320328
file: file.replace(/\.jsonl?$/, ''),
321329
fileName: file,
322-
startTime: content.startTime,
323-
lastUpdated: content.lastUpdated,
330+
startTime,
331+
lastUpdated,
324332
messageCount: content.messageCount ?? content.messages.length,
325333
displayName: content.summary
326334
? stripUnsafeCharacters(content.summary)
@@ -546,12 +554,17 @@ export class SessionSelector {
546554
if (!sessionData) {
547555
throw new Error('Failed to load session data');
548556
}
557+
const normalizedSessionData = {
558+
...sessionData,
559+
startTime: sessionData.startTime || sessionInfo.startTime,
560+
lastUpdated: sessionData.lastUpdated || sessionInfo.lastUpdated,
561+
};
549562

550563
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
551564

552565
return {
553566
sessionPath,
554-
sessionData,
567+
sessionData: normalizedSessionData,
555568
displayInfo,
556569
};
557570
} catch (error) {

0 commit comments

Comments
 (0)