mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-01 23:44:15 -07:00
feat(sessions): add resuming to geminiChat and add CLI flags for session management (#10719)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -451,6 +451,36 @@ describe('parseArguments', () => {
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
process.stdin.isTTY = false;
|
||||
process.argv = ['node', 'script.js', '--resume', 'session-id'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await expect(parseArguments({} as Settings)).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
process.stdin.isTTY = originalIsTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it('should support comma-separated values for --allowed-tools', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
|
||||
@@ -61,6 +61,9 @@ export interface CliArgs {
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
resume: string | 'latest' | undefined;
|
||||
listSessions: boolean | undefined;
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
screenReader: boolean | undefined;
|
||||
useSmartEdit: boolean | undefined;
|
||||
@@ -172,6 +175,35 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
type: 'boolean',
|
||||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('resume', {
|
||||
alias: 'r',
|
||||
type: 'string',
|
||||
// `skipValidation` so that we can distinguish between it being passed with a value, without
|
||||
// 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)',
|
||||
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)
|
||||
// When --resume not passed at all: this `coerce` function is not called at all, and
|
||||
// `yargsInstance.argv.resume` is undefined.
|
||||
if (value === '') {
|
||||
return 'latest';
|
||||
}
|
||||
return value;
|
||||
},
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'List available sessions for the current project and exit.',
|
||||
})
|
||||
.option('delete-session', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Delete a session by index number (use --list-sessions to see available sessions).',
|
||||
})
|
||||
.option('include-directories', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -227,6 +259,11 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
|
||||
if (argv['prompt'] && argv['promptInteractive']) {
|
||||
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
|
||||
}
|
||||
if (argv.resume && !argv.prompt && !process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
|
||||
);
|
||||
}
|
||||
if (argv.yolo && argv['approvalMode']) {
|
||||
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
|
||||
}
|
||||
@@ -585,6 +622,8 @@ export async function loadCliConfig(
|
||||
maxSessionTurns: settings.model?.maxSessionTurns ?? -1,
|
||||
experimentalZedIntegration: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
listSessions: argv.listSessions || false,
|
||||
deleteSession: argv.deleteSession,
|
||||
enabledExtensions: argv.extensions,
|
||||
extensionLoader: extensionManager,
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from './gemini.js';
|
||||
import { type LoadedSettings } from './config/settings.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { type Config, type ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type InitializationResult } from './core/initializer.js';
|
||||
|
||||
@@ -189,6 +189,8 @@ describe('gemini.tsx main function', () => {
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
@@ -339,6 +341,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
getSandbox: () => false,
|
||||
getDebugMode: () => false,
|
||||
getListExtensions: () => false,
|
||||
getListSessions: () => false,
|
||||
getDeleteSession: () => undefined,
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
@@ -391,6 +395,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
useSmartEdit: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
@@ -488,6 +495,7 @@ describe('startInteractiveUI', () => {
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string,
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
await act(async () => {
|
||||
@@ -496,6 +504,7 @@ describe('startInteractiveUI', () => {
|
||||
settings,
|
||||
startupWarnings,
|
||||
workspaceRoot,
|
||||
resumedSessionData,
|
||||
initializationResult,
|
||||
);
|
||||
});
|
||||
@@ -510,6 +519,7 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -538,6 +548,7 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -563,6 +574,7 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -579,6 +591,7 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
@@ -610,6 +623,7 @@ describe('startInteractiveUI', () => {
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
undefined,
|
||||
mockInitializationResult,
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
runExitCleanup,
|
||||
} from './utils/cleanup.js';
|
||||
import { getCliVersion } from './utils/version.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import {
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
@@ -55,6 +55,7 @@ import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.j
|
||||
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 { computeWindowTitle } from './utils/windowTitle.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { ExtensionManager } from './config/extension-manager.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { requestConsentNonInteractive } from './config/extensions/consent.js';
|
||||
@@ -152,6 +154,7 @@ export async function startInteractiveUI(
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: string[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// When not in screen reader mode, disable line wrapping.
|
||||
@@ -206,6 +209,7 @@ export async function startInteractiveUI(
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
@@ -415,6 +419,19 @@ export async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle --list-sessions flag
|
||||
if (config.getListSessions()) {
|
||||
await listSessions(config);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle --delete-session flag
|
||||
const sessionToDelete = config.getDeleteSession();
|
||||
if (sessionToDelete) {
|
||||
await deleteSession(config, sessionToDelete);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||
// Set this as early as possible to avoid spurious characters from
|
||||
@@ -461,6 +478,26 @@ export async function main() {
|
||||
...(await getUserStartupWarnings()),
|
||||
];
|
||||
|
||||
// Handle --resume flag
|
||||
let resumedSessionData: ResumedSessionData | undefined = undefined;
|
||||
if (argv.resume) {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
try {
|
||||
const result = await sessionSelector.resolveSession(argv.resume);
|
||||
resumedSessionData = {
|
||||
conversation: result.sessionData,
|
||||
filePath: result.sessionPath,
|
||||
};
|
||||
// Use the existing session ID to continue recording to the same session
|
||||
config.setSessionId(resumedSessionData.conversation.sessionId);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
await startInteractiveUI(
|
||||
@@ -468,6 +505,7 @@ export async function main() {
|
||||
settings,
|
||||
startupWarnings,
|
||||
process.cwd(),
|
||||
resumedSessionData,
|
||||
initializationResult,
|
||||
);
|
||||
return;
|
||||
@@ -521,6 +559,7 @@ export async function main() {
|
||||
input,
|
||||
prompt_id,
|
||||
hasDeprecatedPromptArg,
|
||||
resumedSessionData,
|
||||
});
|
||||
// Call cleanup before process.exit, which causes cleanup to not run
|
||||
await runExitCleanup();
|
||||
|
||||
@@ -93,6 +93,7 @@ describe('runNonInteractive', () => {
|
||||
let processStderrSpy: MockInstance;
|
||||
let mockGeminiClient: {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
@@ -142,6 +143,7 @@ describe('runNonInteractive', () => {
|
||||
|
||||
mockGeminiClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
resumeChat: vi.fn().mockResolvedValue(undefined),
|
||||
getChatRecordingService: vi.fn(() => ({
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
CompletedToolCall,
|
||||
UserFeedbackPayload,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
@@ -49,6 +51,7 @@ interface RunNonInteractiveParams {
|
||||
input: string;
|
||||
prompt_id: string;
|
||||
hasDeprecatedPromptArg?: boolean;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
export async function runNonInteractive({
|
||||
@@ -57,6 +60,7 @@ export async function runNonInteractive({
|
||||
input,
|
||||
prompt_id,
|
||||
hasDeprecatedPromptArg,
|
||||
resumedSessionData,
|
||||
}: RunNonInteractiveParams): Promise<void> {
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
@@ -185,6 +189,16 @@ export async function runNonInteractive({
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
// Initialize chat. Resume if resume data is passed.
|
||||
if (resumedSessionData) {
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
).clientHistory,
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit init event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
|
||||
@@ -595,6 +595,334 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Resumption', () => {
|
||||
it('handles resumed session data correctly', async () => {
|
||||
const mockResumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-session-123',
|
||||
projectHash: 'test-project-hash',
|
||||
startTime: '2024-01-01T00:00:00Z',
|
||||
lastUpdated: '2024-01-01T00:00:01Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user' as const,
|
||||
content: 'Hello',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini' as const,
|
||||
content: 'Hi there!',
|
||||
role: 'model' as const,
|
||||
parts: [{ text: 'Hi there!' }],
|
||||
timestamp: '2024-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
filePath: '/tmp/test-session.json',
|
||||
};
|
||||
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
resumedSessionData={mockResumedSessionData}
|
||||
/>,
|
||||
);
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await act(async () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders without resumed session data', async () => {
|
||||
let unmount: () => void;
|
||||
await act(async () => {
|
||||
const result = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
resumedSessionData={undefined}
|
||||
/>,
|
||||
);
|
||||
unmount = result.unmount;
|
||||
});
|
||||
await act(async () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes chat recording service when config has it', () => {
|
||||
const mockChatRecordingService = {
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGeminiClient = {
|
||||
isInitialized: vi.fn(() => true),
|
||||
resumeChat: vi.fn(),
|
||||
getUserTier: vi.fn(),
|
||||
getChatRecordingService: vi.fn(() => mockChatRecordingService),
|
||||
};
|
||||
|
||||
const configWithRecording = {
|
||||
...mockConfig,
|
||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
||||
} as unknown as Config;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={configWithRecording}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
describe('Session Recording Integration', () => {
|
||||
it('provides chat recording service configuration', () => {
|
||||
const mockChatRecordingService = {
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-123'),
|
||||
getCurrentConversation: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGeminiClient = {
|
||||
isInitialized: vi.fn(() => true),
|
||||
resumeChat: vi.fn(),
|
||||
getUserTier: vi.fn(),
|
||||
getChatRecordingService: vi.fn(() => mockChatRecordingService),
|
||||
setHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const configWithRecording = {
|
||||
...mockConfig,
|
||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
||||
getSessionId: vi.fn(() => 'test-session-123'),
|
||||
} as unknown as Config;
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={configWithRecording}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify the recording service structure is correct
|
||||
expect(configWithRecording.getGeminiClient).toBeDefined();
|
||||
expect(mockGeminiClient.getChatRecordingService).toBeDefined();
|
||||
expect(mockChatRecordingService.initialize).toBeDefined();
|
||||
expect(mockChatRecordingService.recordMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles session recording when messages are added', () => {
|
||||
const mockRecordMessage = vi.fn();
|
||||
const mockRecordMessageTokens = vi.fn();
|
||||
|
||||
const mockChatRecordingService = {
|
||||
initialize: vi.fn(),
|
||||
recordMessage: mockRecordMessage,
|
||||
recordMessageTokens: mockRecordMessageTokens,
|
||||
recordToolCalls: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-123'),
|
||||
};
|
||||
|
||||
const mockGeminiClient = {
|
||||
isInitialized: vi.fn(() => true),
|
||||
getChatRecordingService: vi.fn(() => mockChatRecordingService),
|
||||
getUserTier: vi.fn(),
|
||||
};
|
||||
|
||||
const configWithRecording = {
|
||||
...mockConfig,
|
||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
||||
} as unknown as Config;
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={configWithRecording}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The actual recording happens through the useHistory hook
|
||||
// which would be triggered by user interactions
|
||||
expect(mockChatRecordingService.initialize).toBeDefined();
|
||||
expect(mockChatRecordingService.recordMessage).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Resume Flow', () => {
|
||||
it('accepts resumed session data', () => {
|
||||
const mockResumeChat = vi.fn();
|
||||
const mockGeminiClient = {
|
||||
isInitialized: vi.fn(() => true),
|
||||
resumeChat: mockResumeChat,
|
||||
getUserTier: vi.fn(),
|
||||
getChatRecordingService: vi.fn(() => ({
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
};
|
||||
|
||||
const configWithClient = {
|
||||
...mockConfig,
|
||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
||||
} as unknown as Config;
|
||||
|
||||
const resumedData = {
|
||||
conversation: {
|
||||
sessionId: 'resumed-session-456',
|
||||
projectHash: 'project-hash',
|
||||
startTime: '2024-01-01T00:00:00Z',
|
||||
lastUpdated: '2024-01-01T00:01:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user' as const,
|
||||
content: 'Previous question',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini' as const,
|
||||
content: 'Previous answer',
|
||||
role: 'model' as const,
|
||||
parts: [{ text: 'Previous answer' }],
|
||||
timestamp: '2024-01-01T00:00:30Z',
|
||||
tokenCount: { input: 10, output: 20 },
|
||||
},
|
||||
],
|
||||
},
|
||||
filePath: '/tmp/resumed-session.json',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(
|
||||
<AppContainer
|
||||
config={configWithClient}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
resumedSessionData={resumedData}
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify the resume functionality structure is in place
|
||||
expect(mockGeminiClient.resumeChat).toBeDefined();
|
||||
expect(resumedData.conversation.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not attempt resume when client is not initialized', () => {
|
||||
const mockResumeChat = vi.fn();
|
||||
const mockGeminiClient = {
|
||||
isInitialized: vi.fn(() => false), // Not initialized
|
||||
resumeChat: mockResumeChat,
|
||||
getUserTier: vi.fn(),
|
||||
getChatRecordingService: vi.fn(),
|
||||
};
|
||||
|
||||
const configWithClient = {
|
||||
...mockConfig,
|
||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
||||
} as unknown as Config;
|
||||
|
||||
const resumedData = {
|
||||
conversation: {
|
||||
sessionId: 'test-session',
|
||||
projectHash: 'project-hash',
|
||||
startTime: '2024-01-01T00:00:00Z',
|
||||
lastUpdated: '2024-01-01T00:01:00Z',
|
||||
messages: [],
|
||||
},
|
||||
filePath: '/tmp/session.json',
|
||||
};
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={configWithClient}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
resumedSessionData={resumedData}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not call resumeChat when client is not initialized
|
||||
expect(mockResumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Counting from Session Stats', () => {
|
||||
it('tracks token counts from session messages', () => {
|
||||
// Session stats are provided through the SessionStatsProvider context
|
||||
// in the real app, not through the config directly
|
||||
const mockChatRecordingService = {
|
||||
initialize: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
getSessionId: vi.fn(() => 'test-session-123'),
|
||||
getCurrentConversation: vi.fn(() => ({
|
||||
sessionId: 'test-session-123',
|
||||
messages: [],
|
||||
totalInputTokens: 150,
|
||||
totalOutputTokens: 350,
|
||||
})),
|
||||
};
|
||||
|
||||
const mockGeminiClient = {
|
||||
isInitialized: vi.fn(() => true),
|
||||
getChatRecordingService: vi.fn(() => mockChatRecordingService),
|
||||
getUserTier: vi.fn(),
|
||||
};
|
||||
|
||||
const configWithRecording = {
|
||||
...mockConfig,
|
||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
||||
} as unknown as Config;
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={configWithRecording}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
// In the actual app, these stats would be displayed in components
|
||||
// and updated as messages are processed through the recording service
|
||||
expect(mockChatRecordingService.recordMessageTokens).toBeDefined();
|
||||
expect(mockChatRecordingService.getCurrentConversation).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quota and Fallback Integration', () => {
|
||||
it('passes a null proQuotaRequest to UIStateContext by default', async () => {
|
||||
// The default mock from beforeEach already sets proQuotaRequest to null
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
getAllGeminiMdFilenames,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
type ResumedSessionData,
|
||||
recordExitFail,
|
||||
ShellExecutionService,
|
||||
saveApiKey,
|
||||
@@ -105,6 +106,7 @@ import {
|
||||
useExtensionUpdates,
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||
import { requestConsentInteractive } from '../config/extensions/consent.js';
|
||||
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
|
||||
@@ -129,6 +131,7 @@ interface AppContainerProps {
|
||||
startupWarnings?: string[];
|
||||
version: string;
|
||||
initializationResult: InitializationResult;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +147,7 @@ const SHELL_WIDTH_FRACTION = 0.89;
|
||||
const SHELL_HEIGHT_PADDING = 10;
|
||||
|
||||
export const AppContainer = (props: AppContainerProps) => {
|
||||
const { settings, config, initializationResult } = props;
|
||||
const { settings, config, initializationResult, resumedSessionData } = props;
|
||||
const historyManager = useHistory();
|
||||
useMemoryMonitor(historyManager);
|
||||
const [corgiMode, setCorgiMode] = useState(false);
|
||||
@@ -395,6 +398,19 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const isAuthDialogOpen = authState === AuthState.Updating;
|
||||
const isAuthenticating = authState === AuthState.Unauthenticated;
|
||||
|
||||
// Session browser and resume functionality
|
||||
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
|
||||
|
||||
useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
isGeminiClientInitialized,
|
||||
setQuittingMessages,
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
});
|
||||
|
||||
// Create handleAuthSelect wrapper for backward compatibility
|
||||
const handleAuthSelect = useCallback(
|
||||
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('handleAtCommand', () => {
|
||||
}),
|
||||
125,
|
||||
);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it('should handle multiple @file references', async () => {
|
||||
const content1 = 'Content file1';
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
import { MessageType, ToolCallStatus } from '../types.js';
|
||||
import type { MessageRecord } from '@google/gemini-cli-core';
|
||||
|
||||
describe('convertSessionToHistoryFormats', () => {
|
||||
it('should convert empty messages array', () => {
|
||||
const result = convertSessionToHistoryFormats([]);
|
||||
|
||||
expect(result.uiHistory).toEqual([]);
|
||||
expect(result.clientHistory).toEqual([]);
|
||||
});
|
||||
|
||||
it('should convert basic user and gemini messages', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Hello',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
timestamp: '2025-01-01T00:02:00Z',
|
||||
content: 'Hi there!',
|
||||
type: 'gemini',
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(2);
|
||||
expect(result.uiHistory[0]).toEqual({
|
||||
type: MessageType.USER,
|
||||
text: 'Hello',
|
||||
});
|
||||
expect(result.uiHistory[1]).toEqual({
|
||||
type: MessageType.GEMINI,
|
||||
text: 'Hi there!',
|
||||
});
|
||||
|
||||
expect(result.clientHistory).toHaveLength(2);
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
});
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there!' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out slash commands from client history', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: '/help',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
timestamp: '2025-01-01T00:02:00Z',
|
||||
content: '?quit',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
timestamp: '2025-01-01T00:03:00Z',
|
||||
content: 'Regular message',
|
||||
type: 'user',
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
// All messages should appear in UI history
|
||||
expect(result.uiHistory).toHaveLength(3);
|
||||
|
||||
// Only non-slash commands should appear in client history
|
||||
expect(result.clientHistory).toHaveLength(1);
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool calls correctly', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: "I'll help you with that.",
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'bash',
|
||||
displayName: 'Execute Command',
|
||||
description: 'Run bash command',
|
||||
args: { command: 'ls -la' },
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .',
|
||||
renderOutputAsMarkdown: false,
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'read',
|
||||
displayName: 'Read File',
|
||||
description: 'Read file contents',
|
||||
args: { path: '/etc/hosts' },
|
||||
status: 'error',
|
||||
timestamp: '2025-01-01T00:01:45Z',
|
||||
resultDisplay: 'Permission denied',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(2); // text message + tool group
|
||||
expect(result.uiHistory[0]).toEqual({
|
||||
type: MessageType.GEMINI,
|
||||
text: "I'll help you with that.",
|
||||
});
|
||||
|
||||
expect(result.uiHistory[1].type).toBe('tool_group');
|
||||
// This if-statement is only necessary because TypeScript can't tell that the toBe() assertion
|
||||
// protects the .tools access below.
|
||||
if (result.uiHistory[1].type === 'tool_group') {
|
||||
expect(result.uiHistory[1].tools).toHaveLength(2);
|
||||
expect(result.uiHistory[1].tools[0]).toEqual({
|
||||
callId: 'tool-1',
|
||||
name: 'Execute Command',
|
||||
description: 'Run bash command',
|
||||
renderOutputAsMarkdown: false,
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: 'total 4\ndrwxr-xr-x 2 user user 4096 Jan 1 00:00 .',
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
expect(result.uiHistory[1].tools[1]).toEqual({
|
||||
callId: 'tool-2',
|
||||
name: 'Read File',
|
||||
description: 'Read file contents',
|
||||
renderOutputAsMarkdown: true, // default value
|
||||
status: ToolCallStatus.Error,
|
||||
resultDisplay: 'Permission denied',
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip empty tool calls arrays', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Message with empty tools',
|
||||
type: 'gemini',
|
||||
toolCalls: [],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(1); // Only text message
|
||||
expect(result.uiHistory[0]).toEqual({
|
||||
type: MessageType.GEMINI,
|
||||
text: 'Message with empty tools',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add tool calls for user messages', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'User message',
|
||||
type: 'user',
|
||||
// This would be invalid in real usage, but testing robustness
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'invalid',
|
||||
args: {},
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
},
|
||||
],
|
||||
} as MessageRecord,
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(1); // Only user message, no tool group
|
||||
expect(result.uiHistory[0]).toEqual({
|
||||
type: MessageType.USER,
|
||||
text: 'User message',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing tool call fields gracefully', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Message with minimal tool',
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'minimal_tool',
|
||||
args: {},
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
// Missing optional fields
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.uiHistory).toHaveLength(2);
|
||||
expect(result.uiHistory[1].type).toBe('tool_group');
|
||||
if (result.uiHistory[1].type === 'tool_group') {
|
||||
expect(result.uiHistory[1].tools[0]).toEqual({
|
||||
callId: 'tool-1',
|
||||
name: 'minimal_tool', // Falls back to name when displayName missing
|
||||
description: '', // Default empty string
|
||||
renderOutputAsMarkdown: true, // Default value
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
});
|
||||
} else {
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
});
|
||||
|
||||
describe('tool calls in client history', () => {
|
||||
it('should convert tool calls to correct Gemini client history format', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'List files',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
timestamp: '2025-01-01T00:02:00Z',
|
||||
content: "I'll list the files for you.",
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'list_directory',
|
||||
args: { path: '/home/user' },
|
||||
result: {
|
||||
functionResponse: {
|
||||
id: 'list_directory-1753650620141-f3b8b9e73919d',
|
||||
name: 'list_directory',
|
||||
response: {
|
||||
output: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:02:30Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
// Should have: user message, model with function call, user with function response
|
||||
expect(result.clientHistory).toHaveLength(3);
|
||||
|
||||
// User message
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'List files' }],
|
||||
});
|
||||
|
||||
// Model message with function call
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: "I'll list the files for you." },
|
||||
{
|
||||
functionCall: {
|
||||
name: 'list_directory',
|
||||
args: { path: '/home/user' },
|
||||
id: 'tool-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Function response
|
||||
expect(result.clientHistory[2]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'list_directory-1753650620141-f3b8b9e73919d',
|
||||
name: 'list_directory',
|
||||
response: { output: 'file1.txt\nfile2.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool calls without text content', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: '',
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
result: 'file1.txt\nfile2.txt',
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.clientHistory).toHaveLength(2);
|
||||
|
||||
// Model message with only function call (no text)
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
id: 'tool-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Function response
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'bash',
|
||||
response: {
|
||||
output: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple tool calls in one message', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Running multiple commands',
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'bash',
|
||||
args: { command: 'pwd' },
|
||||
result: '/home/user',
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
result: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-2',
|
||||
name: 'bash',
|
||||
response: {
|
||||
output: 'file1.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-2',
|
||||
name: 'bash',
|
||||
response: {
|
||||
output: 'file2.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:35Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
// Should have: model with both function calls, then one response
|
||||
expect(result.clientHistory).toHaveLength(2);
|
||||
|
||||
// Model message with both function calls
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Running multiple commands' },
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'pwd' },
|
||||
id: 'tool-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
id: 'tool-2',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// First function response
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'bash',
|
||||
response: { output: '/home/user' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-2',
|
||||
name: 'bash',
|
||||
response: { output: 'file1.txt' },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-2',
|
||||
name: 'bash',
|
||||
response: { output: 'file2.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle Part array results from tools', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Reading file',
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
args: { path: 'test.txt' },
|
||||
result: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: 'Hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: ' World',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
status: 'success',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
expect(result.clientHistory).toHaveLength(2);
|
||||
|
||||
// Function response should extract both function responses
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: 'Hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
response: {
|
||||
output: ' World',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip tool calls without results', () => {
|
||||
const messages: MessageRecord[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Testing tool',
|
||||
type: 'gemini',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: { arg: 'value' },
|
||||
// No result field
|
||||
status: 'error',
|
||||
timestamp: '2025-01-01T00:01:30Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = convertSessionToHistoryFormats(messages);
|
||||
|
||||
// Should only have the model message with function call, no function response
|
||||
expect(result.clientHistory).toHaveLength(1);
|
||||
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Testing tool' },
|
||||
{
|
||||
functionCall: {
|
||||
name: 'test_tool',
|
||||
args: { arg: 'value' },
|
||||
id: 'tool-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { ConversationRecord } from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { partListUnionToString } from '@google/gemini-cli-core';
|
||||
import { MessageType, ToolCallStatus } from '../types.js';
|
||||
/**
|
||||
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Add the message only if it has content
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
let messageType: MessageType;
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
messageType = MessageType.USER;
|
||||
break;
|
||||
default:
|
||||
messageType = MessageType.GEMINI;
|
||||
break;
|
||||
}
|
||||
|
||||
uiHistory.push({
|
||||
type: messageType,
|
||||
text: contentString,
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool calls if present
|
||||
if (
|
||||
msg.type !== 'user' &&
|
||||
'toolCalls' in msg &&
|
||||
msg.toolCalls &&
|
||||
msg.toolCalls.length > 0
|
||||
) {
|
||||
uiHistory.push({
|
||||
type: 'tool_group',
|
||||
tools: msg.toolCalls.map((tool) => ({
|
||||
callId: tool.id,
|
||||
name: tool.displayName || tool.name,
|
||||
description: tool.description || '',
|
||||
renderOutputAsMarkdown: tool.renderOutputAsMarkdown ?? true,
|
||||
status:
|
||||
tool.status === 'success'
|
||||
? ToolCallStatus.Success
|
||||
: ToolCallStatus.Error,
|
||||
resultDisplay: tool.resultDisplay,
|
||||
confirmationDetails: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Gemini client history format
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip system/error messages and user slash commands
|
||||
// if (msg.type === 'system' || msg.type === 'error') {
|
||||
// continue;
|
||||
// }
|
||||
|
||||
if (msg.type === 'user') {
|
||||
// Skip user slash commands
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add regular user message
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Handle Gemini messages with potential tool calls
|
||||
const hasToolCalls =
|
||||
'toolCalls' in msg && msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
// Create model message with function calls
|
||||
const modelParts: Part[] = [];
|
||||
|
||||
// Add text content if present
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
// Add function calls
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
// Create single function response message with all tool call responses
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
// Convert PartListUnion result to function response format
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// toolCall.result is an array containing properly formatted
|
||||
// function responses
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
// Fallback for non-array results
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add user message if we have function responses
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular Gemini message without tool calls
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { useSessionResume } from './useSessionResume.js';
|
||||
import type {
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
|
||||
describe('useSessionResume', () => {
|
||||
// Mock dependencies
|
||||
const mockGeminiClient = {
|
||||
resumeChat: vi.fn(),
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
};
|
||||
|
||||
const createMockHistoryManager = (): UseHistoryManagerReturn => ({
|
||||
history: [],
|
||||
addItem: vi.fn(),
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
});
|
||||
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
|
||||
const mockRefreshStatic = vi.fn();
|
||||
const mockSetQuittingMessages = vi.fn();
|
||||
|
||||
const getDefaultProps = () => ({
|
||||
config: mockConfig as unknown as Config,
|
||||
historyManager: mockHistoryManager,
|
||||
refreshStatic: mockRefreshStatic,
|
||||
isGeminiClientInitialized: true,
|
||||
setQuittingMessages: mockSetQuittingMessages,
|
||||
resumedSessionData: undefined,
|
||||
isAuthenticating: false,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHistoryManager = createMockHistoryManager();
|
||||
});
|
||||
|
||||
describe('loadHistoryForResume', () => {
|
||||
it('should return a loadHistoryForResume callback', () => {
|
||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
expect(result.current.loadHistoryForResume).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should clear history and add items when loading history', () => {
|
||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
const uiHistory: HistoryItemWithoutId[] = [
|
||||
{ type: 'user', text: 'Hello' },
|
||||
{ type: 'gemini', text: 'Hi there!' },
|
||||
];
|
||||
|
||||
const clientHistory = [
|
||||
{ role: 'user' as const, parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model' as const, parts: [{ text: 'Hi there!' }] },
|
||||
];
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [] as MessageRecord[],
|
||||
},
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.loadHistoryForResume(
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
resumedData,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockSetQuittingMessages).toHaveBeenCalledWith(null);
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ type: 'user', text: 'Hello' },
|
||||
0,
|
||||
);
|
||||
expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ type: 'gemini', text: 'Hi there!' },
|
||||
1,
|
||||
);
|
||||
expect(mockRefreshStatic).toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
|
||||
clientHistory,
|
||||
resumedData,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not load history if Gemini client is not initialized', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
isGeminiClientInitialized: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const uiHistory: HistoryItemWithoutId[] = [
|
||||
{ type: 'user', text: 'Hello' },
|
||||
];
|
||||
const clientHistory = [
|
||||
{ role: 'user' as const, parts: [{ text: 'Hello' }] },
|
||||
];
|
||||
const resumedData: ResumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [] as MessageRecord[],
|
||||
},
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.loadHistoryForResume(
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
resumedData,
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty history arrays', () => {
|
||||
const { result } = renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
const resumedData: ResumedSessionData = {
|
||||
conversation: {
|
||||
sessionId: 'test-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [] as MessageRecord[],
|
||||
},
|
||||
filePath: '/path/to/session.json',
|
||||
};
|
||||
|
||||
act(() => {
|
||||
result.current.loadHistoryForResume([], [], resumedData);
|
||||
});
|
||||
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
expect(mockRefreshStatic).toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith([], resumedData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callback stability', () => {
|
||||
it('should maintain stable loadHistoryForResume reference across renders', () => {
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useSessionResume(getDefaultProps()),
|
||||
);
|
||||
|
||||
const initialCallback = result.current.loadHistoryForResume;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.loadHistoryForResume).toBe(initialCallback);
|
||||
});
|
||||
|
||||
it('should update callback when config changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ config }: { config: Config }) =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
config,
|
||||
}),
|
||||
{
|
||||
initialProps: { config: mockConfig as unknown as Config },
|
||||
},
|
||||
);
|
||||
|
||||
const initialCallback = result.current.loadHistoryForResume;
|
||||
|
||||
const newMockConfig = {
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
};
|
||||
|
||||
rerender({ config: newMockConfig as unknown as Config });
|
||||
|
||||
expect(result.current.loadHistoryForResume).not.toBe(initialCallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic resume on mount', () => {
|
||||
it('should not resume when resumedSessionData is not provided', () => {
|
||||
renderHook(() => useSessionResume(getDefaultProps()));
|
||||
|
||||
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not resume when user is authenticating', () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Test message',
|
||||
type: 'user',
|
||||
},
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
isAuthenticating: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not resume when Gemini client is not initialized', () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Test message',
|
||||
type: 'user',
|
||||
},
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
isGeminiClientInitialized: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockHistoryManager.clearItems).not.toHaveBeenCalled();
|
||||
expect(mockHistoryManager.addItem).not.toHaveBeenCalled();
|
||||
expect(mockGeminiClient.resumeChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should automatically resume session when resumedSessionData is provided', async () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Hello from resumed session',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
timestamp: '2025-01-01T00:02:00Z',
|
||||
content: 'Welcome back!',
|
||||
type: 'gemini',
|
||||
},
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{ type: 'user', text: 'Hello from resumed session' },
|
||||
0,
|
||||
);
|
||||
expect(mockHistoryManager.addItem).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ type: 'gemini', text: 'Welcome back!' },
|
||||
1,
|
||||
);
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only resume once even if props change', async () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-123',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: 'Test message',
|
||||
type: 'user',
|
||||
},
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ refreshStatic }: { refreshStatic: () => void }) =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
refreshStatic,
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
{
|
||||
initialProps: { refreshStatic: mockRefreshStatic },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const clearItemsCallCount = (
|
||||
mockHistoryManager.clearItems as ReturnType<typeof vi.fn>
|
||||
).mock.calls.length;
|
||||
|
||||
// Rerender with different refreshStatic
|
||||
const newRefreshStatic = vi.fn();
|
||||
rerender({ refreshStatic: newRefreshStatic });
|
||||
|
||||
// Should not resume again
|
||||
expect(mockHistoryManager.clearItems).toHaveBeenCalledTimes(
|
||||
clearItemsCallCount,
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert session messages correctly during auto-resume', async () => {
|
||||
const conversation: ConversationRecord = {
|
||||
sessionId: 'auto-resume-with-tools',
|
||||
projectHash: 'project-123',
|
||||
startTime: '2025-01-01T00:00:00Z',
|
||||
lastUpdated: '2025-01-01T01:00:00Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
timestamp: '2025-01-01T00:01:00Z',
|
||||
content: '/help',
|
||||
type: 'user',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
timestamp: '2025-01-01T00:02:00Z',
|
||||
content: 'Regular message',
|
||||
type: 'user',
|
||||
},
|
||||
] as MessageRecord[],
|
||||
};
|
||||
|
||||
renderHook(() =>
|
||||
useSessionResume({
|
||||
...getDefaultProps(),
|
||||
resumedSessionData: {
|
||||
conversation,
|
||||
filePath: '/path/to/session.json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGeminiClient.resumeChat).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the client history was called with filtered messages
|
||||
// (slash commands should be filtered out)
|
||||
const clientHistory = mockGeminiClient.resumeChat.mock.calls[0][0];
|
||||
|
||||
// Should only have the non-slash-command message
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
});
|
||||
|
||||
// But UI history should have both
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { Config, ResumedSessionData } from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
|
||||
|
||||
interface UseSessionResumeParams {
|
||||
config: Config;
|
||||
historyManager: UseHistoryManagerReturn;
|
||||
refreshStatic: () => void;
|
||||
isGeminiClientInitialized: boolean;
|
||||
setQuittingMessages: (messages: null) => void;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
isAuthenticating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle session resumption logic.
|
||||
* Provides a callback to load history for resume and automatically
|
||||
* handles command-line resume on mount.
|
||||
*/
|
||||
export function useSessionResume({
|
||||
config,
|
||||
historyManager,
|
||||
refreshStatic,
|
||||
isGeminiClientInitialized,
|
||||
setQuittingMessages,
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
}: UseSessionResumeParams) {
|
||||
// Use refs to avoid dependency chain that causes infinite loop
|
||||
const historyManagerRef = useRef(historyManager);
|
||||
const refreshStaticRef = useRef(refreshStatic);
|
||||
|
||||
useEffect(() => {
|
||||
historyManagerRef.current = historyManager;
|
||||
refreshStaticRef.current = refreshStatic;
|
||||
});
|
||||
|
||||
const loadHistoryForResume = useCallback(
|
||||
(
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedData: ResumedSessionData,
|
||||
) => {
|
||||
// Wait for the client.
|
||||
if (!isGeminiClientInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Now that we have the client, load the history into the UI and the client.
|
||||
setQuittingMessages(null);
|
||||
historyManagerRef.current.clearItems();
|
||||
uiHistory.forEach((item, index) => {
|
||||
historyManagerRef.current.addItem(item, index);
|
||||
});
|
||||
refreshStaticRef.current(); // Force Static component to re-render with the updated history.
|
||||
|
||||
// Give the history to the Gemini client.
|
||||
config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
|
||||
},
|
||||
[config, isGeminiClientInitialized, setQuittingMessages],
|
||||
);
|
||||
|
||||
// Handle interactive resume from the command line (-r/--resume without -p/--prompt-interactive).
|
||||
// Only if we're not authenticating and the client is initialized, though.
|
||||
const hasLoadedResumedSession = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
resumedSessionData &&
|
||||
!isAuthenticating &&
|
||||
isGeminiClientInitialized &&
|
||||
!hasLoadedResumedSession.current
|
||||
) {
|
||||
hasLoadedResumedSession.current = true;
|
||||
const historyData = convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
);
|
||||
loadHistoryForResume(
|
||||
historyData.uiHistory,
|
||||
historyData.clientHistory,
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
resumedSessionData,
|
||||
isAuthenticating,
|
||||
isGeminiClientInitialized,
|
||||
loadHistoryForResume,
|
||||
]);
|
||||
|
||||
return { loadHistoryForResume };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ describe('cleanup', () => {
|
||||
const cleanupModule = await import('./cleanup.js');
|
||||
register = cleanupModule.registerCleanup;
|
||||
runExit = cleanupModule.runExitCleanup;
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
it('should run a registered synchronous function', async () => {
|
||||
const cleanupFn = vi.fn();
|
||||
|
||||
@@ -44,27 +44,43 @@ function createTestSessions(): SessionInfo[] {
|
||||
return [
|
||||
{
|
||||
id: 'current123',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current session',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'recent456',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`,
|
||||
startTime: oneWeekAgo.toISOString(),
|
||||
lastUpdated: oneWeekAgo.toISOString(),
|
||||
firstUserMessage: 'Recent session',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'old789abc',
|
||||
file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`,
|
||||
startTime: twoWeeksAgo.toISOString(),
|
||||
lastUpdated: twoWeeksAgo.toISOString(),
|
||||
firstUserMessage: 'Old session',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'ancient12',
|
||||
file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`,
|
||||
fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`,
|
||||
startTime: oneMonthAgo.toISOString(),
|
||||
lastUpdated: oneMonthAgo.toISOString(),
|
||||
firstUserMessage: 'Ancient session',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -409,27 +425,43 @@ describe('Session Cleanup', () => {
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session5d',
|
||||
file: `${SESSION_FILE_PREFIX}5d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
startTime: fiveDaysAgo.toISOString(),
|
||||
lastUpdated: fiveDaysAgo.toISOString(),
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session8d',
|
||||
file: `${SESSION_FILE_PREFIX}8d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}8d.json`,
|
||||
startTime: eightDaysAgo.toISOString(),
|
||||
lastUpdated: eightDaysAgo.toISOString(),
|
||||
firstUserMessage: '8 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session15d',
|
||||
file: `${SESSION_FILE_PREFIX}15d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}15d.json`,
|
||||
startTime: fifteenDaysAgo.toISOString(),
|
||||
lastUpdated: fifteenDaysAgo.toISOString(),
|
||||
firstUserMessage: '15 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -507,27 +539,43 @@ describe('Session Cleanup', () => {
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session1d',
|
||||
file: `${SESSION_FILE_PREFIX}1d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}1d.json`,
|
||||
startTime: oneDayAgo.toISOString(),
|
||||
lastUpdated: oneDayAgo.toISOString(),
|
||||
firstUserMessage: '1 day',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session7d',
|
||||
file: `${SESSION_FILE_PREFIX}7d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
startTime: sevenDaysAgo.toISOString(),
|
||||
lastUpdated: sevenDaysAgo.toISOString(),
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session13d',
|
||||
file: `${SESSION_FILE_PREFIX}13d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}13d.json`,
|
||||
startTime: thirteenDaysAgo.toISOString(),
|
||||
lastUpdated: thirteenDaysAgo.toISOString(),
|
||||
firstUserMessage: '13 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -579,9 +627,13 @@ describe('Session Cleanup', () => {
|
||||
const sessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -590,9 +642,13 @@ describe('Session Cleanup', () => {
|
||||
const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
sessions.push({
|
||||
id: `session${i}`,
|
||||
file: `${SESSION_FILE_PREFIX}${i}d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}${i}d.json`,
|
||||
startTime: daysAgo.toISOString(),
|
||||
lastUpdated: daysAgo.toISOString(),
|
||||
firstUserMessage: `${i} days`,
|
||||
isCurrentSession: false,
|
||||
index: i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -693,33 +749,53 @@ describe('Session Cleanup', () => {
|
||||
const testSessions: SessionInfo[] = [
|
||||
{
|
||||
id: 'current',
|
||||
file: `${SESSION_FILE_PREFIX}current`,
|
||||
fileName: `${SESSION_FILE_PREFIX}current.json`,
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
firstUserMessage: 'Current',
|
||||
isCurrentSession: true,
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: 'session3d',
|
||||
file: `${SESSION_FILE_PREFIX}3d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}3d.json`,
|
||||
startTime: threeDaysAgo.toISOString(),
|
||||
lastUpdated: threeDaysAgo.toISOString(),
|
||||
firstUserMessage: '3 days',
|
||||
isCurrentSession: false,
|
||||
index: 2,
|
||||
},
|
||||
{
|
||||
id: 'session5d',
|
||||
file: `${SESSION_FILE_PREFIX}5d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}5d.json`,
|
||||
startTime: fiveDaysAgo.toISOString(),
|
||||
lastUpdated: fiveDaysAgo.toISOString(),
|
||||
firstUserMessage: '5 days',
|
||||
isCurrentSession: false,
|
||||
index: 3,
|
||||
},
|
||||
{
|
||||
id: 'session7d',
|
||||
file: `${SESSION_FILE_PREFIX}7d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}7d.json`,
|
||||
startTime: sevenDaysAgo.toISOString(),
|
||||
lastUpdated: sevenDaysAgo.toISOString(),
|
||||
firstUserMessage: '7 days',
|
||||
isCurrentSession: false,
|
||||
index: 4,
|
||||
},
|
||||
{
|
||||
id: 'session12d',
|
||||
file: `${SESSION_FILE_PREFIX}12d`,
|
||||
fileName: `${SESSION_FILE_PREFIX}12d.json`,
|
||||
startTime: twelveDaysAgo.toISOString(),
|
||||
lastUpdated: twelveDaysAgo.toISOString(),
|
||||
firstUserMessage: '12 days',
|
||||
isCurrentSession: false,
|
||||
index: 5,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SessionSelector,
|
||||
extractFirstUserMessage,
|
||||
formatRelativeTime,
|
||||
} from './sessionUtils.js';
|
||||
import type { Config, MessageRecord } from '@google/gemini-cli-core';
|
||||
import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
describe('SessionSelector', () => {
|
||||
let tmpDir: string;
|
||||
let config: Config;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for testing
|
||||
tmpDir = path.join(process.cwd(), '.tmp-test-sessions');
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
// Mock config
|
||||
config = {
|
||||
storage: {
|
||||
getProjectTempDir: () => tmpDir,
|
||||
},
|
||||
getSessionId: () => 'current-session-id',
|
||||
} as Partial<Config> as Config;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test files
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch (_error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should resolve session by UUID', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message 1',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const session2 = {
|
||||
sessionId: sessionId2,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T11:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message 2',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session2, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving by UUID
|
||||
const result1 = await sessionSelector.resolveSession(sessionId1);
|
||||
expect(result1.sessionData.sessionId).toBe(sessionId1);
|
||||
expect(result1.sessionData.messages[0].content).toBe('Test message 1');
|
||||
|
||||
const result2 = await sessionSelector.resolveSession(sessionId2);
|
||||
expect(result2.sessionData.sessionId).toBe(sessionId2);
|
||||
expect(result2.sessionData.messages[0].content).toBe('Test message 2');
|
||||
});
|
||||
|
||||
it('should resolve session by index', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'First session',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const session2 = {
|
||||
sessionId: sessionId2,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T11:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Second session',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session2, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving by index (1-based)
|
||||
const result1 = await sessionSelector.resolveSession('1');
|
||||
expect(result1.sessionData.messages[0].content).toBe('First session');
|
||||
|
||||
const result2 = await sessionSelector.resolveSession('2');
|
||||
expect(result2.sessionData.messages[0].content).toBe('Second session');
|
||||
});
|
||||
|
||||
it('should resolve latest session', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'First session',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const session2 = {
|
||||
sessionId: sessionId2,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T11:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T11:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Latest session',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T11-00-${sessionId2.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session2, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving latest
|
||||
const result = await sessionSelector.resolveSession('latest');
|
||||
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
||||
});
|
||||
|
||||
it('should throw error for invalid session identifier', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session1 = {
|
||||
sessionId: sessionId1,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message 1',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session1, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
await expect(
|
||||
sessionSelector.resolveSession('invalid-uuid'),
|
||||
).rejects.toThrow('Invalid session identifier "invalid-uuid"');
|
||||
|
||||
await expect(sessionSelector.resolveSession('999')).rejects.toThrow(
|
||||
'Invalid session identifier "999"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFirstUserMessage', () => {
|
||||
it('should extract first non-resume user message', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
content: '/resume',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Hello world',
|
||||
id: 'msg2',
|
||||
timestamp: '2024-01-01T10:01:00.000Z',
|
||||
},
|
||||
] as MessageRecord[];
|
||||
|
||||
expect(extractFirstUserMessage(messages)).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should truncate long messages', () => {
|
||||
const longMessage = 'a'.repeat(150);
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
content: longMessage,
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
] as MessageRecord[];
|
||||
|
||||
const result = extractFirstUserMessage(messages);
|
||||
expect(result).toBe('a'.repeat(97) + '...');
|
||||
expect(result.length).toBe(100);
|
||||
});
|
||||
|
||||
it('should return "Empty conversation" for no user messages', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'gemini',
|
||||
content: 'Hello',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
] as MessageRecord[];
|
||||
|
||||
expect(extractFirstUserMessage(messages)).toBe('Empty conversation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('should format time correctly', () => {
|
||||
const now = new Date();
|
||||
|
||||
// 5 minutes ago
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
expect(formatRelativeTime(fiveMinutesAgo.toISOString())).toBe(
|
||||
'5 minutes ago',
|
||||
);
|
||||
|
||||
// 1 minute ago
|
||||
const oneMinuteAgo = new Date(now.getTime() - 1 * 60 * 1000);
|
||||
expect(formatRelativeTime(oneMinuteAgo.toISOString())).toBe('1 minute ago');
|
||||
|
||||
// 2 hours ago
|
||||
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(twoHoursAgo.toISOString())).toBe('2 hours ago');
|
||||
|
||||
// 1 hour ago
|
||||
const oneHourAgo = new Date(now.getTime() - 1 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(oneHourAgo.toISOString())).toBe('1 hour ago');
|
||||
|
||||
// 3 days ago
|
||||
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(threeDaysAgo.toISOString())).toBe('3 days ago');
|
||||
|
||||
// 1 day ago
|
||||
const oneDayAgo = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000);
|
||||
expect(formatRelativeTime(oneDayAgo.toISOString())).toBe('1 day ago');
|
||||
|
||||
// Just now (within 60 seconds)
|
||||
const thirtySecondsAgo = new Date(now.getTime() - 30 * 1000);
|
||||
expect(formatRelativeTime(thirtySecondsAgo.toISOString())).toBe('Just now');
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
SESSION_FILE_PREFIX,
|
||||
type ConversationRecord,
|
||||
partListUnionToString,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -17,12 +22,20 @@ import path from 'node:path';
|
||||
export interface SessionInfo {
|
||||
/** Unique session identifier (filename without .json) */
|
||||
id: string;
|
||||
/** Filename without extension */
|
||||
file: string;
|
||||
/** Full filename including .json extension */
|
||||
fileName: string;
|
||||
/** ISO timestamp when session started */
|
||||
startTime: string;
|
||||
/** ISO timestamp when session was last updated */
|
||||
lastUpdated: string;
|
||||
/** Cleaned first user message content */
|
||||
firstUserMessage: string;
|
||||
/** Whether this is the currently active session */
|
||||
isCurrentSession: boolean;
|
||||
/** Display index in the list */
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,6 +48,55 @@ export interface SessionFileEntry {
|
||||
sessionInfo: SessionInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving a session selection argument.
|
||||
*/
|
||||
export interface SessionSelectionResult {
|
||||
sessionPath: string;
|
||||
sessionData: ConversationRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first meaningful user message from conversation messages.
|
||||
*/
|
||||
export const extractFirstUserMessage = (messages: MessageRecord[]): string => {
|
||||
const userMessage = messages.find((msg) => {
|
||||
const content = partListUnionToString(msg.content);
|
||||
return msg.type === 'user' && content?.trim() && content !== '/resume';
|
||||
});
|
||||
|
||||
if (!userMessage) {
|
||||
return 'Empty conversation';
|
||||
}
|
||||
|
||||
// Truncate long messages for display
|
||||
const content = partListUnionToString(userMessage.content).trim();
|
||||
return content.length > 100 ? content.slice(0, 97) + '...' : content;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a timestamp as relative time (e.g., "2 hours ago", "3 days ago").
|
||||
*/
|
||||
export const formatRelativeTime = (timestamp: string): string => {
|
||||
const now = new Date();
|
||||
const time = new Date(timestamp);
|
||||
const diffMs = now.getTime() - time.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
} else if (diffMinutes > 0) {
|
||||
return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads all session files (including corrupted ones) from the chats directory.
|
||||
* @returns Array of session file entries, with sessionInfo null for corrupted files
|
||||
@@ -69,15 +131,20 @@ export const getAllSessionFiles = async (
|
||||
return { fileName: file, sessionInfo: null };
|
||||
}
|
||||
|
||||
const firstUserMessage = extractFirstUserMessage(content.messages);
|
||||
const isCurrentSession = currentSessionId
|
||||
? file.includes(currentSessionId.slice(0, 8))
|
||||
: false;
|
||||
|
||||
const sessionInfo: SessionInfo = {
|
||||
id: content.sessionId,
|
||||
file: file.replace('.json', ''),
|
||||
fileName: file,
|
||||
startTime: content.startTime,
|
||||
lastUpdated: content.lastUpdated,
|
||||
firstUserMessage,
|
||||
isCurrentSession,
|
||||
index: 0, // Will be set after sorting valid sessions
|
||||
};
|
||||
|
||||
return { fileName: file, sessionInfo };
|
||||
@@ -87,6 +154,7 @@ export const getAllSessionFiles = async (
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return await Promise.all(sessionPromises);
|
||||
} catch (error) {
|
||||
// It's expected that the directory might not exist, which is not an error.
|
||||
@@ -116,5 +184,142 @@ export const getSessionFiles = async (
|
||||
)
|
||||
.map((entry) => entry.sessionInfo);
|
||||
|
||||
// Sort by startTime (oldest first) for stable session numbering
|
||||
validSessions.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
// Set the correct 1-based indexes after sorting
|
||||
validSessions.forEach((session, index) => {
|
||||
session.index = index + 1;
|
||||
});
|
||||
|
||||
return validSessions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility class for session discovery and selection.
|
||||
*/
|
||||
export class SessionSelector {
|
||||
constructor(private config: Config) {}
|
||||
|
||||
/**
|
||||
* Lists all available sessions for the current project.
|
||||
*/
|
||||
async listSessions(): Promise<SessionInfo[]> {
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
return getSessionFiles(chatsDir, this.config.getSessionId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a session by identifier (UUID or numeric index).
|
||||
*
|
||||
* @param identifier - Can be a full UUID or an index number (1-based)
|
||||
* @returns Promise resolving to the found SessionInfo
|
||||
* @throws Error if the session is not found or identifier is invalid
|
||||
*/
|
||||
async findSession(identifier: string): Promise<SessionInfo> {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No previous sessions found for this project.');
|
||||
}
|
||||
|
||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
||||
const sortedSessions = sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
// Try to find by UUID first
|
||||
const sessionByUuid = sortedSessions.find(
|
||||
(session) => session.id === identifier,
|
||||
);
|
||||
if (sessionByUuid) {
|
||||
return sessionByUuid;
|
||||
}
|
||||
|
||||
// Parse as index number (1-based) - only allow numeric indexes
|
||||
const index = parseInt(identifier, 10);
|
||||
if (
|
||||
!isNaN(index) &&
|
||||
index.toString() === identifier &&
|
||||
index > 0 &&
|
||||
index <= sortedSessions.length
|
||||
) {
|
||||
return sortedSessions[index - 1];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid session identifier "${identifier}". Use --list-sessions to see available sessions.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a resume argument to a specific session.
|
||||
*
|
||||
* @param resumeArg - Can be "latest", a full UUID, or an index number (1-based)
|
||||
* @returns Promise resolving to session selection result
|
||||
*/
|
||||
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
||||
let selectedSession: SessionInfo;
|
||||
|
||||
if (resumeArg === 'latest') {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
throw new Error('No previous sessions found for this project.');
|
||||
}
|
||||
|
||||
// Sort by startTime (oldest first, so newest sessions get highest numbers)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
selectedSession = sessions[sessions.length - 1];
|
||||
} else {
|
||||
try {
|
||||
selectedSession = await this.findSession(resumeArg);
|
||||
} catch (error) {
|
||||
// Re-throw with more detailed message for resume command
|
||||
throw new Error(
|
||||
`Invalid session identifier "${resumeArg}". Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest. Error: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.selectSession(selectedSession);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads session data for a selected session.
|
||||
*/
|
||||
private async selectSession(
|
||||
sessionInfo: SessionInfo,
|
||||
): Promise<SessionSelectionResult> {
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
const sessionPath = path.join(chatsDir, sessionInfo.fileName);
|
||||
|
||||
try {
|
||||
const sessionData: ConversationRecord = JSON.parse(
|
||||
await fs.readFile(sessionPath, 'utf8'),
|
||||
);
|
||||
|
||||
return {
|
||||
sessionPath,
|
||||
sessionData,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to load session ${sessionInfo.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { ChatRecordingService, type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
SessionSelector,
|
||||
type SessionInfo,
|
||||
} from './sessionUtils.js';
|
||||
|
||||
export async function listSessions(config: Config): Promise<void> {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log('No previous sessions found for this project.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nAvailable sessions for this project (${sessions.length}):\n`);
|
||||
|
||||
sessions
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
)
|
||||
.forEach((session, index) => {
|
||||
const current = session.isCurrentSession ? ', current' : '';
|
||||
const time = formatRelativeTime(session.lastUpdated);
|
||||
console.log(
|
||||
` ${index + 1}. ${session.firstUserMessage} (${time}${current}) [${session.id}]`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
config: Config,
|
||||
sessionIndex: string,
|
||||
): Promise<void> {
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
const sessions = await sessionSelector.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.error('No sessions found for this project.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort sessions by start time to match list-sessions ordering
|
||||
const sortedSessions = sessions.sort(
|
||||
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
||||
);
|
||||
|
||||
let sessionToDelete: SessionInfo;
|
||||
|
||||
// Try to find by UUID first
|
||||
const sessionByUuid = sortedSessions.find(
|
||||
(session) => session.id === sessionIndex,
|
||||
);
|
||||
if (sessionByUuid) {
|
||||
sessionToDelete = sessionByUuid;
|
||||
} else {
|
||||
// Parse session index
|
||||
const index = parseInt(sessionIndex, 10);
|
||||
if (isNaN(index) || index < 1 || index > sessions.length) {
|
||||
console.error(
|
||||
`Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
sessionToDelete = sortedSessions[index - 1];
|
||||
}
|
||||
|
||||
// Prevent deleting the current session
|
||||
if (sessionToDelete.isCurrentSession) {
|
||||
console.error('Cannot delete the current active session.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use ChatRecordingService to delete the session
|
||||
const chatRecordingService = new ChatRecordingService(config);
|
||||
chatRecordingService.deleteSession(sessionToDelete.file);
|
||||
|
||||
const time = formatRelativeTime(sessionToDelete.lastUpdated);
|
||||
console.log(
|
||||
`Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to delete session: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export interface ConfigParameters {
|
||||
model: string;
|
||||
maxSessionTurns?: number;
|
||||
experimentalZedIntegration?: boolean;
|
||||
listSessions?: boolean;
|
||||
deleteSession?: string;
|
||||
listExtensions?: boolean;
|
||||
extensionLoader?: ExtensionLoader;
|
||||
enabledExtensions?: string[];
|
||||
@@ -311,7 +313,7 @@ export class Config {
|
||||
private blockedMcpServers: string[];
|
||||
private promptRegistry!: PromptRegistry;
|
||||
private agentRegistry!: AgentRegistry;
|
||||
private readonly sessionId: string;
|
||||
private sessionId: string;
|
||||
private fileSystemService: FileSystemService;
|
||||
private contentGeneratorConfig!: ContentGeneratorConfig;
|
||||
private contentGenerator!: ContentGenerator;
|
||||
@@ -360,6 +362,8 @@ export class Config {
|
||||
|
||||
private inFallbackMode = false;
|
||||
private readonly maxSessionTurns: number;
|
||||
private readonly listSessions: boolean;
|
||||
private readonly deleteSession: string | undefined;
|
||||
private readonly listExtensions: boolean;
|
||||
private readonly _extensionLoader: ExtensionLoader;
|
||||
private readonly _enabledExtensions: string[];
|
||||
@@ -473,6 +477,8 @@ export class Config {
|
||||
this.maxSessionTurns = params.maxSessionTurns ?? -1;
|
||||
this.experimentalZedIntegration =
|
||||
params.experimentalZedIntegration ?? false;
|
||||
this.listSessions = params.listSessions ?? false;
|
||||
this.deleteSession = params.deleteSession;
|
||||
this.listExtensions = params.listExtensions ?? false;
|
||||
this._extensionLoader =
|
||||
params.extensionLoader ?? new SimpleExtensionLoader([]);
|
||||
@@ -709,6 +715,10 @@ export class Config {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
shouldLoadMemoryFromIncludeDirectories(): boolean {
|
||||
return this.loadMemoryFromIncludeDirectories;
|
||||
}
|
||||
@@ -1033,6 +1043,14 @@ export class Config {
|
||||
return this.listExtensions;
|
||||
}
|
||||
|
||||
getListSessions(): boolean {
|
||||
return this.listSessions;
|
||||
}
|
||||
|
||||
getDeleteSession(): string | undefined {
|
||||
return this.deleteSession;
|
||||
}
|
||||
|
||||
getExtensionManagement(): boolean {
|
||||
return this.extensionManagement;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ import { GeminiChat } from './geminiChat.js';
|
||||
import { retryWithBackoff } from '../utils/retry.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
import type { ChatRecordingService } from '../services/chatRecordingService.js';
|
||||
import type {
|
||||
ChatRecordingService,
|
||||
ResumedSessionData,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
@@ -152,6 +155,13 @@ export class GeminiClient {
|
||||
this.updateTelemetryTokenCount();
|
||||
}
|
||||
|
||||
async resumeChat(
|
||||
history: Content[],
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
): Promise<void> {
|
||||
this.chat = await this.startChat(history, resumedSessionData);
|
||||
}
|
||||
|
||||
getChatRecordingService(): ChatRecordingService | undefined {
|
||||
return this.chat?.getChatRecordingService();
|
||||
}
|
||||
@@ -175,7 +185,10 @@ export class GeminiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
|
||||
async startChat(
|
||||
extraHistory?: Content[],
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
): Promise<GeminiChat> {
|
||||
this.forceFullIdeContext = true;
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
|
||||
@@ -207,6 +220,7 @@ export class GeminiClient {
|
||||
tools,
|
||||
},
|
||||
history,
|
||||
resumedSessionData,
|
||||
);
|
||||
} catch (error) {
|
||||
await reportError(
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
logContentRetry,
|
||||
logContentRetryFailure,
|
||||
} from '../telemetry/loggers.js';
|
||||
import { ChatRecordingService } from '../services/chatRecordingService.js';
|
||||
import {
|
||||
ChatRecordingService,
|
||||
type ResumedSessionData,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import {
|
||||
ContentRetryEvent,
|
||||
ContentRetryFailureEvent,
|
||||
@@ -191,10 +194,11 @@ export class GeminiChat {
|
||||
private readonly config: Config,
|
||||
private readonly generationConfig: GenerateContentConfig = {},
|
||||
private history: Content[] = [],
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
) {
|
||||
validateHistory(history);
|
||||
this.chatRecordingService = new ChatRecordingService(config);
|
||||
this.chatRecordingService.initialize();
|
||||
this.chatRecordingService.initialize(resumedSessionData);
|
||||
this.lastPromptTokenCount = Math.ceil(
|
||||
JSON.stringify(this.history).length / 4,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user