diff --git a/integration-tests/context-fidelity.test.ts b/integration-tests/context-fidelity.test.ts index 5b5f83d03b..1e688f8d75 100644 --- a/integration-tests/context-fidelity.test.ts +++ b/integration-tests/context-fidelity.test.ts @@ -20,7 +20,7 @@ describe('Context Management Fidelity E2E', () => { afterEach(async () => await rig.cleanup()); - it('should reproduce the exact context working buffer on resume', async () => { + it('should reproduce the exact context working buffer on resume', { timeout: 30000 }, async () => { // Mock responses to trigger GC (summarization) const snapshotResponse: FakeResponse = { method: 'generateContent', @@ -205,28 +205,35 @@ describe('Context Management Fidelity E2E', () => { contextBeforeExit!.length, ); - for (let i = 0; i < contextBeforeExit!.length; i++) { - expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id); - expect(contextAfterResume![i].content).toEqual( - contextBeforeExit![i].content, + try { + for (let i = 0; i < contextBeforeExit!.length; i++) { + expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id); + expect(contextAfterResume![i].content).toEqual( + contextBeforeExit![i].content, + ); + } + + // Most importantly, synthetic IDs (like summaries) must be stable. + const syntheticTurns = contextBeforeExit!.filter( + (t: HistoryTurn) => t.id && t.id.length === 32, + ); // deriveStableId produces 32-char hex + expect(syntheticTurns.length).toBeGreaterThan(0); + + const syntheticTurnsAfter = contextAfterResume!.filter( + (t: HistoryTurn) => t.id && t.id.length === 32, ); + expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual( + syntheticTurns.length, + ); + + // Check if the first synthetic turn is identical + expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id); + expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content); + } catch (e) { + console.error('--- DEBUG LOG ---'); + console.error(fs.readFileSync(commonEnv.GEMINI_DEBUG_LOG_FILE, 'utf-8')); + console.error('-----------------'); + throw e; } - - // Most importantly, synthetic IDs (like summaries) must be stable. - const syntheticTurns = contextBeforeExit!.filter( - (t: HistoryTurn) => t.id && t.id.length === 32, - ); // deriveStableId produces 32-char hex - expect(syntheticTurns.length).toBeGreaterThan(0); - - const syntheticTurnsAfter = contextAfterResume!.filter( - (t: HistoryTurn) => t.id && t.id.length === 32, - ); - expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual( - syntheticTurns.length, - ); - - // Check if the first synthetic turn is identical - expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id); - expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content); - }); + }, 30000); }); diff --git a/integration-tests/resume-gc.test.ts b/integration-tests/resume-gc.test.ts index 3380177049..3b1f1dfaae 100644 --- a/integration-tests/resume-gc.test.ts +++ b/integration-tests/resume-gc.test.ts @@ -20,7 +20,10 @@ describe('Context Management Resume E2E', () => { afterEach(async () => await rig.cleanup()); - it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => { + it( + 'should preserve and utilize GC snapshot boundaries when resuming a session', + { timeout: 30000 }, + async () => { const snapshotResponse: FakeResponse = { method: 'generateContent', response: { @@ -145,5 +148,5 @@ describe('Context Management Resume E2E', () => { const traces = fs.readFileSync(traceLog, 'utf-8'); expect(traces).toContain('Hitting Synchronous Pressure Barrier'); expect(traces).toContain('GC Triggered.'); - }); + }, 30000); }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index b42e1c5a72..2b5a65d46a 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -19,7 +19,6 @@ import { convertSessionToHistoryFormats, type SessionInfo, } from '../../utils/sessionUtils.js'; -import type { Part } from '@google/genai'; export { convertSessionToHistoryFormats }; @@ -27,7 +26,7 @@ export const useSessionBrowser = ( config: Config, onLoadHistory: ( uiHistory: HistoryItemWithoutId[], - clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, + clientHistory: any[], resumedSessionData: ResumedSessionData, ) => Promise, ) => { diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 055686773b..42578c8bda 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -11,7 +11,6 @@ import { type ResumedSessionData, convertSessionToClientHistory, } 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'; @@ -54,7 +53,7 @@ export function useSessionResume({ const loadHistoryForResume = useCallback( async ( uiHistory: HistoryItemWithoutId[], - clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>, + clientHistory: any[], resumedData: ResumedSessionData, ) => { // Wait for the client. diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts index 6e9f53f7fd..b47bd92b9c 100644 --- a/packages/core/src/context/contextManager.ts +++ b/packages/core/src/context/contextManager.ts @@ -398,7 +398,17 @@ export class ContextManager { }); if (pendingRequest) { - hardenedHistory.pop(); // Remove the pending request from the final output + const last = hardenedHistory[hardenedHistory.length - 1]; + if (last && last.content.parts) { + const numPartsToRemove = pendingRequest.content.parts?.length || 0; + if (numPartsToRemove > 0 && last.content.parts.length > numPartsToRemove) { + last.content.parts.splice(-numPartsToRemove); + } else { + hardenedHistory.pop(); + } + } else { + hardenedHistory.pop(); + } } const apiHistory = hardenedHistory.map((h) => h.content); diff --git a/packages/core/src/context/graph/fromGraph.ts b/packages/core/src/context/graph/fromGraph.ts index 92740cc02e..147508b92b 100644 --- a/packages/core/src/context/graph/fromGraph.ts +++ b/packages/core/src/context/graph/fromGraph.ts @@ -27,7 +27,7 @@ export function fromGraph( let currentTurn: { id: string; content: Content } | null = null; for (const node of nodes) { - const turnId = node.turnId || 'orphan'; + const turnId = node.turnId || 'orphan'; debugLogger.log("[ID-TRACK] fromGraph converting node:", node.id, "turnId:", turnId); const durableId = turnId.startsWith('turn_') ? turnId.slice(5) : turnId; // Register the payload in the identity service to ensure stability diff --git a/packages/core/src/context/graph/toGraph.ts b/packages/core/src/context/graph/toGraph.ts index c5dfc0738c..0214c7021b 100644 --- a/packages/core/src/context/graph/toGraph.ts +++ b/packages/core/src/context/graph/toGraph.ts @@ -211,13 +211,13 @@ export class ContextGraphBuilder { ? part.functionCall.id : undefined; + const isSnapshot = isTextPart(part) && isSnapshotState(part.text); + // Use stable API ID if available, otherwise anchor to the turn and index. const id = apiId ? `${apiId}_${turnSalt}_${partIdx}` : `${turnSalt}_${partIdx}`; - const isSnapshot = isTextPart(part) && isSnapshotState(part.text); - const node: ConcreteNode = { id, timestamp: Date.now(), diff --git a/packages/core/src/context/utils/snapshotGenerator.ts b/packages/core/src/context/utils/snapshotGenerator.ts index 3156f516c6..ddad21227a 100644 --- a/packages/core/src/context/utils/snapshotGenerator.ts +++ b/packages/core/src/context/utils/snapshotGenerator.ts @@ -48,7 +48,12 @@ export interface SnapshotState { recent_arc: string[]; } +import { debugLogger } from '../../utils/debugLogger.js'; + export function isSnapshotState(text: string): boolean { + import('../../utils/debugLogger.js').then(({ debugLogger }) => { + debugLogger.log('[isSnapshotState] CALLED WITH:', text.substring(0, 50)); + }); const trimmed = text.trim(); if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { return false; @@ -56,13 +61,16 @@ export function isSnapshotState(text: string): boolean { try { const parsed: unknown = JSON.parse(trimmed); if (!isRecord(parsed)) return false; - return ( - Array.isArray(parsed['active_tasks']) && + const isSnap = Array.isArray(parsed['active_tasks']) && Array.isArray(parsed['discovered_facts']) && Array.isArray(parsed['constraints_and_preferences']) && - Array.isArray(parsed['recent_arc']) - ); - } catch { + Array.isArray(parsed['recent_arc']); + if (!isSnap) { + debugLogger.log('[isSnapshotState] FAILED FOR JSON:', JSON.stringify(parsed)); + } + return isSnap; + } catch (e) { + debugLogger.log('[isSnapshotState] PARSE FAILED FOR:', trimmed); return false; } } @@ -81,6 +89,9 @@ export interface BaselineSnapshotInfo { export function findLatestSnapshotBaseline( targets: readonly ConcreteNode[], ): BaselineSnapshotInfo | undefined { + import('../../utils/debugLogger.js').then(({ debugLogger }) => { + debugLogger.log('[findLatestSnapshotBaseline] Targets:', targets.map(t => ({ id: t.id, type: t.type, text: (t.payload as any).text?.substring(0, 20) }))); + }); const lastSnapshotNode = [...targets] .reverse() .find((n) => n.type === NodeType.SNAPSHOT && n.payload.text); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 21ac4a1e17..bc6cfc8bc2 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -337,7 +337,7 @@ export class GeminiClient { } async resumeChat( - history: Content[], + history: any[], resumedSessionData?: ResumedSessionData, ): Promise { this.chat = await this.startChat(history, resumedSessionData); @@ -378,7 +378,7 @@ export class GeminiClient { } async startChat( - extraHistory?: Content[], + extraHistory?: any[], resumedSessionData?: ResumedSessionData, ): Promise { this.forceFullIdeContext = true; @@ -394,13 +394,16 @@ export class GeminiClient { : await getInitialChatHistory(this.config, extraHistory); try { + import('node:fs').then((fs) => { + fs.appendFileSync('/tmp/gemini_startChat_history.log', JSON.stringify(history, null, 2) + '\n'); + }); const systemMemory = this.config.getSystemInstructionMemory(); const systemInstruction = getCoreSystemPrompt(this.config, systemMemory); const chat = new GeminiChat( this.config, systemInstruction, tools, - history, + history as any[], resumedSessionData, async (modelId: string) => { this.lastUsedModelId = modelId; @@ -421,7 +424,7 @@ export class GeminiClient { await reportError( error, 'Error initializing Gemini chat session.', - history, + history as Content[], 'startChat', ); throw new Error(`Failed to initialize chat: ${getErrorMessage(error)}`); @@ -665,6 +668,7 @@ export class GeminiClient { currentBaseUnits = baseUnits; if (didApplyManagement) { + import('node:fs').then(fs => fs.appendFileSync('/tmp/gemini_sync.log', `[client.ts] setHistory called with ${newHistory.length} items!\n`)); // If the manager pruned history, we update the chat before continuing. // Note: we don't include the pendingRequest in this setHistory, // because Turn.run will add it normally. diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index ca76a0e499..1a70ca6758 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -898,6 +898,7 @@ export class ChatRecordingService { // 1. Sync content and IDs const newMessages: MessageRecord[] = history.map((turn) => { + import('node:fs').then(fs => fs.appendFileSync('/tmp/gemini_sync.log', `[${new Date().toISOString()}] [updateMessages] Turn ID: ${turn.id}, text: ${turn.content.parts?.[0]?.text?.substring(0, 50)}\n`)); const existing = this.cachedConversation?.messages.find( (m) => m.id === turn.id, ); diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index 4803dd4f07..c6c12247a1 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -7,6 +7,7 @@ import { type Part, type PartListUnion } from '@google/genai'; import { type ConversationRecord } from '../services/chatRecordingService.js'; import { partListUnionToString } from '../core/geminiRequest.js'; +import { type HistoryTurn } from '../core/agentChatHistory.js'; /** * Converts a PartListUnion into a normalized array of Part objects. @@ -29,8 +30,8 @@ function ensurePartArray(content: PartListUnion): Part[] { */ export function convertSessionToClientHistory( messages: ConversationRecord['messages'], -): Array<{ role: 'user' | 'model'; parts: Part[] }> { - const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = []; +): HistoryTurn[] { + const clientHistory: HistoryTurn[] = []; for (const msg of messages) { if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { @@ -47,8 +48,11 @@ export function convertSessionToClientHistory( } clientHistory.push({ - role: 'user', - parts: ensurePartArray(msg.content), + id: msg.id, + content: { + role: 'user', + parts: ensurePartArray(msg.content), + } }); } else if (msg.type === 'gemini') { const modelParts: Part[] = []; @@ -85,8 +89,11 @@ export function convertSessionToClientHistory( } clientHistory.push({ - role: 'model', - parts: modelParts, + id: msg.id, + content: { + role: 'model', + parts: modelParts, + } }); const functionResponseParts: Part[] = []; @@ -117,8 +124,11 @@ export function convertSessionToClientHistory( if (functionResponseParts.length > 0) { clientHistory.push({ - role: 'user', - parts: functionResponseParts, + id: `${msg.id}_response`, + content: { + role: 'user', + parts: functionResponseParts, + } }); } } else { @@ -128,8 +138,11 @@ export function convertSessionToClientHistory( if (modelParts.length > 0) { clientHistory.push({ - role: 'model', - parts: modelParts, + id: msg.id, + content: { + role: 'model', + parts: modelParts, + }, }); } } @@ -137,4 +150,4 @@ export function convertSessionToClientHistory( } return clientHistory; -} +} \ No newline at end of file