Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1757,8 +1757,9 @@ describe('startInteractiveUI', () => {

// Verify all startup tasks were called
expect(getVersion).toHaveBeenCalledTimes(1);
// 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check
expect(registerCleanup).toHaveBeenCalledTimes(5);
// 6 cleanups: mouseEvents, lineWrapping, non-resumable session cleanup,
// instance.unmount, TTY check, and consolePatcher
expect(registerCleanup).toHaveBeenCalledTimes(6);

// Verify cleanup handler is registered with unmount function
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/interactiveCli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ export async function startInteractiveUI(
});

const cleanupUnmount = () => instance.unmount();
const cleanupNonResumableCurrentSession = async () => {
try {
await config
.getGeminiClient()
?.getChatRecordingService()
?.deleteCurrentSessionIfNotResumableAsync();
} catch (e: unknown) {
debugLogger.error('Error cleaning up non-resumable session:', e);
}
};
registerCleanup(cleanupNonResumableCurrentSession);
registerCleanup(cleanupUnmount);

const cleanupTtyCheck = setupTtyCheck();
Expand All @@ -212,6 +223,12 @@ export async function startInteractiveUI(
debugLogger.error('Error cleaning up console patcher:', e);
}

try {
removeCleanup(cleanupNonResumableCurrentSession);
} catch (e: unknown) {
debugLogger.error('Error removing non-resumable session cleanup:', e);
}
Comment thread
SandyTao520 marked this conversation as resolved.

try {
removeCleanup(cleanupUnmount);
instance.unmount();
Expand Down
134 changes: 134 additions & 0 deletions packages/cli/src/utils/sessionUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
extractFirstUserMessage,
formatRelativeTime,
hasUserOrAssistantMessage,
hasResumableContent,
SessionError,
convertSessionToHistoryFormats,
} from './sessionUtils.js';
Expand Down Expand Up @@ -512,6 +513,80 @@ describe('SessionSelector', () => {
expect(sessions[0].id).toBe(sessionIdWithUser);
});

it('should not list command-only sessions', async () => {
const commandOnlySessionId = randomUUID();

const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });

const metadata = {
sessionId: commandOnlySessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:01:00.000Z',
};
const commandMessage = {
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:30.000Z',
};

await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${commandOnlySessionId.slice(0, 8)}.jsonl`,
),
`${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n`,
);

const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();

expect(sessions).toEqual([]);
});

it('should use the first non-command user message for display', async () => {
const sessionId = randomUUID();

const chatsDir = path.join(tmpDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });

const metadata = {
sessionId,
projectHash: 'test-hash',
startTime: '2024-01-01T10:00:00.000Z',
lastUpdated: '2024-01-01T10:02:00.000Z',
};
const commandMessage = {
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:30.000Z',
};
const realMessage = {
type: 'user',
content: 'Help me fix resume history',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
};

await fs.writeFile(
path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.jsonl`,
),
`${JSON.stringify(metadata)}\n${JSON.stringify(commandMessage)}\n${JSON.stringify(realMessage)}\n`,
);

const sessionSelector = new SessionSelector(storage);
const sessions = await sessionSelector.listSessions();

expect(sessions).toHaveLength(1);
expect(sessions[0].firstUserMessage).toBe('Help me fix resume history');
expect(sessions[0].displayName).toBe('Help me fix resume history');
});

it('should list session with gemini message even without user message', async () => {
const sessionIdGeminiOnly = randomUUID();

Expand Down Expand Up @@ -922,6 +997,65 @@ describe('hasUserOrAssistantMessage', () => {
});
});

describe('hasResumableContent', () => {
it('should return false for command-only messages', () => {
const messages = [
{
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'user',
content: '?help',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];

expect(hasResumableContent(messages)).toBe(false);
});

it('should return false for internal context-only messages', () => {
const messages = [
{
type: 'user',
content: '<session_context>previous state</session_context>',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'user',
content: '<hook_context>hook data</hook_context>',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];

expect(hasResumableContent(messages)).toBe(false);
});

it('should return true for real user or assistant content', () => {
const messages = [
{
type: 'user',
content: '/resume',
id: 'msg1',
timestamp: '2024-01-01T10:00:00.000Z',
},
{
type: 'gemini',
content: 'I can help with that.',
id: 'msg2',
timestamp: '2024-01-01T10:01:00.000Z',
},
] as MessageRecord[];

expect(hasResumableContent(messages)).toBe(true);
});
});

describe('formatRelativeTime', () => {
it('should format time correctly', () => {
const now = new Date();
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/utils/sessionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type ConversationRecord,
type MessageRecord,
loadConversationRecord,
hasResumableConversationContent,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
Expand Down Expand Up @@ -148,6 +149,9 @@ export interface SessionSelectionResult {
export const hasUserOrAssistantMessage = (messages: MessageRecord[]): boolean =>
messages.some((msg) => msg.type === 'user' || msg.type === 'gemini');

export const hasResumableContent = (messages: MessageRecord[]): boolean =>
hasResumableConversationContent(messages);

/**
* Cleans and sanitizes message content for display by:
* - Converting newlines to spaces
Expand Down Expand Up @@ -287,8 +291,10 @@ export const getAllSessionFiles = async (
const lastUpdated =
content.lastUpdated || content.startTime || fallbackTimestamp;

// Skip sessions that only contain system messages (info, error, warning)
if (!content.hasUserOrAssistantMessage) {
// Skip sessions with no resumable conversation content, including
// startup-only, system-only, command-only, and internal-context-only
// sessions.
if (!content.hasResumableContent) {
return { fileName: file, sessionInfo: null };
}

Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/services/chatRecordingService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,49 @@ describe('ChatRecordingService', () => {
});
});

describe('deleteCurrentSessionIfNotResumableAsync', () => {
it('should delete a startup-only session', async () => {
await chatRecordingService.initialize();
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();
expect(fs.existsSync(conversationFile!)).toBe(true);

await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();

expect(fs.existsSync(conversationFile!)).toBe(false);
});

it('should delete a command-only session', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: '/resume',
model: 'gemini-pro',
});
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();

await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();

expect(fs.existsSync(conversationFile!)).toBe(false);
});

it('should keep a session with a real user message', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: 'Help me debug this test',
model: 'gemini-pro',
});
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();

await chatRecordingService.deleteCurrentSessionIfNotResumableAsync();

expect(fs.existsSync(conversationFile!)).toBe(true);
});
});

describe('recordDirectories', () => {
beforeEach(async () => {
await chatRecordingService.initialize();
Expand Down
Loading
Loading