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