fix(cli): restore resume for legacy sessions (#26577)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Kuroda Kayn
2026-05-12 08:28:47 +08:00
committed by GitHub
parent 24b98ade86
commit 11a9edc808
4 changed files with 177 additions and 11 deletions
+114
View File
@@ -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', () => {
+21 -8
View File
@@ -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) {