mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-28 15:01:14 -07:00
Add global session history and naming workflow
This commit is contained in:
@@ -224,7 +224,7 @@ export async function parseArguments(
|
||||
// one, and not being passed at all.
|
||||
skipValidation: true,
|
||||
description:
|
||||
'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)',
|
||||
'Resume a previous session by name, index, UUID, or "latest" (e.g. --resume my-chat-abc12)',
|
||||
coerce: (value: string): string => {
|
||||
// When --resume passed with a value (`gemini --resume 123`): value = "123" (string)
|
||||
// When --resume passed without a value (`gemini --resume`): value = "" (string)
|
||||
@@ -238,13 +238,12 @@ export async function parseArguments(
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'List available sessions for the current project and exit.',
|
||||
description: 'List available sessions across all projects and exit.',
|
||||
})
|
||||
.option('delete-session', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Delete a session by index number (use --list-sessions to see available sessions).',
|
||||
'Delete a session by name, index, or UUID (use --list-sessions to see available sessions).',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
|
||||
@@ -79,7 +79,10 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionSelector } from './utils/sessionUtils.js';
|
||||
import {
|
||||
getConversationSessionName,
|
||||
SessionSelector,
|
||||
} from './utils/sessionUtils.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
import { StreamingState } from './ui/types.js';
|
||||
@@ -790,6 +793,17 @@ export async function main() {
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
});
|
||||
|
||||
const conversation = config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService()
|
||||
?.getConversation();
|
||||
if (conversation) {
|
||||
const sessionName = getConversationSessionName(conversation);
|
||||
writeToStdout(
|
||||
`\nSession: ${sessionName}\nYou can resume your session by typing: gemini --resume ${sessionName}\n`,
|
||||
);
|
||||
}
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
|
||||
@@ -190,6 +190,7 @@ const mockUIActions: UIActions = {
|
||||
closeSessionBrowser: vi.fn(),
|
||||
handleResumeSession: vi.fn(),
|
||||
handleDeleteSession: vi.fn(),
|
||||
handleRenameSession: vi.fn(),
|
||||
closePermissionsDialog: vi.fn(),
|
||||
setShellModeActive: vi.fn(),
|
||||
vimHandleInput: vi.fn(),
|
||||
|
||||
@@ -97,7 +97,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { basename } from 'node:path';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { computeTerminalTitle } from '../utils/windowTitle.js';
|
||||
import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
@@ -120,7 +120,11 @@ import { type UpdateObject } from './utils/updateCheck.js';
|
||||
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
|
||||
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
|
||||
import type { SessionInfo } from '../utils/sessionUtils.js';
|
||||
import {
|
||||
getConversationSessionName,
|
||||
getGlobalSessionFiles,
|
||||
type SessionInfo,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { useMessageQueue } from './hooks/useMessageQueue.js';
|
||||
import { useMcpStatus } from './hooks/useMcpStatus.js';
|
||||
import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js';
|
||||
@@ -156,6 +160,7 @@ import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
|
||||
import { useSuspend } from './hooks/useSuspend.js';
|
||||
import { CrashResumeDialog } from './components/CrashResumeDialog.js';
|
||||
|
||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||
return pendingHistoryItems.some((item) => {
|
||||
@@ -227,6 +232,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [customDialog, setCustomDialog] = useState<React.ReactNode | null>(
|
||||
null,
|
||||
);
|
||||
const crashRecoveryPromptHandledRef = useRef(false);
|
||||
const [isCrashRecoveryChecked, setIsCrashRecoveryChecked] = useState(false);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
@@ -666,17 +673,164 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
isSessionBrowserOpen,
|
||||
openSessionBrowser,
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleResumeSession: handleResumeSessionBase,
|
||||
handleDeleteSession: handleDeleteSessionSync,
|
||||
handleRenameSession: handleRenameSessionBase,
|
||||
} = useSessionBrowser(config, loadHistoryForResume);
|
||||
// Wrap handleDeleteSession to return a Promise for UIActions interface
|
||||
const handleDeleteSession = useCallback(
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
handleDeleteSessionSync(session);
|
||||
await handleDeleteSessionSync(session);
|
||||
},
|
||||
[handleDeleteSessionSync],
|
||||
);
|
||||
|
||||
const updateSessionRecoveryState = useCallback(
|
||||
(dirtyExit: boolean) => {
|
||||
const chatRecordingService = config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
const conversation = chatRecordingService?.getConversation();
|
||||
const sessionPath = chatRecordingService?.getConversationFilePath();
|
||||
if (!conversation || !sessionPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistentState.set('sessionRecovery', {
|
||||
sessionId: conversation.sessionId,
|
||||
sessionName: getConversationSessionName(conversation),
|
||||
sessionPath,
|
||||
projectRoot: config.getProjectRoot(),
|
||||
dirtyExit,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleResumeSession = useCallback(
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
await handleResumeSessionBase(session);
|
||||
updateSessionRecoveryState(true);
|
||||
},
|
||||
[handleResumeSessionBase, updateSessionRecoveryState],
|
||||
);
|
||||
|
||||
const handleRenameSession = useCallback(
|
||||
async (session: SessionInfo, newNameBase: string): Promise<SessionInfo> => {
|
||||
const updated = await handleRenameSessionBase(session, newNameBase);
|
||||
if (updated.isCurrentSession) {
|
||||
updateSessionRecoveryState(true);
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[handleRenameSessionBase, updateSessionRecoveryState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConfigInitialized || !isCrashRecoveryChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionRecoveryState(true);
|
||||
registerCleanup(() => {
|
||||
updateSessionRecoveryState(false);
|
||||
});
|
||||
}, [isConfigInitialized, isCrashRecoveryChecked, updateSessionRecoveryState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConfigInitialized || crashRecoveryPromptHandledRef.current) {
|
||||
return;
|
||||
}
|
||||
crashRecoveryPromptHandledRef.current = true;
|
||||
const finishCrashCheck = () => {
|
||||
setIsCrashRecoveryChecked(true);
|
||||
};
|
||||
|
||||
if (resumedSessionData) {
|
||||
finishCrashCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
const recovery = persistentState.get('sessionRecovery');
|
||||
if (!recovery || !recovery.dirtyExit) {
|
||||
finishCrashCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolve(recovery.projectRoot) !== resolve(config.getProjectRoot())) {
|
||||
finishCrashCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
const handleRecoveryChoice = async (
|
||||
choice: 'resume' | 'dismiss' | 'browse',
|
||||
) => {
|
||||
const latest = persistentState.get('sessionRecovery');
|
||||
if (latest && latest.sessionId === recovery.sessionId) {
|
||||
persistentState.set('sessionRecovery', {
|
||||
...latest,
|
||||
dirtyExit: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
finishCrashCheck();
|
||||
|
||||
setCustomDialog(null);
|
||||
|
||||
if (choice === 'browse') {
|
||||
openSessionBrowser();
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === 'resume') {
|
||||
try {
|
||||
const sessions = await getGlobalSessionFiles(
|
||||
config.getSessionId(),
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
const targetSession = sessions.find(
|
||||
(session) =>
|
||||
session.id === recovery.sessionId ||
|
||||
session.sessionPath === recovery.sessionPath,
|
||||
);
|
||||
|
||||
if (targetSession) {
|
||||
await handleResumeSession(targetSession);
|
||||
} else {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
'Last crashed session was not found. Opening session browser.',
|
||||
);
|
||||
openSessionBrowser();
|
||||
}
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`Failed to resume crashed session: ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setCustomDialog(
|
||||
<CrashResumeDialog
|
||||
sessionName={recovery.sessionName}
|
||||
projectRoot={recovery.projectRoot}
|
||||
updatedAt={recovery.updatedAt}
|
||||
onSelect={(choice) => {
|
||||
void handleRecoveryChoice(choice);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}, [
|
||||
config,
|
||||
handleResumeSession,
|
||||
isConfigInitialized,
|
||||
openSessionBrowser,
|
||||
resumedSessionData,
|
||||
]);
|
||||
|
||||
// Create handleAuthSelect wrapper for backward compatibility
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
|
||||
@@ -2272,6 +2426,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
handleRenameSession,
|
||||
setQueueErrorMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
@@ -2352,6 +2507,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
closeSessionBrowser,
|
||||
handleResumeSession,
|
||||
handleDeleteSession,
|
||||
handleRenameSession,
|
||||
setQueueErrorMessage,
|
||||
popAllMessages,
|
||||
handleApiKeySubmit,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
import { getConversationSessionName } from '../../utils/sessionUtils.js';
|
||||
|
||||
export const quitCommand: SlashCommand = {
|
||||
name: 'quit',
|
||||
@@ -17,6 +18,16 @@ export const quitCommand: SlashCommand = {
|
||||
const now = Date.now();
|
||||
const { sessionStartTime } = context.session.stats;
|
||||
const wallDuration = now - sessionStartTime.getTime();
|
||||
const conversation = context.services.config
|
||||
?.getGeminiClient()
|
||||
?.getChatRecordingService()
|
||||
?.getConversation();
|
||||
const sessionName = conversation
|
||||
? getConversationSessionName(conversation)
|
||||
: undefined;
|
||||
const resumeCommandHint = sessionName
|
||||
? `gemini --resume ${sessionName}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: 'quit',
|
||||
@@ -29,6 +40,8 @@ export const quitCommand: SlashCommand = {
|
||||
{
|
||||
type: 'quit',
|
||||
duration: formatDuration(wallDuration),
|
||||
...(sessionName ? { sessionName } : {}),
|
||||
...(resumeCommandHint ? { resumeCommandHint } : {}),
|
||||
id: now,
|
||||
},
|
||||
],
|
||||
|
||||
84
packages/cli/src/ui/components/CrashResumeDialog.tsx
Normal file
84
packages/cli/src/ui/components/CrashResumeDialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export type CrashResumeChoice = 'resume' | 'dismiss' | 'browse';
|
||||
|
||||
interface CrashResumeDialogProps {
|
||||
sessionName: string;
|
||||
projectRoot: string;
|
||||
updatedAt: string;
|
||||
onSelect: (choice: CrashResumeChoice) => void;
|
||||
}
|
||||
|
||||
export const CrashResumeDialog: React.FC<CrashResumeDialogProps> = ({
|
||||
sessionName,
|
||||
projectRoot,
|
||||
updatedAt,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape' && !submitting) {
|
||||
setSubmitting(true);
|
||||
onSelect('dismiss');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: !submitting },
|
||||
);
|
||||
|
||||
const options: Array<RadioSelectItem<CrashResumeChoice>> = [
|
||||
{ label: 'Yes, resume last session', value: 'resume', key: 'resume' },
|
||||
{ label: 'No, start fresh', value: 'dismiss', key: 'dismiss' },
|
||||
{ label: 'View session list', value: 'browse', key: 'browse' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Gemini CLI did not exit cleanly last time.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>Resume your previous chat session?</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Session: <Text>{sessionName}</Text>
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
Project: <Text>{projectRoot}</Text>
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>Last update: {updatedAt}</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={(choice) => {
|
||||
setSubmitting(true);
|
||||
onSelect(choice);
|
||||
}}
|
||||
isFocused={!submitting}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -299,6 +299,7 @@ export const DialogManager = ({
|
||||
config={config}
|
||||
onResumeSession={uiActions.handleResumeSession}
|
||||
onDeleteSession={uiActions.handleDeleteSession}
|
||||
onRenameSession={uiActions.handleRenameSession}
|
||||
onExit={uiActions.closeSessionBrowser}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -169,7 +169,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
|
||||
<ModelMessage model={itemForDisplay.model} />
|
||||
)}
|
||||
{itemForDisplay.type === 'quit' && (
|
||||
<SessionSummaryDisplay duration={itemForDisplay.duration} />
|
||||
<SessionSummaryDisplay
|
||||
duration={itemForDisplay.duration}
|
||||
sessionName={itemForDisplay.sessionName}
|
||||
resumeCommandHint={itemForDisplay.resumeCommandHint}
|
||||
/>
|
||||
)}
|
||||
{itemForDisplay.type === 'tool_group' && (
|
||||
<ToolGroupMessage
|
||||
|
||||
@@ -61,6 +61,11 @@ vi.mock('./SessionBrowser.js', async (importOriginal) => {
|
||||
(async () => {
|
||||
// no-op delete handler for tests that don't care about deletion
|
||||
}),
|
||||
props.onRenameSession ??
|
||||
(async (session) => {
|
||||
// no-op rename handler for tests that don't care about rename
|
||||
return session;
|
||||
}),
|
||||
props.onExit,
|
||||
);
|
||||
|
||||
@@ -126,10 +131,17 @@ const createSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
|
||||
id: 'session-id',
|
||||
file: 'session-id',
|
||||
fileName: 'session-id.json',
|
||||
sessionPath: '/tmp/chats/session-id.json',
|
||||
projectTempDir: '/tmp/project',
|
||||
projectId: 'project-id',
|
||||
projectRoot: '/tmp/project-root',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messageCount: 1,
|
||||
displayName: 'Test Session',
|
||||
sessionName: 'test-session-abcde',
|
||||
sessionNameBase: 'test-session',
|
||||
sessionNameSuffix: 'abcde',
|
||||
firstUserMessage: 'Test Session',
|
||||
isCurrentSession: false,
|
||||
index: 0,
|
||||
@@ -153,6 +165,7 @@ describe('SessionBrowser component', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onRenameSession = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
@@ -160,6 +173,7 @@ describe('SessionBrowser component', () => {
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onExit={onExit}
|
||||
testSessions={[]}
|
||||
/>,
|
||||
@@ -190,6 +204,7 @@ describe('SessionBrowser component', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onRenameSession = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
@@ -197,6 +212,7 @@ describe('SessionBrowser component', () => {
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onExit={onExit}
|
||||
testSessions={[session1, session2]}
|
||||
/>,
|
||||
@@ -241,6 +257,7 @@ describe('SessionBrowser component', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onRenameSession = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
@@ -248,6 +265,7 @@ describe('SessionBrowser component', () => {
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onExit={onExit}
|
||||
testSessions={[searchSession, otherSession]}
|
||||
/>,
|
||||
@@ -298,6 +316,7 @@ describe('SessionBrowser component', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onRenameSession = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
@@ -305,6 +324,7 @@ describe('SessionBrowser component', () => {
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onExit={onExit}
|
||||
testSessions={[session1, session2]}
|
||||
/>,
|
||||
@@ -344,6 +364,7 @@ describe('SessionBrowser component', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onRenameSession = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -351,6 +372,7 @@ describe('SessionBrowser component', () => {
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onExit={onExit}
|
||||
testSessions={[currentSession, otherSession]}
|
||||
/>,
|
||||
@@ -369,6 +391,7 @@ describe('SessionBrowser component', () => {
|
||||
const config = createMockConfig();
|
||||
const onResumeSession = vi.fn();
|
||||
const onDeleteSession = vi.fn().mockResolvedValue(undefined);
|
||||
const onRenameSession = vi.fn();
|
||||
const onExit = vi.fn();
|
||||
|
||||
const { lastFrame } = render(
|
||||
@@ -376,6 +399,7 @@ describe('SessionBrowser component', () => {
|
||||
config={config}
|
||||
onResumeSession={onResumeSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onRenameSession={onRenameSession}
|
||||
onExit={onExit}
|
||||
testError="storage failure"
|
||||
/>,
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
cleanMessage,
|
||||
formatRelativeTime,
|
||||
getSessionFiles,
|
||||
getGlobalSessionFiles,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
|
||||
/**
|
||||
@@ -29,6 +28,11 @@ export interface SessionBrowserProps {
|
||||
onResumeSession: (session: SessionInfo) => void;
|
||||
/** Callback when user deletes a session */
|
||||
onDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
/** Callback when user renames a session */
|
||||
onRenameSession: (
|
||||
session: SessionInfo,
|
||||
newNameBase: string,
|
||||
) => Promise<SessionInfo>;
|
||||
/** Callback when user exits the session browser */
|
||||
onExit: () => void;
|
||||
}
|
||||
@@ -63,6 +67,10 @@ export interface SessionBrowserState {
|
||||
isSearchMode: boolean;
|
||||
/** Whether full content has been loaded for search */
|
||||
hasLoadedFullContent: boolean;
|
||||
/** Whether user is actively typing a rename */
|
||||
isRenameMode: boolean;
|
||||
/** Draft rename text */
|
||||
renameQuery: string;
|
||||
|
||||
// Sort state
|
||||
/** Current sort criteria */
|
||||
@@ -95,6 +103,10 @@ export interface SessionBrowserState {
|
||||
setSearchQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** Update search mode state */
|
||||
setIsSearchMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Update rename mode state */
|
||||
setIsRenameMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Update rename query */
|
||||
setRenameQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
/** Update sort order */
|
||||
setSortOrder: React.Dispatch<
|
||||
React.SetStateAction<'date' | 'messages' | 'name'>
|
||||
@@ -170,7 +182,7 @@ const sortSessions = (
|
||||
case 'messages':
|
||||
return b.messageCount - a.messageCount;
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
return a.sessionName.localeCompare(b.sessionName);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -249,7 +261,9 @@ const filterSessions = (
|
||||
return sessions.filter((session) => {
|
||||
const titleMatch =
|
||||
session.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
session.sessionName.toLowerCase().includes(lowerQuery) ||
|
||||
session.id.toLowerCase().includes(lowerQuery) ||
|
||||
(session.projectRoot || '').toLowerCase().includes(lowerQuery) ||
|
||||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
|
||||
|
||||
const contentMatch = session.fullContent
|
||||
@@ -283,6 +297,21 @@ const SearchModeDisplay = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Rename input display component.
|
||||
*/
|
||||
const RenameModeDisplay = ({
|
||||
state,
|
||||
}: {
|
||||
state: SessionBrowserState;
|
||||
}): React.JSX.Element => (
|
||||
<Box marginTop={1}>
|
||||
<Text color={Colors.Gray}>Rename: </Text>
|
||||
<Text color={Colors.AccentPurple}>{state.renameQuery}</Text>
|
||||
<Text color={Colors.Gray}> (Enter to save, Esc to cancel)</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Header component showing session count and sort information.
|
||||
*/
|
||||
@@ -314,6 +343,8 @@ const NavigationHelp = (): React.JSX.Element => (
|
||||
{' '}
|
||||
<Kbd name="Search" shortcut="/" />
|
||||
{' '}
|
||||
<Kbd name="Rename" shortcut="n" />
|
||||
{' '}
|
||||
<Kbd name="Delete" shortcut="x" />
|
||||
{' '}
|
||||
<Kbd name="Quit" shortcut="q" />
|
||||
@@ -380,6 +411,48 @@ const NoResultsDisplay = ({
|
||||
</Box>
|
||||
);
|
||||
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
const parsed = new Date(timestamp);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return timestamp;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const SessionDetailsPanel = ({
|
||||
session,
|
||||
}: {
|
||||
session: SessionInfo | undefined;
|
||||
}): React.JSX.Element | null => {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Selected: <Text color={Colors.Foreground}>{session.sessionName}</Text>
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
Project:{' '}
|
||||
<Text color={Colors.Foreground}>
|
||||
{session.projectRoot || '(unknown)'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
Started:{' '}
|
||||
<Text color={Colors.Foreground}>{formatTimestamp(session.startTime)}</Text>
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
Updated:{' '}
|
||||
<Text color={Colors.Foreground}>
|
||||
{formatTimestamp(session.lastUpdated)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Match snippet display component for search results.
|
||||
*/
|
||||
@@ -472,14 +545,14 @@ const SessionItem = ({
|
||||
|
||||
const truncatedMessage =
|
||||
matchDisplay ||
|
||||
(session.displayName.length === 0 ? (
|
||||
(session.sessionName.length === 0 ? (
|
||||
<Text color={textColor(Colors.Gray)} dimColor>
|
||||
(No messages)
|
||||
</Text>
|
||||
) : session.displayName.length > availableMessageWidth ? (
|
||||
session.displayName.slice(0, availableMessageWidth - 1) + '…'
|
||||
) : session.sessionName.length > availableMessageWidth ? (
|
||||
session.sessionName.slice(0, availableMessageWidth - 1) + '…'
|
||||
) : (
|
||||
session.displayName
|
||||
session.sessionName
|
||||
));
|
||||
|
||||
return (
|
||||
@@ -581,8 +654,9 @@ export const useSessionBrowserState = (
|
||||
const [sortReverse, setSortReverse] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||
const [isRenameMode, setIsRenameMode] = useState(false);
|
||||
const [renameQuery, setRenameQuery] = useState('');
|
||||
const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false);
|
||||
const loadingFullContentRef = useRef(false);
|
||||
|
||||
const filteredAndSortedSessions = useMemo(() => {
|
||||
const filtered = filterSessions(sessions, searchQuery);
|
||||
@@ -593,7 +667,6 @@ export const useSessionBrowserState = (
|
||||
useEffect(() => {
|
||||
if (!searchQuery) {
|
||||
setHasLoadedFullContent(false);
|
||||
loadingFullContentRef.current = false;
|
||||
}
|
||||
}, [searchQuery]);
|
||||
|
||||
@@ -617,6 +690,10 @@ export const useSessionBrowserState = (
|
||||
setSearchQuery,
|
||||
isSearchMode,
|
||||
setIsSearchMode,
|
||||
isRenameMode,
|
||||
setIsRenameMode,
|
||||
renameQuery,
|
||||
setRenameQuery,
|
||||
hasLoadedFullContent,
|
||||
setHasLoadedFullContent,
|
||||
sortOrder,
|
||||
@@ -650,10 +727,9 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
useEffect(() => {
|
||||
const loadSessions = async () => {
|
||||
try {
|
||||
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
|
||||
const sessionData = await getSessionFiles(
|
||||
chatsDir,
|
||||
const sessionData = await getGlobalSessionFiles(
|
||||
config.getSessionId(),
|
||||
config.getProjectRoot(),
|
||||
);
|
||||
setSessions(sessionData);
|
||||
setLoading(false);
|
||||
@@ -673,13 +749,9 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => {
|
||||
const loadFullContent = async () => {
|
||||
if (isSearchMode && !hasLoadedFullContent) {
|
||||
try {
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const sessionData = await getSessionFiles(
|
||||
chatsDir,
|
||||
const sessionData = await getGlobalSessionFiles(
|
||||
config.getSessionId(),
|
||||
config.getProjectRoot(),
|
||||
{ includeFullContent: true },
|
||||
);
|
||||
setSessions(sessionData);
|
||||
@@ -764,10 +836,56 @@ export const useSessionBrowserInput = (
|
||||
cycleSortOrder: () => void,
|
||||
onResumeSession: (session: SessionInfo) => void,
|
||||
onDeleteSession: (session: SessionInfo) => Promise<void>,
|
||||
onRenameSession: (session: SessionInfo, newNameBase: string) => Promise<SessionInfo>,
|
||||
onExit: () => void,
|
||||
) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const selectedSession = state.filteredAndSortedSessions[state.activeIndex];
|
||||
|
||||
if (state.isRenameMode) {
|
||||
if (key.name === 'escape') {
|
||||
state.setIsRenameMode(false);
|
||||
state.setRenameQuery('');
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'backspace') {
|
||||
state.setRenameQuery((prev) => prev.slice(0, -1));
|
||||
return true;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
if (selectedSession) {
|
||||
onRenameSession(selectedSession, state.renameQuery)
|
||||
.then((updatedSession) => {
|
||||
state.setSessions((prev) =>
|
||||
prev.map((session) =>
|
||||
session.id === updatedSession.id ? updatedSession : session,
|
||||
),
|
||||
);
|
||||
state.setIsRenameMode(false);
|
||||
state.setRenameQuery('');
|
||||
})
|
||||
.catch((error) => {
|
||||
state.setError(
|
||||
`Failed to rename session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
key.sequence &&
|
||||
key.sequence.length === 1 &&
|
||||
!key.alt &&
|
||||
!key.ctrl &&
|
||||
!key.cmd
|
||||
) {
|
||||
state.setRenameQuery((prev) => prev + key.sequence);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.isSearchMode) {
|
||||
// Search-specific input handling. Only control/symbols here.
|
||||
if (key.name === 'escape') {
|
||||
@@ -819,6 +937,12 @@ export const useSessionBrowserInput = (
|
||||
else if (key.sequence === '/') {
|
||||
state.setIsSearchMode(true);
|
||||
return true;
|
||||
} else if (key.sequence === 'n' || key.sequence === 'N') {
|
||||
if (selectedSession) {
|
||||
state.setIsRenameMode(true);
|
||||
state.setRenameQuery(selectedSession.sessionNameBase);
|
||||
}
|
||||
return true;
|
||||
} else if (
|
||||
key.sequence === 'q' ||
|
||||
key.sequence === 'Q' ||
|
||||
@@ -829,8 +953,6 @@ export const useSessionBrowserInput = (
|
||||
}
|
||||
// Delete session control.
|
||||
else if (key.sequence === 'x' || key.sequence === 'X') {
|
||||
const selectedSession =
|
||||
state.filteredAndSortedSessions[state.activeIndex];
|
||||
if (selectedSession && !selectedSession.isCurrentSession) {
|
||||
onDeleteSession(selectedSession)
|
||||
.then(() => {
|
||||
@@ -870,10 +992,9 @@ export const useSessionBrowserInput = (
|
||||
// Handling regardless of search mode.
|
||||
if (
|
||||
key.name === 'return' &&
|
||||
state.filteredAndSortedSessions[state.activeIndex]
|
||||
selectedSession &&
|
||||
!state.isSearchMode
|
||||
) {
|
||||
const selectedSession =
|
||||
state.filteredAndSortedSessions[state.activeIndex];
|
||||
// Don't allow resuming the current session
|
||||
if (!selectedSession.isCurrentSession) {
|
||||
onResumeSession(selectedSession);
|
||||
@@ -914,17 +1035,23 @@ export function SessionBrowserView({
|
||||
if (state.sessions.length === 0) {
|
||||
return <SessionBrowserEmpty />;
|
||||
}
|
||||
|
||||
const selectedSession = state.filteredAndSortedSessions[state.activeIndex];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<SessionListHeader state={state} />
|
||||
|
||||
{state.isSearchMode && <SearchModeDisplay state={state} />}
|
||||
{state.isRenameMode && <RenameModeDisplay state={state} />}
|
||||
|
||||
{state.totalSessions === 0 ? (
|
||||
<NoResultsDisplay state={state} />
|
||||
) : (
|
||||
<SessionList state={state} formatRelativeTime={formatRelativeTime} />
|
||||
)}
|
||||
|
||||
{!state.isSearchMode && <SessionDetailsPanel session={selectedSession} />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -933,6 +1060,7 @@ export function SessionBrowser({
|
||||
config,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
onExit,
|
||||
}: SessionBrowserProps): React.JSX.Element {
|
||||
// Use all our custom hooks
|
||||
@@ -946,6 +1074,7 @@ export function SessionBrowser({
|
||||
cycleSortOrder,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
onExit,
|
||||
);
|
||||
|
||||
|
||||
@@ -5,14 +5,33 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { StatsDisplay } from './StatsDisplay.js';
|
||||
import { Colors } from '../colors.js';
|
||||
|
||||
interface SessionSummaryDisplayProps {
|
||||
duration: string;
|
||||
sessionName?: string;
|
||||
resumeCommandHint?: string;
|
||||
}
|
||||
|
||||
export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
|
||||
duration,
|
||||
sessionName,
|
||||
resumeCommandHint,
|
||||
}) => (
|
||||
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
|
||||
<Box flexDirection="column">
|
||||
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
|
||||
{sessionName && resumeCommandHint && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={Colors.Gray}>
|
||||
Session: <Text color={Colors.Foreground}>{sessionName}</Text>
|
||||
</Text>
|
||||
<Text color={Colors.Gray}>
|
||||
You can resume your session by typing:{' '}
|
||||
<Text color={Colors.AccentPurple}>{resumeCommandHint}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -12,13 +12,18 @@ exports[`SessionBrowser component > enters search mode, filters sessions, and re
|
||||
|
||||
exports[`SessionBrowser component > renders a list of sessions and marks current session as disabled 1`] = `
|
||||
" Chat Sessions (2 total) sorted by date desc
|
||||
Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
|
||||
Navigate: ↑/↓ Resume: Enter Search: / Rename: n Delete: x Quit: q
|
||||
Sort: s Reverse: r First/Last: g/G
|
||||
|
||||
Index │ Msgs │ Age │ Name
|
||||
❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current)
|
||||
#2 │ 2 │ 10mo │ First conversation about cats
|
||||
▼"
|
||||
❯ #1 │ 5 │ 10mo │ test-session-abcde (current)
|
||||
#2 │ 2 │ 10mo │ test-session-abcde
|
||||
▼
|
||||
|
||||
Selected: test-session-abcde
|
||||
Project: /tmp/project-root
|
||||
Started: 11/1/2025, 8:00:00 AM
|
||||
Updated: 1/1/2025, 6:30:00 AM"
|
||||
`;
|
||||
|
||||
exports[`SessionBrowser component > shows an error state when loading sessions fails 1`] = `
|
||||
|
||||
@@ -65,6 +65,10 @@ export interface UIActions {
|
||||
closeSessionBrowser: () => void;
|
||||
handleResumeSession: (session: SessionInfo) => Promise<void>;
|
||||
handleDeleteSession: (session: SessionInfo) => Promise<void>;
|
||||
handleRenameSession: (
|
||||
session: SessionInfo,
|
||||
newNameBase: string,
|
||||
) => Promise<SessionInfo>;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
popAllMessages: () => string | undefined;
|
||||
handleApiKeySubmit: (apiKey: string) => Promise<void>;
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
convertSessionToHistoryFormats,
|
||||
} from './useSessionBrowser.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import type {
|
||||
Config,
|
||||
ConversationRecord,
|
||||
@@ -23,25 +22,13 @@ import { coreEvents } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('path');
|
||||
vi.mock('../../utils/sessionUtils.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../utils/sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
getSessionFiles: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp';
|
||||
const MOCKED_CHATS_DIR = '/test/project/temp/chats';
|
||||
const MOCKED_SESSION_ID = 'test-session-123';
|
||||
const MOCKED_CURRENT_SESSION_ID = 'current-session-id';
|
||||
|
||||
describe('useSessionBrowser', () => {
|
||||
const mockedFs = vi.mocked(fs);
|
||||
const mockedPath = vi.mocked(path);
|
||||
const mockedGetSessionFiles = vi.mocked(getSessionFiles);
|
||||
|
||||
const mockConfig = {
|
||||
storage: {
|
||||
@@ -61,7 +48,6 @@ describe('useSessionBrowser', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
|
||||
mockedPath.join.mockImplementation((...args) => args.join('/'));
|
||||
vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue(
|
||||
MOCKED_PROJECT_TEMP_DIR,
|
||||
);
|
||||
@@ -77,11 +63,23 @@ describe('useSessionBrowser', () => {
|
||||
messages: [{ type: 'user', content: 'Hello' } as MessageRecord],
|
||||
} as ConversationRecord;
|
||||
|
||||
const mockSession = {
|
||||
const mockSession: SessionInfo = {
|
||||
id: MOCKED_SESSION_ID,
|
||||
file: MOCKED_FILENAME.replace('.json', ''),
|
||||
fileName: MOCKED_FILENAME,
|
||||
} as SessionInfo;
|
||||
mockedGetSessionFiles.mockResolvedValue([mockSession]);
|
||||
sessionPath: `/test/sessions/${MOCKED_FILENAME}`,
|
||||
projectTempDir: MOCKED_PROJECT_TEMP_DIR,
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
messageCount: 1,
|
||||
displayName: 'Test',
|
||||
sessionName: 'test-session-abcde',
|
||||
sessionNameBase: 'test-session',
|
||||
sessionNameSuffix: 'abcde',
|
||||
firstUserMessage: 'Test',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
};
|
||||
mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -92,7 +90,7 @@ describe('useSessionBrowser', () => {
|
||||
await result.current.handleResumeSession(mockSession);
|
||||
});
|
||||
expect(mockedFs.readFile).toHaveBeenCalledWith(
|
||||
`${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`,
|
||||
mockSession.sessionPath,
|
||||
'utf8',
|
||||
);
|
||||
expect(mockConfig.setSessionId).toHaveBeenCalledWith(
|
||||
@@ -104,10 +102,23 @@ describe('useSessionBrowser', () => {
|
||||
|
||||
it('should handle file read error', async () => {
|
||||
const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json';
|
||||
const mockSession = {
|
||||
const mockSession: SessionInfo = {
|
||||
id: MOCKED_SESSION_ID,
|
||||
file: MOCKED_FILENAME.replace('.json', ''),
|
||||
fileName: MOCKED_FILENAME,
|
||||
} as SessionInfo;
|
||||
sessionPath: `/test/sessions/${MOCKED_FILENAME}`,
|
||||
projectTempDir: MOCKED_PROJECT_TEMP_DIR,
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
messageCount: 1,
|
||||
displayName: 'Test',
|
||||
sessionName: 'test-session-abcde',
|
||||
sessionNameBase: 'test-session',
|
||||
sessionNameSuffix: 'abcde',
|
||||
firstUserMessage: 'Test',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
};
|
||||
mockedFs.readFile.mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -128,10 +139,23 @@ describe('useSessionBrowser', () => {
|
||||
|
||||
it('should handle JSON parse error', async () => {
|
||||
const MOCKED_FILENAME = 'invalid.json';
|
||||
const mockSession = {
|
||||
const mockSession: SessionInfo = {
|
||||
id: MOCKED_SESSION_ID,
|
||||
file: MOCKED_FILENAME.replace('.json', ''),
|
||||
fileName: MOCKED_FILENAME,
|
||||
} as SessionInfo;
|
||||
sessionPath: `/test/sessions/${MOCKED_FILENAME}`,
|
||||
projectTempDir: MOCKED_PROJECT_TEMP_DIR,
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
messageCount: 1,
|
||||
displayName: 'Test',
|
||||
sessionName: 'test-session-abcde',
|
||||
sessionNameBase: 'test-session',
|
||||
sessionNameSuffix: 'abcde',
|
||||
firstUserMessage: 'Test',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
};
|
||||
mockedFs.readFile.mockResolvedValue('invalid json');
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
Config,
|
||||
ConversationRecord,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
convertSessionToHistoryFormats,
|
||||
deleteSessionArtifacts,
|
||||
renameSession,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export { convertSessionToHistoryFormats };
|
||||
@@ -47,18 +49,9 @@ export const useSessionBrowser = (
|
||||
handleResumeSession: useCallback(
|
||||
async (session: SessionInfo) => {
|
||||
try {
|
||||
const chatsDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
|
||||
const fileName = session.fileName;
|
||||
|
||||
const originalFilePath = path.join(chatsDir, fileName);
|
||||
|
||||
// Load up the conversation.
|
||||
const conversation: ConversationRecord = JSON.parse(
|
||||
await fs.readFile(originalFilePath, 'utf8'),
|
||||
const conversation = JSON.parse(
|
||||
await fs.readFile(session.sessionPath, 'utf8'),
|
||||
);
|
||||
|
||||
// Use the old session's ID to continue it.
|
||||
@@ -67,7 +60,7 @@ export const useSessionBrowser = (
|
||||
|
||||
const resumedSessionData = {
|
||||
conversation,
|
||||
filePath: originalFilePath,
|
||||
filePath: session.sessionPath,
|
||||
};
|
||||
|
||||
// We've loaded it; tell the UI about it.
|
||||
@@ -89,27 +82,31 @@ export const useSessionBrowser = (
|
||||
),
|
||||
|
||||
/**
|
||||
* Deletes a session by ID using the ChatRecordingService.
|
||||
* Deletes a session and related tool output artifacts.
|
||||
*/
|
||||
handleDeleteSession: useCallback(
|
||||
(session: SessionInfo) => {
|
||||
// Note: Chat sessions are stored on disk using a filename derived from
|
||||
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
|
||||
// The ChatRecordingService.deleteSession API expects this file basename
|
||||
// (without the ".json" extension), not the full session UUID.
|
||||
async (session: SessionInfo): Promise<void> => {
|
||||
try {
|
||||
const chatRecordingService = config
|
||||
.getGeminiClient()
|
||||
?.getChatRecordingService();
|
||||
if (chatRecordingService) {
|
||||
chatRecordingService.deleteSession(session.file);
|
||||
}
|
||||
await deleteSessionArtifacts(session);
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error deleting session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[config],
|
||||
[],
|
||||
),
|
||||
|
||||
handleRenameSession: useCallback(
|
||||
async (session: SessionInfo, newNameBase: string): Promise<SessionInfo> => {
|
||||
try {
|
||||
const result = await renameSession(session, newNameBase);
|
||||
return result.sessionInfo;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback('error', 'Error renaming session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -184,6 +184,8 @@ export type HistoryItemModel = HistoryItemBase & {
|
||||
export type HistoryItemQuit = HistoryItemBase & {
|
||||
type: 'quit';
|
||||
duration: string;
|
||||
sessionName?: string;
|
||||
resumeCommandHint?: string;
|
||||
};
|
||||
|
||||
export type HistoryItemToolGroup = HistoryItemBase & {
|
||||
|
||||
@@ -15,6 +15,14 @@ interface PersistentStateData {
|
||||
tipsShown?: number;
|
||||
hasSeenScreenReaderNudge?: boolean;
|
||||
focusUiEnabled?: boolean;
|
||||
sessionRecovery?: {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
sessionPath: string;
|
||||
projectRoot: string;
|
||||
dirtyExit: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
// Add other persistent state keys here as needed
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,44 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
}
|
||||
|
||||
// Create test session data
|
||||
function createSessionInfo(overrides: Partial<SessionInfo>): SessionInfo {
|
||||
const id = overrides.id ?? 'session';
|
||||
const displayName = overrides.displayName ?? 'Session';
|
||||
const normalizedDisplayName = displayName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const sessionNameBase =
|
||||
overrides.sessionNameBase ?? (normalizedDisplayName || 'session');
|
||||
const defaultSuffix = id
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.slice(0, 5)
|
||||
.padEnd(5, '0');
|
||||
const sessionNameSuffix = overrides.sessionNameSuffix ?? defaultSuffix;
|
||||
|
||||
return {
|
||||
id,
|
||||
file: `${SESSION_FILE_PREFIX}${id}`,
|
||||
fileName: `${SESSION_FILE_PREFIX}${id}.json`,
|
||||
sessionPath: `/tmp/test-project/chats/${SESSION_FILE_PREFIX}${id}.json`,
|
||||
projectTempDir: '/tmp/test-project',
|
||||
projectRoot: '/workspace/test-project',
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messageCount: 1,
|
||||
displayName,
|
||||
sessionNameBase,
|
||||
sessionNameSuffix,
|
||||
sessionName:
|
||||
overrides.sessionName || `${sessionNameBase}-${sessionNameSuffix}`,
|
||||
firstUserMessage: displayName,
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestSessions(): SessionInfo[] {
|
||||
const now = new Date();
|
||||
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
@@ -59,7 +97,7 @@ function createTestSessions(): SessionInfo[] {
|
||||
const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return [
|
||||
{
|
||||
createSessionInfo({
|
||||
id: 'current123',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
||||
@@ -70,8 +108,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'recent456',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
||||
@@ -82,8 +120,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
firstUserMessage: 'Recent session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'old789abc',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
||||
@@ -94,8 +132,8 @@ function createTestSessions(): SessionInfo[] {
|
||||
firstUserMessage: 'Old session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'ancient12',
|
||||
file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
||||
@@ -106,7 +144,7 @@ function createTestSessions(): SessionInfo[] {
|
||||
firstUserMessage: 'Ancient session',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -444,7 +482,7 @@ describe('Session Cleanup', () => {
|
||||
const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
createSessionInfo({
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
@@ -455,8 +493,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session5d',
|
||||
file: `${SESSION_FILE_PREFIX}5d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
@@ -467,8 +505,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session8d',
|
||||
file: `${SESSION_FILE_PREFIX}8d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
||||
@@ -479,8 +517,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '8 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session15d',
|
||||
file: `${SESSION_FILE_PREFIX}15d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
||||
@@ -491,7 +529,7 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '15 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue(
|
||||
@@ -566,7 +604,7 @@ describe('Session Cleanup', () => {
|
||||
);
|
||||
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
createSessionInfo({
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
@@ -577,8 +615,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session1d',
|
||||
file: `${SESSION_FILE_PREFIX}1d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
||||
@@ -589,8 +627,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '1 day',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session7d',
|
||||
file: `${SESSION_FILE_PREFIX}7d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
@@ -601,8 +639,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session13d',
|
||||
file: `${SESSION_FILE_PREFIX}13d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
||||
@@ -613,7 +651,7 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '13 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue(
|
||||
@@ -662,7 +700,7 @@ describe('Session Cleanup', () => {
|
||||
// Create 6 sessions with different timestamps
|
||||
const now = new Date();
|
||||
const sessions: SessionInfo[] = [
|
||||
{
|
||||
createSessionInfo({
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
@@ -673,13 +711,13 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Add 5 more sessions with decreasing timestamps
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
sessions.push({
|
||||
sessions.push(createSessionInfo({
|
||||
id: `session${i}`,
|
||||
file: `${SESSION_FILE_PREFIX}${i}d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
||||
@@ -690,7 +728,7 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: `${i} days`,
|
||||
isCurrentSession: false,
|
||||
index: i + 1,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue(
|
||||
@@ -788,7 +826,7 @@ describe('Session Cleanup', () => {
|
||||
const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
createSessionInfo({
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
@@ -799,8 +837,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session3d',
|
||||
file: `${SESSION_FILE_PREFIX}3d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
||||
@@ -811,8 +849,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '3 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session5d',
|
||||
file: `${SESSION_FILE_PREFIX}5d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
@@ -823,8 +861,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session7d',
|
||||
file: `${SESSION_FILE_PREFIX}7d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
@@ -835,8 +873,8 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createSessionInfo({
|
||||
id: 'session12d',
|
||||
file: `${SESSION_FILE_PREFIX}12d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
||||
@@ -847,7 +885,7 @@ describe('Session Cleanup', () => {
|
||||
firstUserMessage: '12 days',
|
||||
isCurrentSession: false,
|
||||
index: 5,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetAllSessionFiles.mockResolvedValue(
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
SessionSelector,
|
||||
extractFirstUserMessage,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SessionError,
|
||||
} from './sessionUtils.js';
|
||||
import type { Config, MessageRecord } from '@google/gemini-cli-core';
|
||||
import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core';
|
||||
import { SESSION_FILE_PREFIX, Storage } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
@@ -21,22 +21,26 @@ import { randomUUID } from 'node:crypto';
|
||||
describe('SessionSelector', () => {
|
||||
let tmpDir: string;
|
||||
let config: Config;
|
||||
const projectRoot = '/workspace/project-a';
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tmpDir = path.join(process.cwd(), '.tmp-test-sessions');
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
vi.spyOn(Storage, 'getGlobalTempDir').mockReturnValue(tmpDir);
|
||||
|
||||
// Mock config
|
||||
config = {
|
||||
storage: {
|
||||
getProjectTempDir: () => tmpDir,
|
||||
getProjectTempDir: () => path.join(tmpDir, 'project-a'),
|
||||
},
|
||||
getSessionId: () => 'current-session-id',
|
||||
getProjectRoot: () => projectRoot,
|
||||
} as Partial<Config> as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
// Clean up test files
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
@@ -45,13 +49,20 @@ describe('SessionSelector', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const createChatsDir = async () => {
|
||||
const projectDir = path.join(tmpDir, 'project-a');
|
||||
const chatsDir = path.join(projectDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(projectDir, '.project_root'), projectRoot);
|
||||
return chatsDir;
|
||||
};
|
||||
|
||||
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 chatsDir = await createChatsDir();
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
@@ -116,8 +127,7 @@ describe('SessionSelector', () => {
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
const chatsDir = await createChatsDir();
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
@@ -180,8 +190,7 @@ describe('SessionSelector', () => {
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
const chatsDir = await createChatsDir();
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
@@ -240,8 +249,7 @@ describe('SessionSelector', () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
const chatsDir = await createChatsDir();
|
||||
|
||||
const sessionOriginal = {
|
||||
sessionId,
|
||||
@@ -304,8 +312,7 @@ describe('SessionSelector', () => {
|
||||
const sessionId1 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
const chatsDir = await createChatsDir();
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
@@ -346,8 +353,7 @@ describe('SessionSelector', () => {
|
||||
const sessionIdSystemOnly = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
const chatsDir = await createChatsDir();
|
||||
|
||||
// Session with user message - should be listed
|
||||
const sessionWithUser = {
|
||||
@@ -415,8 +421,7 @@ describe('SessionSelector', () => {
|
||||
const sessionIdGeminiOnly = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
const chatsDir = await createChatsDir();
|
||||
|
||||
// Session with only gemini message - should be listed
|
||||
const sessionGeminiOnly = {
|
||||
|
||||
@@ -5,12 +5,19 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
buildSessionName,
|
||||
checkExhaustive,
|
||||
ensureSessionNameBase,
|
||||
getDefaultSessionNameBase,
|
||||
partListUnionToString,
|
||||
sanitizeFilenamePart,
|
||||
SESSION_FILE_PREFIX,
|
||||
Storage,
|
||||
type Config,
|
||||
type ConversationRecord,
|
||||
type MessageRecord,
|
||||
getSessionNameSuffix,
|
||||
normalizeSessionNameSuffix,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -54,7 +61,7 @@ export class SessionError extends Error {
|
||||
static noSessionsFound(): SessionError {
|
||||
return new SessionError(
|
||||
'NO_SESSIONS_FOUND',
|
||||
'No previous sessions found for this project.',
|
||||
'No previous sessions found.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +71,7 @@ export class SessionError extends Error {
|
||||
static invalidSessionIdentifier(identifier: string): SessionError {
|
||||
return new SessionError(
|
||||
'INVALID_SESSION_IDENTIFIER',
|
||||
`Invalid session identifier "${identifier}".\n Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`,
|
||||
`Invalid session identifier "${identifier}".\n Use --list-sessions to see available sessions, then use --resume {name}, --resume {number}, --resume {uuid}, or --resume latest.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +108,12 @@ export interface SessionInfo {
|
||||
lastUpdated: string;
|
||||
/** Display name for the session (typically first user message) */
|
||||
displayName: string;
|
||||
/** Shell-friendly resumable session name: <base>-<immutableSuffix> */
|
||||
sessionName: string;
|
||||
/** Mutable base segment of sessionName */
|
||||
sessionNameBase: string;
|
||||
/** Immutable 5-char suffix segment of sessionName */
|
||||
sessionNameSuffix: string;
|
||||
/** Cleaned first user message content */
|
||||
firstUserMessage: string;
|
||||
/** Whether this is the currently active session */
|
||||
@@ -109,6 +122,14 @@ export interface SessionInfo {
|
||||
index: number;
|
||||
/** AI-generated summary of the session (if available) */
|
||||
summary?: string;
|
||||
/** Project root where this session was created (if known) */
|
||||
projectRoot?: string;
|
||||
/** Project identifier in ~/.gemini/tmp */
|
||||
projectId?: string;
|
||||
/** Absolute path to project temp directory */
|
||||
projectTempDir: string;
|
||||
/** Absolute path to the session json file */
|
||||
sessionPath: string;
|
||||
/** Full concatenated content (only loaded when needed for search) */
|
||||
fullContent?: string;
|
||||
/** Processed messages with normalized roles (only loaded when needed) */
|
||||
@@ -138,6 +159,27 @@ export interface SessionSelectionResult {
|
||||
displayInfo: string;
|
||||
}
|
||||
|
||||
export interface RenameSessionResult {
|
||||
sessionInfo: SessionInfo;
|
||||
conversation: ConversationRecord;
|
||||
}
|
||||
|
||||
export function getConversationSessionName(
|
||||
conversation: Pick<
|
||||
ConversationRecord,
|
||||
'sessionId' | 'startTime' | 'sessionNameBase' | 'sessionNameSuffix'
|
||||
>,
|
||||
): string {
|
||||
const base = ensureSessionNameBase(
|
||||
conversation.sessionNameBase ||
|
||||
getDefaultSessionNameBase(new Date(conversation.startTime || Date.now())),
|
||||
);
|
||||
const suffix = normalizeSessionNameSuffix(
|
||||
conversation.sessionNameSuffix || getSessionNameSuffix(conversation.sessionId),
|
||||
);
|
||||
return buildSessionName(base, suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a session has at least one user or assistant (gemini) message.
|
||||
* Sessions with only system messages (info, error, warning) are considered empty.
|
||||
@@ -238,6 +280,13 @@ export interface GetSessionOptions {
|
||||
includeFullContent?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionFileContext {
|
||||
projectRoot?: string;
|
||||
projectId?: string;
|
||||
projectTempDir: string;
|
||||
isCurrentProject: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all session files (including corrupted ones) from the chats directory.
|
||||
* @returns Array of session file entries, with sessionInfo null for corrupted files
|
||||
@@ -246,6 +295,10 @@ export const getAllSessionFiles = async (
|
||||
chatsDir: string,
|
||||
currentSessionId?: string,
|
||||
options: GetSessionOptions = {},
|
||||
context: SessionFileContext = {
|
||||
projectTempDir: path.dirname(chatsDir),
|
||||
isCurrentProject: false,
|
||||
},
|
||||
): Promise<SessionFileEntry[]> => {
|
||||
try {
|
||||
const files = await fs.readdir(chatsDir);
|
||||
@@ -279,9 +332,25 @@ export const getAllSessionFiles = async (
|
||||
}
|
||||
|
||||
const firstUserMessage = extractFirstUserMessage(content.messages);
|
||||
const isCurrentSession = currentSessionId
|
||||
? file.includes(currentSessionId.slice(0, 8))
|
||||
: false;
|
||||
const isCurrentSession = Boolean(
|
||||
context.isCurrentProject &&
|
||||
currentSessionId &&
|
||||
content.sessionId === currentSessionId,
|
||||
);
|
||||
|
||||
const sessionNameSuffix = content.sessionNameSuffix
|
||||
? content.sessionNameSuffix
|
||||
: getSessionNameSuffix(content.sessionId);
|
||||
const sessionNameBase = ensureSessionNameBase(
|
||||
content.sessionNameBase ||
|
||||
(content.summary
|
||||
? stripUnsafeCharacters(content.summary)
|
||||
: firstUserMessage),
|
||||
);
|
||||
const sessionName = buildSessionName(
|
||||
sessionNameBase,
|
||||
sessionNameSuffix,
|
||||
);
|
||||
|
||||
let fullContent: string | undefined;
|
||||
let messages:
|
||||
@@ -290,9 +359,9 @@ export const getAllSessionFiles = async (
|
||||
|
||||
if (options.includeFullContent) {
|
||||
fullContent = content.messages
|
||||
.map((msg) => partListUnionToString(msg.content))
|
||||
.map((msg: MessageRecord) => partListUnionToString(msg.content))
|
||||
.join(' ');
|
||||
messages = content.messages.map((msg) => ({
|
||||
messages = content.messages.map((msg: MessageRecord) => ({
|
||||
role:
|
||||
msg.type === 'user'
|
||||
? ('user' as const)
|
||||
@@ -305,9 +374,16 @@ export const getAllSessionFiles = async (
|
||||
id: content.sessionId,
|
||||
file: file.replace('.json', ''),
|
||||
fileName: file,
|
||||
sessionPath: filePath,
|
||||
projectTempDir: context.projectTempDir,
|
||||
projectRoot: context.projectRoot,
|
||||
projectId: context.projectId,
|
||||
startTime: content.startTime,
|
||||
lastUpdated: content.lastUpdated,
|
||||
messageCount: content.messages.length,
|
||||
sessionName,
|
||||
sessionNameBase,
|
||||
sessionNameSuffix,
|
||||
displayName: content.summary
|
||||
? stripUnsafeCharacters(content.summary)
|
||||
: firstUserMessage,
|
||||
@@ -351,6 +427,10 @@ export const getSessionFiles = async (
|
||||
chatsDir,
|
||||
currentSessionId,
|
||||
options,
|
||||
{
|
||||
projectTempDir: path.dirname(chatsDir),
|
||||
isCurrentProject: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Filter out corrupted files and extract SessionInfo
|
||||
@@ -388,6 +468,170 @@ export const getSessionFiles = async (
|
||||
return uniqueSessions;
|
||||
};
|
||||
|
||||
interface ProjectChatDirectory {
|
||||
projectId: string;
|
||||
projectTempDir: string;
|
||||
chatsDir: string;
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
async function getProjectChatDirectories(): Promise<ProjectChatDirectory[]> {
|
||||
const globalTempDir = Storage.getGlobalTempDir();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(globalTempDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const directories = await Promise.all(
|
||||
entries.map(async (projectId): Promise<ProjectChatDirectory | null> => {
|
||||
const projectTempDir = path.join(globalTempDir, projectId);
|
||||
const chatsDir = path.join(projectTempDir, 'chats');
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(projectTempDir);
|
||||
if (!stat.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectRoot: string | undefined;
|
||||
try {
|
||||
projectRoot = (
|
||||
await fs.readFile(path.join(projectTempDir, '.project_root'), 'utf8')
|
||||
).trim();
|
||||
} catch {
|
||||
projectRoot = undefined;
|
||||
}
|
||||
|
||||
return { projectId, projectTempDir, chatsDir, projectRoot };
|
||||
}),
|
||||
);
|
||||
|
||||
return directories.filter((entry): entry is ProjectChatDirectory =>
|
||||
Boolean(entry),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getGlobalSessionFiles(
|
||||
currentSessionId?: string,
|
||||
currentProjectRoot?: string,
|
||||
options: GetSessionOptions = {},
|
||||
): Promise<SessionInfo[]> {
|
||||
const projectChatDirs = await getProjectChatDirectories();
|
||||
|
||||
const allEntries = await Promise.all(
|
||||
projectChatDirs.map(async (projectDir) =>
|
||||
getAllSessionFiles(projectDir.chatsDir, currentSessionId, options, {
|
||||
projectRoot: projectDir.projectRoot,
|
||||
projectId: projectDir.projectId,
|
||||
projectTempDir: projectDir.projectTempDir,
|
||||
isCurrentProject:
|
||||
!!currentProjectRoot &&
|
||||
!!projectDir.projectRoot &&
|
||||
path.resolve(currentProjectRoot) === path.resolve(projectDir.projectRoot),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const flattened = allEntries.flat();
|
||||
const validSessions = flattened
|
||||
.filter(
|
||||
(entry): entry is { fileName: string; sessionInfo: SessionInfo } =>
|
||||
entry.sessionInfo !== null,
|
||||
)
|
||||
.map((entry) => entry.sessionInfo);
|
||||
|
||||
const uniqueSessionsMap = new Map<string, SessionInfo>();
|
||||
for (const session of validSessions) {
|
||||
if (
|
||||
!uniqueSessionsMap.has(session.id) ||
|
||||
new Date(session.lastUpdated).getTime() >
|
||||
new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime()
|
||||
) {
|
||||
uniqueSessionsMap.set(session.id, session);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueSessions = Array.from(uniqueSessionsMap.values());
|
||||
uniqueSessions.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
uniqueSessions.forEach((session, index) => {
|
||||
session.index = index + 1;
|
||||
});
|
||||
|
||||
return uniqueSessions;
|
||||
}
|
||||
|
||||
export async function renameSession(
|
||||
session: SessionInfo,
|
||||
newNameBase: string,
|
||||
): Promise<RenameSessionResult> {
|
||||
const conversation: ConversationRecord = JSON.parse(
|
||||
await fs.readFile(session.sessionPath, 'utf8'),
|
||||
);
|
||||
|
||||
const sessionNameBase = ensureSessionNameBase(newNameBase);
|
||||
const existingSuffix =
|
||||
conversation.sessionNameSuffix ||
|
||||
session.sessionNameSuffix ||
|
||||
getSessionNameSuffix(conversation.sessionId);
|
||||
const sessionNameSuffix = normalizeSessionNameSuffix(existingSuffix);
|
||||
|
||||
conversation.sessionNameBase = sessionNameBase;
|
||||
conversation.sessionNameSuffix = sessionNameSuffix;
|
||||
conversation.lastUpdated = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(session.sessionPath, JSON.stringify(conversation, null, 2));
|
||||
|
||||
return {
|
||||
conversation,
|
||||
sessionInfo: {
|
||||
...session,
|
||||
lastUpdated: conversation.lastUpdated,
|
||||
sessionNameBase,
|
||||
sessionNameSuffix,
|
||||
sessionName: buildSessionName(sessionNameBase, sessionNameSuffix),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteSessionArtifacts(session: SessionInfo): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(session.sessionPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Error) ||
|
||||
!('code' in error) ||
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const safeSessionId = sanitizeFilenamePart(session.id);
|
||||
const toolOutputsBase = path.join(session.projectTempDir, 'tool-outputs');
|
||||
const toolOutputDir = path.join(
|
||||
toolOutputsBase,
|
||||
`session-${safeSessionId}`,
|
||||
);
|
||||
const resolvedBase = path.resolve(toolOutputsBase);
|
||||
const resolvedTarget = path.resolve(toolOutputDir);
|
||||
|
||||
if (
|
||||
resolvedTarget === resolvedBase ||
|
||||
!resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.rm(toolOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for session discovery and selection.
|
||||
*/
|
||||
@@ -395,18 +639,17 @@ export class SessionSelector {
|
||||
constructor(private config: Config) {}
|
||||
|
||||
/**
|
||||
* Lists all available sessions for the current project.
|
||||
* Lists all available sessions globally across projects.
|
||||
*/
|
||||
async listSessions(): Promise<SessionInfo[]> {
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
return getGlobalSessionFiles(
|
||||
this.config.getSessionId(),
|
||||
this.config.getProjectRoot(),
|
||||
);
|
||||
return getSessionFiles(chatsDir, this.config.getSessionId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a session by identifier (UUID or numeric index).
|
||||
* Finds a session by identifier (name, UUID or numeric index).
|
||||
*
|
||||
* @param identifier - Can be a full UUID or an index number (1-based)
|
||||
* @returns Promise resolving to the found SessionInfo
|
||||
@@ -425,7 +668,15 @@ export class SessionSelector {
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
// Try to find by UUID first
|
||||
// Try to find by session name first.
|
||||
const sessionByName = sortedSessions.find(
|
||||
(session) => session.sessionName === identifier,
|
||||
);
|
||||
if (sessionByName) {
|
||||
return sessionByName;
|
||||
}
|
||||
|
||||
// Try to find by UUID.
|
||||
const sessionByUuid = sortedSessions.find(
|
||||
(session) => session.id === identifier,
|
||||
);
|
||||
@@ -450,7 +701,7 @@ export class SessionSelector {
|
||||
/**
|
||||
* Resolves a resume argument to a specific session.
|
||||
*
|
||||
* @param resumeArg - Can be "latest", a full UUID, or an index number (1-based)
|
||||
* @param resumeArg - Can be "latest", a session name, a full UUID, or an index number (1-based)
|
||||
* @returns Promise resolving to session selection result
|
||||
*/
|
||||
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
||||
@@ -460,7 +711,7 @@ export class SessionSelector {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No previous sessions found for this project.');
|
||||
throw new Error('No previous sessions found.');
|
||||
}
|
||||
|
||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
||||
@@ -494,11 +745,7 @@ export class SessionSelector {
|
||||
private async selectSession(
|
||||
sessionInfo: SessionInfo,
|
||||
): Promise<SessionSelectionResult> {
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
|
||||
const sessionPath = sessionInfo.sessionPath;
|
||||
|
||||
try {
|
||||
const sessionData: ConversationRecord = JSON.parse(
|
||||
@@ -508,7 +755,7 @@ export class SessionSelector {
|
||||
const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`;
|
||||
|
||||
return {
|
||||
sessionPath,
|
||||
sessionPath: sessionInfo.sessionPath,
|
||||
sessionData,
|
||||
displayInfo,
|
||||
};
|
||||
@@ -578,7 +825,7 @@ export function convertSessionToHistoryFormats(
|
||||
) {
|
||||
uiHistory.push({
|
||||
type: 'tool_group',
|
||||
tools: msg.toolCalls.map((tool) => ({
|
||||
tools: msg.toolCalls.map((tool: NonNullable<typeof msg.toolCalls>[number]) => ({
|
||||
callId: tool.id,
|
||||
name: tool.displayName || tool.name,
|
||||
description: tool.description || '',
|
||||
|
||||
@@ -1,740 +1,192 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { ChatRecordingService } from '@google/gemini-cli-core';
|
||||
import { listSessions, deleteSession } from './sessions.js';
|
||||
import { SessionSelector, type SessionInfo } from './sessionUtils.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
SessionError,
|
||||
type SessionInfo,
|
||||
} from './sessionUtils.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeToStdout: vi.fn(),
|
||||
writeToStderr: vi.fn(),
|
||||
generateSummary: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn(),
|
||||
findSession: vi.fn(),
|
||||
deleteSessionArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the SessionSelector and ChatRecordingService
|
||||
vi.mock('./sessionUtils.js', () => ({
|
||||
SessionSelector: vi.fn(),
|
||||
formatRelativeTime: vi.fn(() => 'some time ago'),
|
||||
}));
|
||||
vi.mock('./sessionUtils.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./sessionUtils.js')>();
|
||||
return {
|
||||
...actual,
|
||||
SessionSelector: vi.fn(),
|
||||
formatRelativeTime: vi.fn(() => 'some time ago'),
|
||||
deleteSessionArtifacts: mocks.deleteSessionArtifacts,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async () => {
|
||||
const actual = await vi.importActual('@google/gemini-cli-core');
|
||||
return {
|
||||
...actual,
|
||||
ChatRecordingService: vi.fn(),
|
||||
generateSummary: vi.fn().mockResolvedValue(undefined),
|
||||
generateSummary: mocks.generateSummary,
|
||||
writeToStdout: mocks.writeToStdout,
|
||||
writeToStderr: mocks.writeToStderr,
|
||||
};
|
||||
});
|
||||
|
||||
describe('listSessions', () => {
|
||||
let mockConfig: Config;
|
||||
let mockListSessions: ReturnType<typeof vi.fn>;
|
||||
const createSession = (overrides: Partial<SessionInfo> = {}): SessionInfo => ({
|
||||
id: 'session-id',
|
||||
file: 'session-file',
|
||||
fileName: 'session-file.json',
|
||||
sessionPath: '/tmp/project/chats/session-file.json',
|
||||
projectTempDir: '/tmp/project',
|
||||
projectId: 'project',
|
||||
projectRoot: '/workspace/project',
|
||||
startTime: '2025-01-20T12:00:00.000Z',
|
||||
lastUpdated: '2025-01-20T12:00:00.000Z',
|
||||
messageCount: 5,
|
||||
displayName: 'Display title',
|
||||
sessionName: 'display-title-abc12',
|
||||
sessionNameBase: 'display-title',
|
||||
sessionNameSuffix: 'abc12',
|
||||
firstUserMessage: 'First user message',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('sessions utils', () => {
|
||||
const mockConfig = {} as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock config
|
||||
mockConfig = {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'),
|
||||
},
|
||||
getSessionId: vi.fn().mockReturnValue('current-session-id'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Create mock listSessions method
|
||||
mockListSessions = vi.fn();
|
||||
|
||||
// Mock SessionSelector constructor to return object with listSessions method
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
listSessions: mockListSessions,
|
||||
}) as unknown as InstanceType<typeof SessionSelector>,
|
||||
listSessions: mocks.listSessions,
|
||||
findSession: mocks.findSession,
|
||||
}) as unknown as SessionSelector,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.writeToStdout.mockClear();
|
||||
mocks.writeToStderr.mockClear();
|
||||
});
|
||||
describe('listSessions', () => {
|
||||
it('prints empty message when no sessions exist', async () => {
|
||||
mocks.listSessions.mockResolvedValue([]);
|
||||
|
||||
it('should display message when no previous sessions were found', async () => {
|
||||
// Arrange: Return empty array from listSessions
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Act
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Assert
|
||||
expect(mockListSessions).toHaveBeenCalledOnce();
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'No previous sessions found for this project.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should list sessions when sessions are found', async () => {
|
||||
// Arrange: Create test sessions
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-2025-01-18T12-00-00-session-1',
|
||||
fileName: 'session-2025-01-18T12-00-00-session-1.json',
|
||||
startTime: twoDaysAgo.toISOString(),
|
||||
lastUpdated: twoDaysAgo.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'First user message',
|
||||
firstUserMessage: 'First user message',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session-2',
|
||||
file: 'session-2025-01-20T11-00-00-session-2',
|
||||
fileName: 'session-2025-01-20T11-00-00-session-2.json',
|
||||
startTime: oneHourAgo.toISOString(),
|
||||
lastUpdated: oneHourAgo.toISOString(),
|
||||
messageCount: 10,
|
||||
displayName: 'Second user message',
|
||||
firstUserMessage: 'Second user message',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'current-session-id',
|
||||
file: 'session-2025-01-20T12-00-00-current-s',
|
||||
fileName: 'session-2025-01-20T12-00-00-current-s.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 3,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 3,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Assert
|
||||
expect(mockListSessions).toHaveBeenCalledOnce();
|
||||
|
||||
// Check that the header was displayed
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'\nAvailable sessions for this project (3):\n',
|
||||
);
|
||||
|
||||
// Check that each session was logged
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1. First user message'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[session-1]'),
|
||||
);
|
||||
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2. Second user message'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[session-2]'),
|
||||
);
|
||||
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('3. Current session'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining(', current)'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[current-session-id]'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should sort sessions by start time (oldest first)', async () => {
|
||||
// Arrange: Create sessions in non-chronological order
|
||||
const session1Time = new Date('2025-01-18T12:00:00.000Z');
|
||||
const session2Time = new Date('2025-01-19T12:00:00.000Z');
|
||||
const session3Time = new Date('2025-01-20T12:00:00.000Z');
|
||||
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-2',
|
||||
file: 'session-2',
|
||||
fileName: 'session-2.json',
|
||||
startTime: session2Time.toISOString(), // Middle
|
||||
lastUpdated: session2Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Middle session',
|
||||
firstUserMessage: 'Middle session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-1',
|
||||
fileName: 'session-1.json',
|
||||
startTime: session1Time.toISOString(), // Oldest
|
||||
lastUpdated: session1Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Oldest session',
|
||||
firstUserMessage: 'Oldest session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session-3',
|
||||
file: 'session-3',
|
||||
fileName: 'session-3.json',
|
||||
startTime: session3Time.toISOString(), // Newest
|
||||
lastUpdated: session3Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Newest session',
|
||||
firstUserMessage: 'Newest session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Assert
|
||||
// Get all the session log calls (skip the header)
|
||||
const sessionCalls = mocks.writeToStdout.mock.calls.filter(
|
||||
(call): call is [string] =>
|
||||
typeof call[0] === 'string' &&
|
||||
call[0].includes('[session-') &&
|
||||
!call[0].includes('Available sessions'),
|
||||
);
|
||||
|
||||
// Verify they are sorted by start time (oldest first)
|
||||
expect(sessionCalls[0][0]).toContain('1. Oldest session');
|
||||
expect(sessionCalls[1][0]).toContain('2. Middle session');
|
||||
expect(sessionCalls[2][0]).toContain('3. Newest session');
|
||||
});
|
||||
|
||||
it('should format session output with relative time and session ID', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'abc123def456',
|
||||
file: 'session-file',
|
||||
fileName: 'session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test message',
|
||||
firstUserMessage: 'Test message',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1. Test message'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('some time ago'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[abc123def456]'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single session', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'single-session',
|
||||
file: 'session-file',
|
||||
fileName: 'session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Only session',
|
||||
firstUserMessage: 'Only session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'\nAvailable sessions for this project (1):\n',
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1. Only session'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining(', current)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display summary as title when available instead of first user message', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-with-summary',
|
||||
file: 'session-file',
|
||||
fileName: 'session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 10,
|
||||
displayName: 'Add dark mode to the app', // Summary
|
||||
firstUserMessage:
|
||||
'How do I add dark mode to my React application with CSS variables?',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
summary: 'Add dark mode to the app',
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await listSessions(mockConfig);
|
||||
|
||||
// Assert: Should show the summary (displayName), not the first user message
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1. Add dark mode to the app'),
|
||||
);
|
||||
expect(mocks.writeToStdout).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('How do I add dark mode to my React application'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteSession', () => {
|
||||
let mockConfig: Config;
|
||||
let mockListSessions: ReturnType<typeof vi.fn>;
|
||||
let mockDeleteSession: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock config
|
||||
mockConfig = {
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'),
|
||||
},
|
||||
getSessionId: vi.fn().mockReturnValue('current-session-id'),
|
||||
} as unknown as Config;
|
||||
|
||||
// Create mock methods
|
||||
mockListSessions = vi.fn();
|
||||
mockDeleteSession = vi.fn();
|
||||
|
||||
// Mock SessionSelector constructor
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
listSessions: mockListSessions,
|
||||
}) as unknown as InstanceType<typeof SessionSelector>,
|
||||
);
|
||||
|
||||
// Mock ChatRecordingService
|
||||
vi.mocked(ChatRecordingService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
deleteSession: mockDeleteSession,
|
||||
}) as unknown as InstanceType<typeof ChatRecordingService>,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should display error when no sessions are found', async () => {
|
||||
// Arrange
|
||||
mockListSessions.mockResolvedValue([]);
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, '1');
|
||||
|
||||
// Assert
|
||||
expect(mockListSessions).toHaveBeenCalledOnce();
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'No sessions found for this project.',
|
||||
);
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete session by UUID', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-uuid-123',
|
||||
file: 'session-file-123',
|
||||
fileName: 'session-file-123.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
mockDeleteSession.mockImplementation(() => {});
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, 'session-uuid-123');
|
||||
|
||||
// Assert
|
||||
expect(mockListSessions).toHaveBeenCalledOnce();
|
||||
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-123');
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'Deleted session 1: Test session (some time ago)',
|
||||
);
|
||||
expect(mocks.writeToStderr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete session by index', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: oneHourAgo.toISOString(),
|
||||
lastUpdated: oneHourAgo.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'First session',
|
||||
firstUserMessage: 'First session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session-2',
|
||||
file: 'session-file-2',
|
||||
fileName: 'session-file-2.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 10,
|
||||
displayName: 'Second session',
|
||||
firstUserMessage: 'Second session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
mockDeleteSession.mockImplementation(() => {});
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, '2');
|
||||
|
||||
// Assert
|
||||
expect(mockListSessions).toHaveBeenCalledOnce();
|
||||
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-2');
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'Deleted session 2: Second session (some time ago)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should display error for invalid session identifier (non-numeric)', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, 'invalid-id');
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Invalid session identifier "invalid-id". Use --list-sessions to see available sessions.',
|
||||
);
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display error for invalid session identifier (out of range)', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, '999');
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Invalid session identifier "999". Use --list-sessions to see available sessions.',
|
||||
);
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display error for invalid session identifier (zero)', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, '0');
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Invalid session identifier "0". Use --list-sessions to see available sessions.',
|
||||
);
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent deletion of current session', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current-session-id',
|
||||
file: 'current-session-file',
|
||||
fileName: 'current-session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act - try to delete by index
|
||||
await deleteSession(mockConfig, '1');
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Cannot delete the current active session.',
|
||||
);
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent deletion of current session by UUID', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current-session-id',
|
||||
file: 'current-session-file',
|
||||
fileName: 'current-session-file.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Current session',
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
|
||||
// Act - try to delete by UUID
|
||||
await deleteSession(mockConfig, 'current-session-id');
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Cannot delete the current active session.',
|
||||
);
|
||||
expect(mockDeleteSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle deletion errors gracefully', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
mockDeleteSession.mockImplementation(() => {
|
||||
throw new Error('File deletion failed');
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'No previous sessions found.',
|
||||
);
|
||||
});
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, '1');
|
||||
it('prints global session list with name and project root', async () => {
|
||||
mocks.listSessions.mockResolvedValue([
|
||||
createSession({
|
||||
id: 'older',
|
||||
sessionName: 'older-chat-aaaaa',
|
||||
startTime: '2025-01-19T12:00:00.000Z',
|
||||
projectRoot: '/workspace/older',
|
||||
}),
|
||||
createSession({
|
||||
id: 'newer',
|
||||
sessionName: 'newer-chat-bbbbb',
|
||||
startTime: '2025-01-20T12:00:00.000Z',
|
||||
projectRoot: '/workspace/newer',
|
||||
isCurrentSession: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Assert
|
||||
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1');
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Failed to delete session: File deletion failed',
|
||||
);
|
||||
await listSessions(mockConfig);
|
||||
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'\nAvailable sessions (2):\n',
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('1. older-chat-aaaaa'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/workspace/older'),
|
||||
);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining(', current)'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error deletion failures', async () => {
|
||||
// Arrange
|
||||
const now = new Date('2025-01-20T12:00:00.000Z');
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Test session',
|
||||
firstUserMessage: 'Test session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
describe('deleteSession', () => {
|
||||
it('prints empty message when no sessions exist', async () => {
|
||||
mocks.listSessions.mockResolvedValue([]);
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
mockDeleteSession.mockImplementation(() => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw 'Unknown error type';
|
||||
await deleteSession(mockConfig, '1');
|
||||
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith('No sessions found.');
|
||||
expect(mocks.deleteSessionArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Act
|
||||
await deleteSession(mockConfig, '1');
|
||||
it('deletes a selected session and prints confirmation', async () => {
|
||||
const target = createSession({ sessionName: 'target-chat-ccccc' });
|
||||
mocks.listSessions.mockResolvedValue([target]);
|
||||
mocks.findSession.mockResolvedValue(target);
|
||||
|
||||
// Assert
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Failed to delete session: Unknown error',
|
||||
);
|
||||
});
|
||||
await deleteSession(mockConfig, 'target-chat-ccccc');
|
||||
|
||||
it('should sort sessions before finding by index', async () => {
|
||||
// Arrange: Create sessions in non-chronological order
|
||||
const session1Time = new Date('2025-01-18T12:00:00.000Z');
|
||||
const session2Time = new Date('2025-01-19T12:00:00.000Z');
|
||||
const session3Time = new Date('2025-01-20T12:00:00.000Z');
|
||||
expect(mocks.findSession).toHaveBeenCalledWith('target-chat-ccccc');
|
||||
expect(mocks.deleteSessionArtifacts).toHaveBeenCalledWith(target);
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
'Deleted session: target-chat-ccccc (some time ago)',
|
||||
);
|
||||
});
|
||||
|
||||
const mockSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'session-3',
|
||||
file: 'session-file-3',
|
||||
fileName: 'session-file-3.json',
|
||||
startTime: session3Time.toISOString(), // Newest
|
||||
lastUpdated: session3Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Newest session',
|
||||
firstUserMessage: 'Newest session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session-1',
|
||||
file: 'session-file-1',
|
||||
fileName: 'session-file-1.json',
|
||||
startTime: session1Time.toISOString(), // Oldest
|
||||
lastUpdated: session1Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Oldest session',
|
||||
firstUserMessage: 'Oldest session',
|
||||
isCurrentSession: false,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session-2',
|
||||
file: 'session-file-2',
|
||||
fileName: 'session-file-2.json',
|
||||
startTime: session2Time.toISOString(), // Middle
|
||||
lastUpdated: session2Time.toISOString(),
|
||||
messageCount: 5,
|
||||
displayName: 'Middle session',
|
||||
firstUserMessage: 'Middle session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
];
|
||||
it('shows SessionError messages for invalid identifiers', async () => {
|
||||
mocks.listSessions.mockResolvedValue([createSession()]);
|
||||
mocks.findSession.mockRejectedValue(
|
||||
SessionError.invalidSessionIdentifier('bad'),
|
||||
);
|
||||
|
||||
mockListSessions.mockResolvedValue(mockSessions);
|
||||
mockDeleteSession.mockImplementation(() => {});
|
||||
await deleteSession(mockConfig, 'bad');
|
||||
|
||||
// Act - delete index 1 (should be oldest session after sorting)
|
||||
await deleteSession(mockConfig, '1');
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid session identifier "bad".'),
|
||||
);
|
||||
expect(mocks.deleteSessionArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1');
|
||||
expect(mocks.writeToStdout).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Oldest session'),
|
||||
);
|
||||
it('prevents deleting the current session', async () => {
|
||||
const current = createSession({
|
||||
id: 'current-session-id',
|
||||
isCurrentSession: true,
|
||||
});
|
||||
mocks.listSessions.mockResolvedValue([current]);
|
||||
mocks.findSession.mockResolvedValue(current);
|
||||
|
||||
await deleteSession(mockConfig, 'current-session-id');
|
||||
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Cannot delete the current active session.',
|
||||
);
|
||||
expect(mocks.deleteSessionArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles deletion failures', async () => {
|
||||
const target = createSession({ sessionName: 'broken-chat-ddddd' });
|
||||
mocks.listSessions.mockResolvedValue([target]);
|
||||
mocks.findSession.mockResolvedValue(target);
|
||||
mocks.deleteSessionArtifacts.mockRejectedValue(new Error('disk failure'));
|
||||
|
||||
await deleteSession(mockConfig, 'broken-chat-ddddd');
|
||||
|
||||
expect(mocks.writeToStderr).toHaveBeenCalledWith(
|
||||
'Failed to delete session: disk failure',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,15 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ChatRecordingService,
|
||||
generateSummary,
|
||||
writeToStderr,
|
||||
writeToStdout,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
deleteSessionArtifacts,
|
||||
formatRelativeTime,
|
||||
SessionError,
|
||||
SessionSelector,
|
||||
type SessionInfo,
|
||||
} from './sessionUtils.js';
|
||||
@@ -25,13 +26,11 @@ export async function listSessions(config: Config): Promise<void> {
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
writeToStdout('No previous sessions found for this project.');
|
||||
writeToStdout('No previous sessions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
writeToStdout(
|
||||
`\nAvailable sessions for this project (${sessions.length}):\n`,
|
||||
);
|
||||
writeToStdout(`\nAvailable sessions (${sessions.length}):\n`);
|
||||
|
||||
sessions
|
||||
.sort(
|
||||
@@ -41,13 +40,11 @@ export async function listSessions(config: Config): Promise<void> {
|
||||
.forEach((session, index) => {
|
||||
const current = session.isCurrentSession ? ', current' : '';
|
||||
const time = formatRelativeTime(session.lastUpdated);
|
||||
const title =
|
||||
session.displayName.length > 100
|
||||
? session.displayName.slice(0, 97) + '...'
|
||||
: session.displayName;
|
||||
const folder = session.projectRoot || '(unknown project)';
|
||||
writeToStdout(
|
||||
` ${index + 1}. ${title} (${time}${current}) [${session.id}]\n`,
|
||||
` ${index + 1}. ${session.sessionName} (${time}${current})\n`,
|
||||
);
|
||||
writeToStdout(` ${folder}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,33 +56,18 @@ export async function deleteSession(
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
writeToStderr('No sessions found for this project.');
|
||||
writeToStderr('No sessions found.');
|
||||
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) {
|
||||
writeToStderr(
|
||||
`Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`,
|
||||
);
|
||||
try {
|
||||
sessionToDelete = await sessionSelector.findSession(sessionIndex);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionError) {
|
||||
writeToStderr(error.message);
|
||||
return;
|
||||
}
|
||||
sessionToDelete = sortedSessions[index - 1];
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deleting the current session
|
||||
@@ -95,13 +77,11 @@ export async function deleteSession(
|
||||
}
|
||||
|
||||
try {
|
||||
// Use ChatRecordingService to delete the session
|
||||
const chatRecordingService = new ChatRecordingService(config);
|
||||
chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
await deleteSessionArtifacts(sessionToDelete);
|
||||
|
||||
const time = formatRelativeTime(sessionToDelete.lastUpdated);
|
||||
writeToStdout(
|
||||
`Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`,
|
||||
`Deleted session: ${sessionToDelete.sessionName} (${time})`,
|
||||
);
|
||||
} catch (error) {
|
||||
writeToStderr(
|
||||
|
||||
@@ -156,6 +156,18 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'session-name-default': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-flash-lite',
|
||||
generateContentConfig: {
|
||||
maxOutputTokens: 128,
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'web-search': {
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {
|
||||
|
||||
@@ -109,6 +109,7 @@ export * from './utils/constants.js';
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
export * from './services/gitService.js';
|
||||
export * from './services/chatRecordingService.js';
|
||||
export * from './services/sessionNamingService.js';
|
||||
export * from './services/fileSystemService.js';
|
||||
export * from './services/sessionSummaryUtils.js';
|
||||
export * from './services/contextManager.js';
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type Status } from '../core/coreToolScheduler.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
|
||||
import { getProjectHash } from '../utils/paths.js';
|
||||
import { sanitizeFilenamePart } from '../utils/fileUtils.js';
|
||||
@@ -20,6 +22,13 @@ import type {
|
||||
} from '@google/genai';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import type { ToolResultDisplay } from '../tools/tools.js';
|
||||
import {
|
||||
ensureSessionNameBase,
|
||||
generateSessionNameBase,
|
||||
getDefaultSessionNameBase,
|
||||
getSessionNameSuffix,
|
||||
normalizeSessionNameBase,
|
||||
} from './sessionNamingService.js';
|
||||
|
||||
export const SESSION_FILE_PREFIX = 'session-';
|
||||
|
||||
@@ -99,6 +108,10 @@ export interface ConversationRecord {
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
messages: MessageRecord[];
|
||||
/** User-defined or AI-generated session name base (suffix stored separately). */
|
||||
sessionNameBase?: string;
|
||||
/** Immutable session name suffix derived from sessionId. */
|
||||
sessionNameSuffix?: string;
|
||||
summary?: string;
|
||||
/** Workspace directories added during the session via /dir add */
|
||||
directories?: string[];
|
||||
@@ -131,11 +144,16 @@ export class ChatRecordingService {
|
||||
private queuedThoughts: Array<ThoughtSummary & { timestamp: string }> = [];
|
||||
private queuedTokens: TokensSummary | null = null;
|
||||
private config: Config;
|
||||
private sessionNameSuffix: string;
|
||||
private fallbackSessionNameBase: string;
|
||||
private sessionNameGenerationStarted = false;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
this.sessionId = config.getSessionId();
|
||||
this.projectHash = getProjectHash(config.getProjectRoot());
|
||||
this.sessionNameSuffix = getSessionNameSuffix(this.sessionId);
|
||||
this.fallbackSessionNameBase = getDefaultSessionNameBase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,15 +166,22 @@ export class ChatRecordingService {
|
||||
// Resume from existing session
|
||||
this.conversationFile = resumedSessionData.filePath;
|
||||
this.sessionId = resumedSessionData.conversation.sessionId;
|
||||
this.sessionNameSuffix = getSessionNameSuffix(this.sessionId);
|
||||
this.fallbackSessionNameBase = getDefaultSessionNameBase(
|
||||
new Date(resumedSessionData.conversation.startTime || Date.now()),
|
||||
);
|
||||
this.sessionNameGenerationStarted = true;
|
||||
|
||||
// Update the session ID in the existing file
|
||||
this.updateConversation((conversation) => {
|
||||
conversation.sessionId = this.sessionId;
|
||||
this.ensureSessionNaming(conversation);
|
||||
});
|
||||
|
||||
// Clear any cached data to force fresh reads
|
||||
this.cachedLastConvData = null;
|
||||
} else {
|
||||
this.sessionNameGenerationStarted = false;
|
||||
// Create new session
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
@@ -179,6 +204,8 @@ export class ChatRecordingService {
|
||||
projectHash: this.projectHash,
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
sessionNameBase: this.fallbackSessionNameBase,
|
||||
sessionNameSuffix: this.sessionNameSuffix,
|
||||
messages: [],
|
||||
});
|
||||
}
|
||||
@@ -234,8 +261,13 @@ export class ChatRecordingService {
|
||||
}): void {
|
||||
if (!this.conversationFile) return;
|
||||
|
||||
let firstUserMessage: string | null = null;
|
||||
let expectedSessionNameBase: string | null = null;
|
||||
|
||||
try {
|
||||
this.updateConversation((conversation) => {
|
||||
this.ensureSessionNaming(conversation);
|
||||
|
||||
const msg = this.newMessage(
|
||||
message.type,
|
||||
message.content,
|
||||
@@ -254,8 +286,24 @@ export class ChatRecordingService {
|
||||
} else {
|
||||
// Or else just add it.
|
||||
conversation.messages.push(msg);
|
||||
|
||||
const meaningfulUserMessage = this.getMeaningfulUserMessage(
|
||||
message.content,
|
||||
);
|
||||
if (meaningfulUserMessage && !this.sessionNameGenerationStarted) {
|
||||
this.sessionNameGenerationStarted = true;
|
||||
firstUserMessage = meaningfulUserMessage;
|
||||
expectedSessionNameBase = conversation.sessionNameBase ?? null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (firstUserMessage && expectedSessionNameBase) {
|
||||
void this.generateAndSaveSessionName(
|
||||
firstUserMessage,
|
||||
expectedSessionNameBase,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error saving message to chat history.', error);
|
||||
throw error;
|
||||
@@ -433,6 +481,8 @@ export class ChatRecordingService {
|
||||
projectHash: this.projectHash,
|
||||
startTime: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
sessionNameBase: this.fallbackSessionNameBase,
|
||||
sessionNameSuffix: this.sessionNameSuffix,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
@@ -486,6 +536,68 @@ export class ChatRecordingService {
|
||||
this.writeConversation(conversation);
|
||||
}
|
||||
|
||||
private ensureSessionNaming(conversation: ConversationRecord): void {
|
||||
conversation.sessionNameSuffix =
|
||||
conversation.sessionNameSuffix || this.sessionNameSuffix;
|
||||
conversation.sessionNameBase = ensureSessionNameBase(
|
||||
conversation.sessionNameBase || this.fallbackSessionNameBase,
|
||||
);
|
||||
}
|
||||
|
||||
private getMeaningfulUserMessage(content: PartListUnion): string | null {
|
||||
const text = partListUnionToString(content).trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
if (text.startsWith('/') || text.startsWith('?')) {
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private async generateAndSaveSessionName(
|
||||
firstUserRequest: string,
|
||||
expectedSessionNameBase: string,
|
||||
): Promise<void> {
|
||||
if (!this.conversationFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLlmClient = new BaseLlmClient(contentGenerator, this.config);
|
||||
const generatedBase = await generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest,
|
||||
});
|
||||
if (!generatedBase) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateConversation((conversation) => {
|
||||
this.ensureSessionNaming(conversation);
|
||||
|
||||
const currentBase = normalizeSessionNameBase(
|
||||
conversation.sessionNameBase || '',
|
||||
);
|
||||
const expectedBase = normalizeSessionNameBase(expectedSessionNameBase);
|
||||
|
||||
// Avoid overwriting user-initiated renames while async generation was pending.
|
||||
if (currentBase.length === 0 || currentBase === expectedBase) {
|
||||
conversation.sessionNameBase = generatedBase;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.debug(
|
||||
`[SessionName] Failed to generate session name: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a summary for the current session.
|
||||
*/
|
||||
|
||||
80
packages/core/src/services/sessionNamingService.test.ts
Normal file
80
packages/core/src/services/sessionNamingService.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import {
|
||||
buildSessionName,
|
||||
ensureSessionNameBase,
|
||||
generateSessionNameBase,
|
||||
getDefaultSessionNameBase,
|
||||
getSessionNameSuffix,
|
||||
normalizeSessionNameBase,
|
||||
normalizeSessionNameSuffix,
|
||||
} from './sessionNamingService.js';
|
||||
|
||||
describe('sessionNamingService', () => {
|
||||
it('normalizes session name base as kebab-case', () => {
|
||||
expect(normalizeSessionNameBase('Fix Login Bug!!')).toBe('fix-login-bug');
|
||||
});
|
||||
|
||||
it('returns default fallback base when empty', () => {
|
||||
const base = ensureSessionNameBase('');
|
||||
expect(base.startsWith('session-')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates deterministic 5-char suffix', () => {
|
||||
expect(getSessionNameSuffix('123e4567-e89b-12d3-a456-426614174000')).toBe(
|
||||
'123e4',
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes suffix with padding', () => {
|
||||
expect(normalizeSessionNameSuffix('ab')).toBe('ab000');
|
||||
});
|
||||
|
||||
it('builds full name with immutable suffix', () => {
|
||||
expect(buildSessionName('Fix auth flow', 'abc12')).toBe(
|
||||
'fix-auth-flow-abc12',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats default session base from timestamp', () => {
|
||||
const base = getDefaultSessionNameBase(new Date('2026-02-13T12:34:56Z'));
|
||||
expect(base).toBe('session-20260213-123456');
|
||||
});
|
||||
|
||||
it('generates normalized name from model output', async () => {
|
||||
const baseLlmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'Fix API auth bug' }] } }],
|
||||
}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const generated = await generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest: 'Please help fix our API authentication bug',
|
||||
});
|
||||
|
||||
expect(generated).toBe('fix-api-auth-bug');
|
||||
});
|
||||
|
||||
it('returns null when model output is empty', async () => {
|
||||
const baseLlmClient = {
|
||||
generateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: ' ' }] } }],
|
||||
}),
|
||||
} as unknown as BaseLlmClient;
|
||||
|
||||
const generated = await generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest: 'name this',
|
||||
});
|
||||
|
||||
expect(generated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
128
packages/core/src/services/sessionNamingService.ts
Normal file
128
packages/core/src/services/sessionNamingService.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { BaseLlmClient } from '../core/baseLlmClient.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
|
||||
export const SESSION_NAME_SUFFIX_LENGTH = 5;
|
||||
const SESSION_NAME_BASE_MAX_LENGTH = 60;
|
||||
const SESSION_NAME_TIMEOUT_MS = 4000;
|
||||
|
||||
const SESSION_NAME_PROMPT = `Generate a short session name based on the user's FIRST request.
|
||||
|
||||
Rules:
|
||||
- 3 to 8 words max
|
||||
- Focus on user's goal
|
||||
- No punctuation
|
||||
- No quotes
|
||||
|
||||
First user request:
|
||||
{request}
|
||||
|
||||
Session name:`;
|
||||
|
||||
function leftPad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
export function getDefaultSessionNameBase(date: Date = new Date()): string {
|
||||
return `session-${date.getUTCFullYear()}${leftPad(date.getUTCMonth() + 1)}${leftPad(date.getUTCDate())}-${leftPad(date.getUTCHours())}${leftPad(date.getUTCMinutes())}${leftPad(date.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function normalizeSessionNameBase(input: string): string {
|
||||
const normalized = input
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\x00-\x7F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, SESSION_NAME_BASE_MAX_LENGTH)
|
||||
.replace(/-+$/g, '');
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeSessionNameSuffix(input: string): string {
|
||||
const alphanumeric = input.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
if (alphanumeric.length === 0) {
|
||||
return '00000';
|
||||
}
|
||||
|
||||
if (alphanumeric.length >= SESSION_NAME_SUFFIX_LENGTH) {
|
||||
return alphanumeric.slice(0, SESSION_NAME_SUFFIX_LENGTH);
|
||||
}
|
||||
|
||||
return alphanumeric.padEnd(SESSION_NAME_SUFFIX_LENGTH, '0');
|
||||
}
|
||||
|
||||
export function getSessionNameSuffix(sessionId: string): string {
|
||||
return normalizeSessionNameSuffix(sessionId);
|
||||
}
|
||||
|
||||
export function ensureSessionNameBase(base: string | undefined): string {
|
||||
const normalized = normalizeSessionNameBase(base ?? '');
|
||||
if (normalized.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
return getDefaultSessionNameBase();
|
||||
}
|
||||
|
||||
export function buildSessionName(base: string, suffix: string): string {
|
||||
return `${ensureSessionNameBase(base)}-${normalizeSessionNameSuffix(suffix)}`;
|
||||
}
|
||||
|
||||
export interface GenerateSessionNameOptions {
|
||||
baseLlmClient: BaseLlmClient;
|
||||
firstUserRequest: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function generateSessionNameBase({
|
||||
baseLlmClient,
|
||||
firstUserRequest,
|
||||
timeoutMs = SESSION_NAME_TIMEOUT_MS,
|
||||
}: GenerateSessionNameOptions): Promise<string | null> {
|
||||
const trimmedRequest = firstUserRequest.trim();
|
||||
if (!trimmedRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prompt = SESSION_NAME_PROMPT.replace('{request}', trimmedRequest);
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
];
|
||||
|
||||
const response = await baseLlmClient.generateContent({
|
||||
modelConfigKey: { model: 'session-name-default' },
|
||||
contents,
|
||||
abortSignal: abortController.signal,
|
||||
promptId: 'session-name-generation',
|
||||
});
|
||||
|
||||
const generated = getResponseText(response);
|
||||
if (!generated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalizeSessionNameBase(generated);
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user