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:
bl-ue
2025-11-10 18:31:00 -07:00
committed by GitHub
parent 51f952e700
commit 6893d27441
21 changed files with 2578 additions and 11 deletions

View File

@@ -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

View File

@@ -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) => {

View File

@@ -278,7 +278,7 @@ describe('handleAtCommand', () => {
}),
125,
);
});
}, 10000);
it('should handle multiple @file references', async () => {
const content1 = 'Content file1';

View File

@@ -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',
},
},
],
});
});
});
});

View File

@@ -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,
};
}

View File

@@ -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);
});
});
});

View File

@@ -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 };
}