From e062f0d09a60ddc8447c7944cbf2e30f48960ab1 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 5 Mar 2026 16:44:25 -0800 Subject: [PATCH] perf: skip pre-compression history on session resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/cli/src/acp/acpClient.ts | 5 +- packages/cli/src/nonInteractiveCli.ts | 1 + .../cli/src/ui/hooks/useSessionBrowser.ts | 7 +- packages/cli/src/ui/hooks/useSessionResume.ts | 8 +- packages/cli/src/utils/sessionUtils.test.ts | 85 ++++++++++++++++++- packages/cli/src/utils/sessionUtils.ts | 18 +++- .../core/__snapshots__/prompts.test.ts.snap | 16 ++++ packages/core/src/core/client.test.ts | 4 + packages/core/src/core/client.ts | 5 ++ packages/core/src/prompts/promptProvider.ts | 43 +--------- packages/core/src/prompts/snippets.ts | 13 +-- .../src/services/chatRecordingService.test.ts | 54 ++++++++++++ .../core/src/services/chatRecordingService.ts | 26 ++++++ packages/core/src/utils/sessionUtils.test.ts | 66 ++++++++++++++ packages/core/src/utils/sessionUtils.ts | 11 ++- 15 files changed, 303 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 2a8a524ff8..2e3a85b498 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -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(); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index c2cab72353..8454a6b541 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -222,6 +222,7 @@ export async function runNonInteractive({ await geminiClient.resumeChat( convertSessionToClientHistory( resumedSessionData.conversation.messages, + resumedSessionData.conversation.lastCompressionIndex, ), resumedSessionData, ); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 9c6d05b322..0e6bf25f3f 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -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) { diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 055686773b..affa2bcf7f 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -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, ); } diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index bcf7c19dfe..92a1ba0ecd 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -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', + }); + }); +}); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 3aa0131ac2..5d93e2568e 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -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) { diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 07ba92bafd..d3cd847f3c 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -408,6 +408,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -1143,6 +1145,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -1294,6 +1298,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -1464,6 +1470,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -1608,6 +1616,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -3171,6 +3181,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -3323,6 +3335,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). @@ -3795,6 +3809,8 @@ Operate as a **strategic orchestrator**. Your own context window is your most pr When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. +**Concurrency Safety and Mandate:** You should NEVER run multiple subagents in a single turn if their abilities mutate the same files or resources. This is to prevent race conditions and ensure that the workspace is in a consistent state. Only run multiple subagents in parallel when their tasks are independent (e.g., multiple concurrent research or read-only tasks) or if parallel execution is explicitly requested by the user. + **High-Impact Delegation Candidates:** - **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). - **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b9e8b11b8a..d34009392c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -415,6 +415,7 @@ describe('Gemini Client (client.ts)', () => { getChatRecordingService: vi.fn().mockReturnValue({ getConversation: vi.fn().mockReturnValue(null), getConversationFilePath: vi.fn().mockReturnValue(null), + recordCompressionPoint: vi.fn(), }), }; client['chat'] = mockOriginalChat as GeminiChat; @@ -649,6 +650,7 @@ describe('Gemini Client (client.ts)', () => { const mockRecordingService = { getConversation: vi.fn().mockReturnValue(mockConversation), getConversationFilePath: vi.fn().mockReturnValue(mockFilePath), + recordCompressionPoint: vi.fn(), }; vi.mocked(mockOriginalChat.getChatRecordingService!).mockReturnValue( mockRecordingService as unknown as ChatRecordingService, @@ -1552,6 +1554,7 @@ ${JSON.stringify( getChatRecordingService: vi.fn().mockReturnValue({ getConversation: vi.fn(), getConversationFilePath: vi.fn(), + recordCompressionPoint: vi.fn(), }), } as unknown as GeminiChat; @@ -1565,6 +1568,7 @@ ${JSON.stringify( getChatRecordingService: vi.fn().mockReturnValue({ getConversation: vi.fn(), getConversationFilePath: vi.fn(), + recordCompressionPoint: vi.fn(), }), } as unknown as GeminiChat; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 123b80ff2c..834aa00b6d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1222,6 +1222,11 @@ Do not wait for a reflection cycle if the information is critical for future tur // capture current session data before resetting const currentRecordingService = this.getChat().getChatRecordingService(); + + // Mark this point in the recording so resume only loads + // messages from here onward (everything before was compressed). + currentRecordingService.recordCompressionPoint(); + const conversation = currentRecordingService.getConversation(); const filePath = currentRecordingService.getConversationFilePath(); diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index da165e3375..a39750ca09 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -178,6 +178,7 @@ export class PromptProvider { approvedPlan: approvedPlanPath ? { path: approvedPlanPath } : undefined, + taskTracker: config.isTrackerEnabled(), }), !isPlanMode, ), @@ -189,6 +190,7 @@ export class PromptProvider { planModeToolsList, plansDir: config.storage.getPlansDir(), approvedPlanPath: config.getApprovedPlanPath(), + taskTracker: config.isTrackerEnabled(), }), isPlanMode, ) @@ -200,48 +202,7 @@ export class PromptProvider { enableShellEfficiency: config.getEnableShellOutputEfficiency(), interactiveShellEnabled: config.isInteractiveShellEnabled(), })), - skills.length > 0, - ), - hookContext: isSectionEnabled('hookContext') || undefined, - primaryWorkflows: this.withSection( - 'primaryWorkflows', - () => ({ - interactive: interactiveMode, - enableCodebaseInvestigator: enabledToolNames.has( - CodebaseInvestigatorAgent.name, - ), - enableWriteTodosTool: enabledToolNames.has(WRITE_TODOS_TOOL_NAME), - enableEnterPlanModeTool: enabledToolNames.has( - ENTER_PLAN_MODE_TOOL_NAME, - ), - enableGrep: enabledToolNames.has(GREP_TOOL_NAME), - enableGlob: enabledToolNames.has(GLOB_TOOL_NAME), - approvedPlan: approvedPlanPath - ? { path: approvedPlanPath } - : undefined, - taskTracker: config.isTrackerEnabled(), - }), - !isPlanMode, - ), - planningWorkflow: this.withSection( - 'planningWorkflow', - () => ({ - planModeToolsList, - plansDir: config.storage.getPlansDir(), - approvedPlanPath: config.getApprovedPlanPath(), - taskTracker: config.isTrackerEnabled(), - }), - isPlanMode, - ), taskTracker: config.isTrackerEnabled(), - operationalGuidelines: this.withSection( - 'operationalGuidelines', - () => ({ - interactive: interactiveMode, - enableShellEfficiency: config.getEnableShellOutputEfficiency(), - interactiveShellEnabled: config.isInteractiveShellEnabled(), - }), - ), sandbox: this.withSection('sandbox', () => getSandboxMode()), interactiveYoloMode: this.withSection( 'interactiveYoloMode', diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 7219bd35f1..245700256e 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -138,6 +138,7 @@ export function getCoreSystemPrompt(options: SystemPromptOptions): string { options.planningWorkflow ? renderPlanningWorkflow(options.planningWorkflow) : renderPrimaryWorkflows(options.primaryWorkflows), + options.taskTracker ? renderTaskTracker() : '', renderOperationalGuidelines(options.operationalGuidelines), renderInteractiveYoloMode(options.interactiveYoloMode), renderSandbox(options.sandbox), @@ -151,18 +152,6 @@ export function getCoreSystemPrompt(options: SystemPromptOptions): string { .trim(); } -${options.taskTracker ? renderTaskTracker() : ''} - -${renderOperationalGuidelines(options.operationalGuidelines)} - -${renderInteractiveYoloMode(options.interactiveYoloMode)} - -${renderSandbox(options.sandbox)} - -${renderGitRepo(options.gitRepo)} -`.trim(); -} - /** * Wraps the base prompt with user memory and approval mode plans. */ diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 2b8e8f1977..3317dce432 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -532,6 +532,60 @@ describe('ChatRecordingService', () => { }); }); + describe('recordCompressionPoint', () => { + it('should set lastCompressionIndex to the current message count and update on subsequent calls', () => { + chatRecordingService.initialize(); + + // Record a few messages + chatRecordingService.recordMessage({ + type: 'user', + content: 'msg1', + model: 'm', + }); + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'response1', + model: 'm', + }); + chatRecordingService.recordMessage({ + type: 'user', + content: 'msg2', + model: 'm', + }); + + // Record compression point + chatRecordingService.recordCompressionPoint(); + + const sessionFile = chatRecordingService.getConversationFilePath()!; + let conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + + expect(conversation.lastCompressionIndex).toBe(3); + + // Record more messages + chatRecordingService.recordMessage({ + type: 'gemini', + content: 'response2', + model: 'm', + }); + chatRecordingService.recordMessage({ + type: 'user', + content: 'msg3', + model: 'm', + }); + + // Record compression point again + chatRecordingService.recordCompressionPoint(); + + conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + + expect(conversation.lastCompressionIndex).toBe(5); + }); + }); + describe('ENOSPC (disk full) graceful degradation - issue #16266', () => { it('should disable recording and not throw when ENOSPC occurs during initialize', () => { const enospcError = new Error('ENOSPC: no space left on device'); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index cd8d1e53c1..64d7056b6d 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -104,6 +104,13 @@ export interface ConversationRecord { directories?: string[]; /** The kind of conversation (main agent or subagent) */ kind?: 'main' | 'subagent'; + /** + * The index into `messages` at which the last compression occurred. + * On resume, only messages from this index onward need to be loaded + * into the client history / UI — earlier messages were already + * summarised and folded into the compressed context. + */ + lastCompressionIndex?: number; } /** @@ -532,6 +539,25 @@ export class ChatRecordingService { this.writeConversation(conversation); } + /** + * Marks the current end of the messages array as the compression point. + * On resume, only messages from this index onward will be loaded. + */ + recordCompressionPoint(): void { + if (!this.conversationFile) return; + + try { + this.updateConversation((conversation) => { + conversation.lastCompressionIndex = conversation.messages.length; + }); + } catch (error) { + debugLogger.error( + 'Error recording compression point in chat history.', + error, + ); + } + } + /** * Saves a summary for the current session. */ diff --git a/packages/core/src/utils/sessionUtils.test.ts b/packages/core/src/utils/sessionUtils.test.ts index d132087ee8..c2b778f7a2 100644 --- a/packages/core/src/utils/sessionUtils.test.ts +++ b/packages/core/src/utils/sessionUtils.test.ts @@ -182,4 +182,70 @@ describe('convertSessionToClientHistory', () => { }, ]); }); + + describe('startIndex parameter', () => { + const messages: ConversationRecord['messages'] = [ + { + id: '1', + type: 'user', + timestamp: '2024-01-01T10:00:00Z', + content: 'First message', + }, + { + id: '2', + type: 'gemini', + timestamp: '2024-01-01T10:01:00Z', + content: 'First response', + }, + { + id: '3', + type: 'user', + timestamp: '2024-01-01T10:02:00Z', + content: 'Second message', + }, + { + id: '4', + type: 'gemini', + timestamp: '2024-01-01T10:03:00Z', + content: 'Second response', + }, + ]; + + it('should only convert messages from startIndex onward', () => { + const history = convertSessionToClientHistory(messages, 2); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Second message' }] }, + { role: 'model', parts: [{ text: 'Second response' }] }, + ]); + }); + + it('should convert all messages when startIndex is 0', () => { + const history = convertSessionToClientHistory(messages, 0); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'First message' }] }, + { role: 'model', parts: [{ text: 'First response' }] }, + { role: 'user', parts: [{ text: 'Second message' }] }, + { role: 'model', parts: [{ text: 'Second response' }] }, + ]); + }); + + it('should convert all messages when startIndex is undefined', () => { + const history = convertSessionToClientHistory(messages, undefined); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'First message' }] }, + { role: 'model', parts: [{ text: 'First response' }] }, + { role: 'user', parts: [{ text: 'Second message' }] }, + { role: 'model', parts: [{ text: 'Second response' }] }, + ]); + }); + + it('should return empty array when startIndex exceeds messages length', () => { + const history = convertSessionToClientHistory(messages, 100); + + expect(history).toEqual([]); + }); + }); }); diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index 4803dd4f07..488f1dbbbf 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -26,13 +26,22 @@ function ensurePartArray(content: PartListUnion): Part[] { /** * Converts session/conversation data into Gemini client history formats. + * + * @param messages - The full array of recorded messages. + * @param startIndex - If provided, only messages from this index onward are + * converted. Used on resume to skip pre-compression history. */ export function convertSessionToClientHistory( messages: ConversationRecord['messages'], + startIndex?: number, ): Array<{ role: 'user' | 'model'; parts: Part[] }> { const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = []; + const slice = + startIndex != null && startIndex > 0 + ? messages.slice(startIndex) + : messages; - for (const msg of messages) { + for (const msg of slice) { if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { continue; }