perf: skip pre-compression history on session resume

On resume (-r), the CLI was loading and replaying the entire session
recording, including messages that had already been compressed away.
For long-running Forever Mode sessions this made resume extremely slow.

Add lastCompressionIndex to ConversationRecord, stamped when
compression succeeds. On resume, only messages from that index
onward are loaded into the client history and UI. Fully backward
compatible — old sessions without the field load all messages as before.
This commit is contained in:
Sandy Tao
2026-03-05 16:44:25 -08:00
parent 79ea865790
commit e062f0d09a
15 changed files with 303 additions and 59 deletions

View File

@@ -358,7 +358,10 @@ export class GeminiAgent {
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const clientHistory = convertSessionToClientHistory(
sessionData.messages,
sessionData.lastCompressionIndex,
);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();

View File

@@ -222,6 +222,7 @@ export async function runNonInteractive({
await geminiClient.resumeChat(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
resumedSessionData.conversation.lastCompressionIndex,
),
resumedSessionData,
);

View File

@@ -76,12 +76,17 @@ export const useSessionBrowser = (
// We've loaded it; tell the UI about it.
setIsSessionBrowserOpen(false);
const compressionIndex = conversation.lastCompressionIndex;
const historyData = convertSessionToHistoryFormats(
conversation.messages,
compressionIndex,
);
await onLoadHistory(
historyData.uiHistory,
convertSessionToClientHistory(conversation.messages),
convertSessionToClientHistory(
conversation.messages,
compressionIndex,
),
resumedSessionData,
);
} catch (error) {

View File

@@ -109,12 +109,18 @@ export function useSessionResume({
!hasLoadedResumedSession.current
) {
hasLoadedResumedSession.current = true;
const compressionIndex =
resumedSessionData.conversation.lastCompressionIndex;
const historyData = convertSessionToHistoryFormats(
resumedSessionData.conversation.messages,
compressionIndex,
);
void loadHistoryForResume(
historyData.uiHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
compressionIndex,
),
resumedSessionData,
);
}

View File

@@ -10,10 +10,16 @@ import {
extractFirstUserMessage,
formatRelativeTime,
hasUserOrAssistantMessage,
convertSessionToHistoryFormats,
SessionError,
} from './sessionUtils.js';
import type { Config, MessageRecord } from '@google/gemini-cli-core';
import type {
Config,
MessageRecord,
ConversationRecord,
} from '@google/gemini-cli-core';
import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core';
import { MessageType } from '../ui/types.js';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
@@ -765,3 +771,80 @@ describe('formatRelativeTime', () => {
expect(formatRelativeTime(thirtySecondsAgo.toISOString())).toBe('Just now');
});
});
describe('convertSessionToHistoryFormats', () => {
const messages: ConversationRecord['messages'] = [
{
id: '1',
type: 'user',
timestamp: '2024-01-01T10:00:00Z',
content: 'First question',
},
{
id: '2',
type: 'gemini',
timestamp: '2024-01-01T10:01:00Z',
content: 'First answer',
},
{
id: '3',
type: 'user',
timestamp: '2024-01-01T10:02:00Z',
content: 'Second question',
},
{
id: '4',
type: 'gemini',
timestamp: '2024-01-01T10:03:00Z',
content: 'Second answer',
},
];
it('should convert all messages when startIndex is undefined', () => {
const { uiHistory } = convertSessionToHistoryFormats(messages);
expect(uiHistory).toHaveLength(4);
expect(uiHistory[0]).toEqual({
type: MessageType.USER,
text: 'First question',
});
expect(uiHistory[1]).toEqual({
type: MessageType.GEMINI,
text: 'First answer',
});
expect(uiHistory[2]).toEqual({
type: MessageType.USER,
text: 'Second question',
});
expect(uiHistory[3]).toEqual({
type: MessageType.GEMINI,
text: 'Second answer',
});
});
it('should show only post-compression messages with a leading info message when startIndex is provided', () => {
const { uiHistory } = convertSessionToHistoryFormats(messages, 2);
// Should have: 1 info message + 2 remaining messages
expect(uiHistory).toHaveLength(3);
// First item is the compression info message
expect(uiHistory[0].type).toBe(MessageType.INFO);
expect((uiHistory[0] as { type: string; text: string }).text).toContain(
'2 messages',
);
expect((uiHistory[0] as { type: string; text: string }).text).toContain(
'compressed',
);
// Remaining items are the post-compression messages
expect(uiHistory[1]).toEqual({
type: MessageType.USER,
text: 'Second question',
});
expect(uiHistory[2]).toEqual({
type: MessageType.GEMINI,
text: 'Second answer',
});
});
});

View File

@@ -526,15 +526,31 @@ export class SessionSelector {
/**
* Converts session/conversation data into UI history format.
*
* @param messages - The full array of recorded messages.
* @param startIndex - If provided, only messages from this index onward are
* converted. A leading info item is added to indicate earlier history was
* compressed away.
*/
export function convertSessionToHistoryFormats(
messages: ConversationRecord['messages'],
startIndex?: number,
): {
uiHistory: HistoryItemWithoutId[];
} {
const uiHistory: HistoryItemWithoutId[] = [];
const hasCompressedHistory =
startIndex != null && startIndex > 0 && startIndex < messages.length;
const slice = hasCompressedHistory ? messages.slice(startIndex) : messages;
for (const msg of messages) {
if (hasCompressedHistory) {
uiHistory.push({
type: MessageType.INFO,
text: ` Earlier history (${startIndex} messages) was compressed. Showing conversation from last compression point.`,
});
}
for (const msg of slice) {
// Add thoughts if present
if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) {
for (const thought of msg.thoughts) {