mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-28 23:11:19 -07:00
feat(sessions): add resuming to geminiChat and add CLI flags for session management (#10719)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -16,7 +16,7 @@ describe('cleanup', () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
register = cleanupModule.registerCleanup;
|
||||
runExit = cleanupModule.runExitCleanup;
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
it('should run a registered synchronous function', async () => {
|
||||
const cleanupFn = vi.fn();
|
||||
|
||||
@@ -44,27 +44,43 @@ function createTestSessions(): SessionInfo[] {
|
||||
return [
|
||||
{
|
||||
id: 'current123',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'recent456',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
||||
startTime: oneWeekAgo.toISOString(),
|
||||
lastUpdated: oneWeekAgo.toISOString(),
|
||||
firstUserMessage: 'Recent session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'old789abc',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
||||
startTime: twoWeeksAgo.toISOString(),
|
||||
lastUpdated: twoWeeksAgo.toISOString(),
|
||||
firstUserMessage: 'Old session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'ancient12',
|
||||
file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
||||
startTime: oneMonthAgo.toISOString(),
|
||||
lastUpdated: oneMonthAgo.toISOString(),
|
||||
firstUserMessage: 'Ancient session',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -409,27 +425,43 @@ describe('Session Cleanup', () => {
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session5d',
|
||||
file: `${SESSION_FILE_PREFIX}5d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
startTime: fiveDaysAgo.toISOString(),
|
||||
lastUpdated: fiveDaysAgo.toISOString(),
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session8d',
|
||||
file: `${SESSION_FILE_PREFIX}8d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
||||
startTime: eightDaysAgo.toISOString(),
|
||||
lastUpdated: eightDaysAgo.toISOString(),
|
||||
firstUserMessage: '8 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session15d',
|
||||
file: `${SESSION_FILE_PREFIX}15d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
||||
startTime: fifteenDaysAgo.toISOString(),
|
||||
lastUpdated: fifteenDaysAgo.toISOString(),
|
||||
firstUserMessage: '15 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -507,27 +539,43 @@ describe('Session Cleanup', () => {
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session1d',
|
||||
file: `${SESSION_FILE_PREFIX}1d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
||||
startTime: oneDayAgo.toISOString(),
|
||||
lastUpdated: oneDayAgo.toISOString(),
|
||||
firstUserMessage: '1 day',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session7d',
|
||||
file: `${SESSION_FILE_PREFIX}7d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
startTime: sevenDaysAgo.toISOString(),
|
||||
lastUpdated: sevenDaysAgo.toISOString(),
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session13d',
|
||||
file: `${SESSION_FILE_PREFIX}13d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
||||
startTime: thirteenDaysAgo.toISOString(),
|
||||
lastUpdated: thirteenDaysAgo.toISOString(),
|
||||
firstUserMessage: '13 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -579,9 +627,13 @@ describe('Session Cleanup', () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -590,9 +642,13 @@ describe('Session Cleanup', () => {
|
||||
const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
sessions.push({
|
||||
id: `session${i}`,
|
||||
file: `${SESSION_FILE_PREFIX}${i}d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
||||
startTime: daysAgo.toISOString(),
|
||||
lastUpdated: daysAgo.toISOString(),
|
||||
firstUserMessage: `${i} days`,
|
||||
isCurrentSession: false,
|
||||
index: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -693,33 +749,53 @@ describe('Session Cleanup', () => {
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session3d',
|
||||
file: `${SESSION_FILE_PREFIX}3d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
||||
startTime: threeDaysAgo.toISOString(),
|
||||
lastUpdated: threeDaysAgo.toISOString(),
|
||||
firstUserMessage: '3 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session5d',
|
||||
file: `${SESSION_FILE_PREFIX}5d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
startTime: fiveDaysAgo.toISOString(),
|
||||
lastUpdated: fiveDaysAgo.toISOString(),
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session7d',
|
||||
file: `${SESSION_FILE_PREFIX}7d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
startTime: sevenDaysAgo.toISOString(),
|
||||
lastUpdated: sevenDaysAgo.toISOString(),
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
id: 'session12d',
|
||||
file: `${SESSION_FILE_PREFIX}12d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
||||
startTime: twelveDaysAgo.toISOString(),
|
||||
lastUpdated: twelveDaysAgo.toISOString(),
|
||||
firstUserMessage: '12 days',
|
||||
isCurrentSession: false,
|
||||
index: 5,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
363
packages/cli/src/utils/sessionUtils.test.ts
Normal file
363
packages/cli/src/utils/sessionUtils.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SessionSelector,
|
||||
extractFirstUserMessage,
|
||||
formatRelativeTime,
|
||||
} from './sessionUtils.js';
|
||||
import type { Config, MessageRecord } from '@google/gemini-cli-core';
|
||||
import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
describe('SessionSelector', () => {
|
||||
let tmpDir: string;
|
||||
let config: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tmpDir = path.join(process.cwd(), '.tmp-test-sessions');
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
// Mock config
|
||||
config = {
|
||||
storage: {
|
||||
getProjectTempDir: () => tmpDir,
|
||||
},
|
||||
getSessionId: () => 'current-session-id',
|
||||
} as Partial<Config> as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test files
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (_error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should resolve session by UUID', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message 1',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const session2 = {
|
||||
sessionId: sessionId2,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T11:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message 2',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session2, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving by UUID
|
||||
const result1 = await sessionSelector.resolveSession(sessionId1);
|
||||
expect(result1.sessionData.sessionId).toBe(sessionId1);
|
||||
expect(result1.sessionData.messages[0].content).toBe('Test message 1');
|
||||
|
||||
const result2 = await sessionSelector.resolveSession(sessionId2);
|
||||
expect(result2.sessionData.sessionId).toBe(sessionId2);
|
||||
expect(result2.sessionData.messages[0].content).toBe('Test message 2');
|
||||
});
|
||||
|
||||
it('should resolve session by index', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'First session',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const session2 = {
|
||||
sessionId: sessionId2,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T11:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Second session',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session2, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving by index (1-based)
|
||||
const result1 = await sessionSelector.resolveSession('1');
|
||||
expect(result1.sessionData.messages[0].content).toBe('First session');
|
||||
|
||||
const result2 = await sessionSelector.resolveSession('2');
|
||||
expect(result2.sessionData.messages[0].content).toBe('Second session');
|
||||
});
|
||||
|
||||
it('should resolve latest session', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'First session',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const session2 = {
|
||||
sessionId: sessionId2,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T11:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Latest session',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session2, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving latest
|
||||
const result = await sessionSelector.resolveSession('latest');
|
||||
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
||||
});
|
||||
|
||||
it('should throw error for invalid session identifier', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message 1',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
await expect(
|
||||
sessionSelector.resolveSession('invalid-uuid'),
|
||||
).rejects.toThrow('Invalid session identifier "invalid-uuid"');
|
||||
|
||||
await expect(sessionSelector.resolveSession('999')).rejects.toThrow(
|
||||
'Invalid session identifier "999"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFirstUserMessage', () => {
|
||||
it('should extract first non-resume user message', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
content: '/resume',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Hello world',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
},
|
||||
] as MessageRecord[];
|
||||
|
||||
expect(extractFirstUserMessage(messages)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should truncate long messages', () => {
|
||||
const longMessage = 'a'.repeat(150);
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
content: longMessage,
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
] as MessageRecord[];
|
||||
|
||||
const result = extractFirstUserMessage(messages);
|
||||
expect(result).toBe('a'.repeat(97) + '...');
|
||||
expect(result.length).toBe(100);
|
||||
});
|
||||
|
||||
it('should return "Empty conversation" for no user messages', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'gemini',
|
||||
content: 'Hello',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
] as MessageRecord[];
|
||||
|
||||
expect(extractFirstUserMessage(messages)).toBe('Empty conversation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('should format time correctly', () => {
|
||||
const now = new Date();
|
||||
|
||||
// 5 minutes ago
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
expect(formatRelativeTime(fiveMinutesAgo.toISOString())).toBe(
|
||||
'5 minutes ago',
|
||||
);
|
||||
|
||||
// 1 minute ago
|
||||
const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000);
|
||||
expect(formatRelativeTime(oneMinuteAgo.toISOString())).toBe('1 minute ago');
|
||||
|
||||
// 2 hours ago
|
||||
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(twoHoursAgo.toISOString())).toBe('2 hours ago');
|
||||
|
||||
// 1 hour ago
|
||||
const oneHourAgo = new Date(now.getTime() - 1 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(oneHourAgo.toISOString())).toBe('1 hour ago');
|
||||
|
||||
// 3 days ago
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(threeDaysAgo.toISOString())).toBe('3 days ago');
|
||||
|
||||
// 1 day ago
|
||||
const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(oneDayAgo.toISOString())).toBe('1 day ago');
|
||||
|
||||
// Just now (within 60 seconds)
|
||||
const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000);
|
||||
expect(formatRelativeTime(thirtySecondsAgo.toISOString())).toBe('Just now');
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
type ConversationRecord,
|
||||
partListUnionToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -17,12 +22,20 @@ import path from 'node:path';
|
||||
export interface SessionInfo {
|
||||
/** Unique session identifier (filename without .json) */
|
||||
id: string;
|
||||
/** Filename without extension */
|
||||
file: string;
|
||||
/** Full filename including .json extension */
|
||||
fileName: string;
|
||||
/** ISO timestamp when session started */
|
||||
startTime: string;
|
||||
/** ISO timestamp when session was last updated */
|
||||
lastUpdated: string;
|
||||
/** Cleaned first user message content */
|
||||
firstUserMessage: string;
|
||||
/** Whether this is the currently active session */
|
||||
isCurrentSession: boolean;
|
||||
/** Display index in the list */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +48,55 @@ export interface SessionFileEntry {
|
||||
sessionInfo: SessionInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a session selection argument.
|
||||
*/
|
||||
export interface SessionSelectionResult {
|
||||
sessionPath: string;
|
||||
sessionData: ConversationRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first meaningful user message from conversation messages.
|
||||
*/
|
||||
export const extractFirstUserMessage = (messages: MessageRecord[]): string => {
|
||||
const userMessage = messages.find((msg) => {
|
||||
const content = partListUnionToString(msg.content);
|
||||
return msg.type === 'user' && content?.trim() && content !== '/resume';
|
||||
});
|
||||
|
||||
if (!userMessage) {
|
||||
return 'Empty conversation';
|
||||
}
|
||||
|
||||
// Truncate long messages for display
|
||||
const content = partListUnionToString(userMessage.content).trim();
|
||||
return content.length > 100 ? content.slice(0, 97) + '...' : content;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago").
|
||||
*/
|
||||
export const formatRelativeTime = (timestamp: string): string => {
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffMs = now.getTime() - time.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads all session files (including corrupted ones) from the chats directory.
|
||||
* @returns Array of session file entries, with sessionInfo null for corrupted files
|
||||
@@ -69,15 +131,20 @@ export const getAllSessionFiles = async (
|
||||
return { fileName: file, sessionInfo: null };
|
||||
}
|
||||
|
||||
const firstUserMessage = extractFirstUserMessage(content.messages);
|
||||
const isCurrentSession = currentSessionId
|
||||
? file.includes(currentSessionId.slice(0, 8))
|
||||
: false;
|
||||
|
||||
const sessionInfo: SessionInfo = {
|
||||
id: content.sessionId,
|
||||
file: file.replace('.json', ''),
|
||||
fileName: file,
|
||||
startTime: content.startTime,
|
||||
lastUpdated: content.lastUpdated,
|
||||
firstUserMessage,
|
||||
isCurrentSession,
|
||||
index: 0, // Will be set after sorting valid sessions
|
||||
};
|
||||
|
||||
return { fileName: file, sessionInfo };
|
||||
@@ -87,6 +154,7 @@ export const getAllSessionFiles = async (
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return await Promise.all(sessionPromises);
|
||||
} catch (error) {
|
||||
// It's expected that the directory might not exist, which is not an error.
|
||||
@@ -116,5 +184,142 @@ export const getSessionFiles = async (
|
||||
)
|
||||
.map((entry) => entry.sessionInfo);
|
||||
|
||||
// Sort by startTime (oldest first) for stable session numbering
|
||||
validSessions.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
// Set the correct 1-based indexes after sorting
|
||||
validSessions.forEach((session, index) => {
|
||||
session.index = index + 1;
|
||||
});
|
||||
|
||||
return validSessions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility class for session discovery and selection.
|
||||
*/
|
||||
export class SessionSelector {
|
||||
constructor(private config: Config) {}
|
||||
|
||||
/**
|
||||
* Lists all available sessions for the current project.
|
||||
*/
|
||||
async listSessions(): Promise<SessionInfo[]> {
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
return getSessionFiles(chatsDir, this.config.getSessionId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a session by identifier (UUID or numeric index).
|
||||
*
|
||||
* @param identifier - Can be a full UUID or an index number (1-based)
|
||||
* @returns Promise resolving to the found SessionInfo
|
||||
* @throws Error if the session is not found or identifier is invalid
|
||||
*/
|
||||
async findSession(identifier: string): Promise<SessionInfo> {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No previous sessions found for this project.');
|
||||
}
|
||||
|
||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
||||
const sortedSessions = sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
// Try to find by UUID first
|
||||
const sessionByUuid = sortedSessions.find(
|
||||
(session) => session.id === identifier,
|
||||
);
|
||||
if (sessionByUuid) {
|
||||
return sessionByUuid;
|
||||
}
|
||||
|
||||
// Parse as index number (1-based) - only allow numeric indexes
|
||||
const index = parseInt(identifier, 10);
|
||||
if (
|
||||
!isNaN(index) &&
|
||||
index.toString() === identifier &&
|
||||
index > 0 &&
|
||||
index <= sortedSessions.length
|
||||
) {
|
||||
return sortedSessions[index - 1];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid session identifier "${identifier}". Use --list-sessions to see available sessions.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a resume argument to a specific session.
|
||||
*
|
||||
* @param resumeArg - Can be "latest", a full UUID, or an index number (1-based)
|
||||
* @returns Promise resolving to session selection result
|
||||
*/
|
||||
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
||||
let selectedSession: SessionInfo;
|
||||
|
||||
if (resumeArg === 'latest') {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No previous sessions found for this project.');
|
||||
}
|
||||
|
||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
selectedSession = sessions[sessions.length - 1];
|
||||
} else {
|
||||
try {
|
||||
selectedSession = await this.findSession(resumeArg);
|
||||
} catch (error) {
|
||||
// Re-throw with more detailed message for resume command
|
||||
throw new Error(
|
||||
`Invalid session identifier "${resumeArg}". Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest. Error: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.selectSession(selectedSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads session data for a selected session.
|
||||
*/
|
||||
private async selectSession(
|
||||
sessionInfo: SessionInfo,
|
||||
): Promise<SessionSelectionResult> {
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
|
||||
|
||||
try {
|
||||
const sessionData: ConversationRecord = JSON.parse(
|
||||
await fs.readFile(sessionPath, 'utf8'),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionPath,
|
||||
sessionData,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load session ${sessionInfo.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
packages/cli/src/utils/sessions.ts
Normal file
96
packages/cli/src/utils/sessions.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ChatRecordingService, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
SessionSelector,
|
||||
type SessionInfo,
|
||||
} from './sessionUtils.js';
|
||||
|
||||
export async function listSessions(config: Config): Promise<void> {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No previous sessions found for this project.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nAvailable sessions for this project (${sessions.length}):\n`);
|
||||
|
||||
sessions
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
)
|
||||
.forEach((session, index) => {
|
||||
const current = session.isCurrentSession ? ', current' : '';
|
||||
const time = formatRelativeTime(session.lastUpdated);
|
||||
console.log(
|
||||
` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
config: Config,
|
||||
sessionIndex: string,
|
||||
): Promise<void> {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.error('No sessions found for this project.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort sessions by start time to match list-sessions ordering
|
||||
const sortedSessions = sessions.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
let sessionToDelete: SessionInfo;
|
||||
|
||||
// Try to find by UUID first
|
||||
const sessionByUuid = sortedSessions.find(
|
||||
(session) => session.id === sessionIndex,
|
||||
);
|
||||
if (sessionByUuid) {
|
||||
sessionToDelete = sessionByUuid;
|
||||
} else {
|
||||
// Parse session index
|
||||
const index = parseInt(sessionIndex, 10);
|
||||
if (isNaN(index) || index < 1 || index > sessions.length) {
|
||||
console.error(
|
||||
`Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
sessionToDelete = sortedSessions[index - 1];
|
||||
}
|
||||
|
||||
// Prevent deleting the current session
|
||||
if (sessionToDelete.isCurrentSession) {
|
||||
console.error('Cannot delete the current active session.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use ChatRecordingService to delete the session
|
||||
const chatRecordingService = new ChatRecordingService(config);
|
||||
chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
|
||||
const time = formatRelativeTime(sessionToDelete.lastUpdated);
|
||||
console.log(
|
||||
`Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user