diff --git a/integration-tests/context-fidelity.test.ts b/integration-tests/context-fidelity.test.ts index 1e688f8d75..5b5f83d03b 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', { timeout: 30000 }, async () => { + it('should reproduce the exact context working buffer on resume', async () => { // Mock responses to trigger GC (summarization) const snapshotResponse: FakeResponse = { method: 'generateContent', @@ -205,35 +205,28 @@ describe('Context Management Fidelity E2E', () => { contextBeforeExit!.length, ); - 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, + for (let i = 0; i < contextBeforeExit!.length; i++) { + expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id); + expect(contextAfterResume![i].content).toEqual( + contextBeforeExit![i].content, ); - 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; } - }, 30000); + + // 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); + }); }); diff --git a/integration-tests/resume-gc.test.ts b/integration-tests/resume-gc.test.ts index 3b1f1dfaae..3380177049 100644 --- a/integration-tests/resume-gc.test.ts +++ b/integration-tests/resume-gc.test.ts @@ -20,10 +20,7 @@ describe('Context Management Resume E2E', () => { afterEach(async () => await rig.cleanup()); - it( - 'should preserve and utilize GC snapshot boundaries when resuming a session', - { timeout: 30000 }, - async () => { + it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => { const snapshotResponse: FakeResponse = { method: 'generateContent', response: { @@ -148,5 +145,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/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index f703323c1b..a7b0feee4a 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -14,7 +14,6 @@ import { type HistoryItem } from '../types.js'; import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js'; import { revertFileChanges } from '../utils/rewindFileOps.js'; import { RewindOutcome } from '../components/RewindConfirmation.js'; -import type { Content } from '@google/genai'; import { checkExhaustive, coreEvents, @@ -58,7 +57,7 @@ async function rewindConversation( const { uiHistory } = convertSessionToHistoryFormats(conversation.messages); const clientHistory = convertSessionToClientHistory(conversation.messages); - client.setHistory(clientHistory as Content[]); + client.setHistory(clientHistory); // Reset context manager as we are rewinding history await context.services.agentContext?.config diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index cb4e3bd17d..2402bb1adc 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -194,14 +194,16 @@ describe('convertSessionToHistoryFormats', () => { const clientHistory = convertSessionToClientHistory(messages); expect(clientHistory).toHaveLength(2); - expect(clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'Hello' }], - }); - expect(clientHistory[1]).toEqual({ - role: 'model', - parts: [{ text: 'Hi there' }], - }); + expect(clientHistory.map((h) => h.content)).toEqual([ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + { + role: 'model', + parts: [{ text: 'Hi there' }], + }, + ]); }); it('should convert thinking tokens (thoughts) to thinking history items', () => { @@ -254,10 +256,12 @@ describe('convertSessionToHistoryFormats', () => { const clientHistory = convertSessionToClientHistory(messages); expect(clientHistory).toHaveLength(1); - expect(clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'Expanded content' }], - }); + expect(clientHistory.map((h) => h.content)).toEqual([ + { + role: 'user', + parts: [{ text: 'Expanded content' }], + }, + ]); }); it('should filter out slash commands from client history but keep in UI', () => { @@ -316,33 +320,35 @@ describe('convertSessionToHistoryFormats', () => { const clientHistory = convertSessionToClientHistory(messages); expect(clientHistory).toHaveLength(3); // User, Model (call), User (response) - expect(clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'What time is it?' }], - }); - expect(clientHistory[1]).toEqual({ - role: 'model', - parts: [ - { - functionCall: { - name: 'get_time', - args: {}, - id: 'call_1', + expect(clientHistory.map((h) => h.content)).toEqual([ + { + role: 'user', + parts: [{ text: 'What time is it?' }], + }, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'get_time', + args: {}, + id: 'call_1', + }, }, - }, - ], - }); - expect(clientHistory[2]).toEqual({ - role: 'user', - parts: [ - { - functionResponse: { - id: 'call_1', - name: 'get_time', - response: { output: '12:00' }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'get_time', + response: { output: '12:00' }, + id: 'call_1', + }, }, - }, - ], - }); + ], + }, + ]); }); }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 2b5a65d46a..aabb2eb0b2 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -12,8 +12,11 @@ import { convertSessionToClientHistory, uiTelemetryService, loadConversationRecord, - type Config, - type ResumedSessionData, +} from '@google/gemini-cli-core'; +import type { + HistoryTurn, + Config, + ResumedSessionData, } from '@google/gemini-cli-core'; import { convertSessionToHistoryFormats, @@ -22,11 +25,15 @@ import { export { convertSessionToHistoryFormats }; +import type { Part } from '@google/genai'; + export const useSessionBrowser = ( config: Config, onLoadHistory: ( uiHistory: HistoryItemWithoutId[], - clientHistory: any[], + clientHistory: Array< + { role: 'user' | 'model'; parts: Part[] } | HistoryTurn + >, resumedSessionData: ResumedSessionData, ) => Promise, ) => { diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 3997eb06c5..c1655f6ce6 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -13,6 +13,7 @@ import type { ResumedSessionData, ConversationRecord, MessageRecord, + HistoryTurn, } from '@google/gemini-cli-core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { HistoryItemWithoutId } from '../types.js'; @@ -527,10 +528,12 @@ describe('useSessionResume', () => { // Should only have the non-slash-command message expect(clientHistory).toHaveLength(1); - expect(clientHistory[0]).toEqual({ - role: 'user', - parts: [{ text: 'Regular message' }], - }); + expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([ + { + role: 'user', + parts: [{ text: 'Regular message' }], + }, + ]); // But UI history should have both expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2); diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 42578c8bda..9808b26579 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -7,13 +7,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { coreEvents, - type Config, - type ResumedSessionData, convertSessionToClientHistory, } from '@google/gemini-cli-core'; +import type { + HistoryTurn, + Config, + ResumedSessionData, +} from '@google/gemini-cli-core'; import type { HistoryItemWithoutId } from '../types.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { convertSessionToHistoryFormats } from './useSessionBrowser.js'; +import type { Part } from '@google/genai'; interface UseSessionResumeParams { config: Config; @@ -53,7 +57,9 @@ export function useSessionResume({ const loadHistoryForResume = useCallback( async ( uiHistory: HistoryItemWithoutId[], - clientHistory: any[], + clientHistory: Array< + { role: 'user' | 'model'; parts: Part[] } | HistoryTurn + >, resumedData: ResumedSessionData, ) => { // Wait for the client. diff --git a/packages/core/src/context/chatCompressionService.test.ts b/packages/core/src/context/chatCompressionService.test.ts index c4f26dedc0..ea21bb0225 100644 --- a/packages/core/src/context/chatCompressionService.test.ts +++ b/packages/core/src/context/chatCompressionService.test.ts @@ -196,7 +196,7 @@ describe('ChatCompressionService', () => { } as unknown as Config; vi.mocked(getInitialChatHistory).mockImplementation( - async (_config, extraHistory) => extraHistory || [], + async (_config, extraHistory) => (extraHistory ? [...extraHistory] : []), ); }); diff --git a/packages/core/src/context/chatCompressionService.ts b/packages/core/src/context/chatCompressionService.ts index 992ca67cf9..6d49ed97e7 100644 --- a/packages/core/src/context/chatCompressionService.ts +++ b/packages/core/src/context/chatCompressionService.ts @@ -442,7 +442,9 @@ export class ChatCompressionService { const fullNewHistory = await getInitialChatHistory(config, extraHistory); const newTokenCount = await calculateRequestTokenCount( - fullNewHistory.flatMap((c) => c.parts || []), + fullNewHistory.flatMap( + (c) => ('content' in c ? c.content.parts : c.parts) || [], + ), config.getContentGenerator(), model, ); diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts index b47bd92b9c..e8a59b7967 100644 --- a/packages/core/src/context/contextManager.ts +++ b/packages/core/src/context/contextManager.ts @@ -401,7 +401,10 @@ export class ContextManager { 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) { + if ( + numPartsToRemove > 0 && + last.content.parts.length > numPartsToRemove + ) { last.content.parts.splice(-numPartsToRemove); } else { hardenedHistory.pop(); diff --git a/packages/core/src/context/graph/fromGraph.ts b/packages/core/src/context/graph/fromGraph.ts index 147508b92b..92740cc02e 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'; debugLogger.log("[ID-TRACK] fromGraph converting node:", node.id, "turnId:", turnId); + const turnId = node.turnId || 'orphan'; 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/system-tests/__snapshots__/lifecycle.golden.test.ts.snap b/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap index 76dfdd643b..afd08df64d 100644 --- a/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap +++ b/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap @@ -71,6 +71,13 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To }, "thoughtSignature": "skip_thought_signature_validator", }, + { + "functionCall": { + "args": {}, + "id": "undefined", + "name": "run_shell_command", + }, + }, ], "role": "model", }, @@ -89,6 +96,15 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To }, }, }, + { + "functionResponse": { + "id": "undefined", + "name": "run_shell_command", + "response": { + "error": "The tool execution result was lost due to context management truncation.", + }, + }, + }, ], "role": "user", }, diff --git a/packages/core/src/context/utils/snapshotGenerator.ts b/packages/core/src/context/utils/snapshotGenerator.ts index ddad21227a..3042d560ce 100644 --- a/packages/core/src/context/utils/snapshotGenerator.ts +++ b/packages/core/src/context/utils/snapshotGenerator.ts @@ -51,9 +51,6 @@ export interface SnapshotState { 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; @@ -61,15 +58,19 @@ export function isSnapshotState(text: string): boolean { try { const parsed: unknown = JSON.parse(trimmed); if (!isRecord(parsed)) return false; - const isSnap = 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']); if (!isSnap) { - debugLogger.log('[isSnapshotState] FAILED FOR JSON:', JSON.stringify(parsed)); + debugLogger.log( + '[isSnapshotState] FAILED FOR JSON:', + JSON.stringify(parsed), + ); } return isSnap; - } catch (e) { + } catch { debugLogger.log('[isSnapshotState] PARSE FAILED FOR:', trimmed); return false; } @@ -89,9 +90,20 @@ 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) }))); - }); + debugLogger.log( + '[findLatestSnapshotBaseline] Targets:', + targets.map((t) => ({ + id: t.id, + type: t.type, + text: + t.payload && + typeof t.payload === 'object' && + 'text' in t.payload && + typeof t.payload.text === 'string' + ? t.payload.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 bc6cfc8bc2..69a68c0313 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: any[], + history: ReadonlyArray, resumedSessionData?: ResumedSessionData, ): Promise { this.chat = await this.startChat(history, resumedSessionData); @@ -378,7 +378,7 @@ export class GeminiClient { } async startChat( - extraHistory?: any[], + extraHistory?: ReadonlyArray, resumedSessionData?: ResumedSessionData, ): Promise { this.forceFullIdeContext = true; @@ -394,16 +394,13 @@ 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 as any[], + [...history], resumedSessionData, async (modelId: string) => { this.lastUsedModelId = modelId; @@ -424,7 +421,7 @@ export class GeminiClient { await reportError( error, 'Error initializing Gemini chat session.', - history as Content[], + [...history], 'startChat', ); throw new Error(`Failed to initialize chat: ${getErrorMessage(error)}`); @@ -668,7 +665,6 @@ 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 1a70ca6758..ca76a0e499 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -898,7 +898,6 @@ 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/environmentContext.ts b/packages/core/src/utils/environmentContext.ts index 947062eb27..6344a08569 100644 --- a/packages/core/src/utils/environmentContext.ts +++ b/packages/core/src/utils/environmentContext.ts @@ -7,6 +7,7 @@ import type { Part, Content } from '@google/genai'; import type { Config } from '../config/config.js'; import { getFolderStructure } from './getFolderStructure.js'; +import type { HistoryTurn } from '../core/agentChatHistory.js'; export const INITIAL_HISTORY_LENGTH = 1; @@ -81,8 +82,8 @@ ${environmentMemory} export async function getInitialChatHistory( config: Config, - extraHistory?: Content[], -): Promise { + extraHistory?: ReadonlyArray, +): Promise> { const envParts = await getEnvironmentContext(config); const envContextString = envParts.map((part) => part.text || '').join('\n\n'); diff --git a/packages/core/src/utils/historyHardening.test.ts b/packages/core/src/utils/historyHardening.test.ts index 6c691bd3fe..face667132 100644 --- a/packages/core/src/utils/historyHardening.test.ts +++ b/packages/core/src/utils/historyHardening.test.ts @@ -212,7 +212,7 @@ describe('hardenHistory', () => { ).toEqual({ ok: true }); }); - it('should drop orphaned functionResponses', () => { + it('should synthesize a functionCall for a singleton orphaned functionResponse', () => { const history: HistoryTurn[] = [ { id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } }, { id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } }, @@ -231,9 +231,86 @@ describe('hardenHistory', () => { ]; const hardened = hardenHistory(history); + // Turn 1: user, Turn 2: model (with synthetic call), Turn 3: user expect(hardened.length).toBe(3); - expect(hardened[2].content.parts).toHaveLength(1); - expect(hardened[2].content.parts![0]).toEqual({ text: 'text is kept' }); + + const modelTurn = hardened[1]; + expect(modelTurn.content.role).toBe('model'); + expect(modelTurn.content.parts).toHaveLength(2); // text + synthetic call + expect(modelTurn.content.parts![1].functionCall).toBeDefined(); + expect(modelTurn.content.parts![1].functionCall?.id).toBe('orphan_1'); + expect( + (modelTurn.content.parts![1] as unknown as { thoughtSignature: string }) + .thoughtSignature, + ).toBe(SYNTHETIC_THOUGHT_SIGNATURE); + + const userTurn = hardened[2]; + expect(userTurn.content.parts).toHaveLength(2); // hoisted response + text + expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_1'); + expect(userTurn.content.parts![1]).toEqual({ text: 'text is kept' }); + }); + + it('should synthesize functionCalls for multiple orphaned functionResponses in parallel', () => { + const history: HistoryTurn[] = [ + { + id: '1', + content: { role: 'user', parts: [{ text: 'Parallel action' }] }, + }, + // Previous model turn exists but has NO tool calls + { + id: '2', + content: { role: 'model', parts: [{ text: 'I will do nothing' }] }, + }, + { + id: '3', + content: { + role: 'user', + parts: [ + { + functionResponse: { id: 'orphan_A', name: 'toolA', response: {} }, + }, + { + functionResponse: { id: 'orphan_B', name: 'toolB', response: {} }, + }, + { + functionResponse: { id: 'orphan_C', name: 'toolC', response: {} }, + }, + ], + }, + }, + ]; + + const hardened = hardenHistory(history); + expect(hardened.length).toBe(3); + + const modelTurn = hardened[1]; + expect(modelTurn.content.role).toBe('model'); + expect(modelTurn.content.parts).toHaveLength(4); // original text + 3 synthetic calls + + // Only the FIRST function call should get the synthetic signature + const callA = modelTurn.content.parts![1]; + expect(callA.functionCall?.id).toBe('orphan_A'); + expect( + (callA as unknown as { thoughtSignature?: string }).thoughtSignature, + ).toBe(SYNTHETIC_THOUGHT_SIGNATURE); + + const callB = modelTurn.content.parts![2]; + expect(callB.functionCall?.id).toBe('orphan_B'); + expect( + (callB as unknown as { thoughtSignature?: string }).thoughtSignature, + ).toBeUndefined(); + + const callC = modelTurn.content.parts![3]; + expect(callC.functionCall?.id).toBe('orphan_C'); + expect( + (callC as unknown as { thoughtSignature?: string }).thoughtSignature, + ).toBeUndefined(); + + const userTurn = hardened[2]; + expect(userTurn.content.parts).toHaveLength(3); + expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_A'); + expect(userTurn.content.parts![1].functionResponse?.id).toBe('orphan_B'); + expect(userTurn.content.parts![2].functionResponse?.id).toBe('orphan_C'); }); it('should hoist and re-order tool responses to match functionCall order', () => { diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index a034f0a3d3..8a2dc547b1 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -179,6 +179,7 @@ function pairToolsAndEnforceSignatures( const prevTurn = result[result.length - 1]; const parts = turn.content.parts || []; const validParts: Part[] = []; + const orphanedResponses: Part[] = []; for (const p of parts) { if (p.functionResponse) { @@ -195,13 +196,47 @@ function pairToolsAndEnforceSignatures( validParts.push(p); } else { debugLogger.log( - `[HistoryHardener] Dropping orphaned functionResponse id='${id}' (name='${name}')`, + `[HistoryHardener] Orphaned functionResponse id='${id}' (name='${name}'). Injecting synthetic functionCall.`, ); + orphanedResponses.push(p); + validParts.push(p); } } else { validParts.push(p); } } + + if (orphanedResponses.length > 0) { + let targetModelTurn: HistoryTurn; + if (prevTurn?.content.role === 'model') { + targetModelTurn = prevTurn; + } else { + targetModelTurn = { + id: deriveStableId([turn.id, 'sentinel_call']), + content: { role: 'model', parts: [] }, + }; + result.push(targetModelTurn); + } + + for (const orph of orphanedResponses) { + targetModelTurn.content.parts = targetModelTurn.content.parts || []; + const hasExistingCall = targetModelTurn.content.parts.some( + (p) => !!p.functionCall, + ); + const callPart: Part = { + functionCall: { + name: orph.functionResponse!.name, + id: orph.functionResponse!.id, + args: {}, + }, + }; + if (!hasExistingCall) { + callPart.thoughtSignature = SYNTHETIC_THOUGHT_SIGNATURE; + } + targetModelTurn.content.parts.push(callPart); + } + } + turn.content.parts = validParts; } diff --git a/packages/core/src/utils/sessionUtils.test.ts b/packages/core/src/utils/sessionUtils.test.ts index d132087ee8..adcb2e76e0 100644 --- a/packages/core/src/utils/sessionUtils.test.ts +++ b/packages/core/src/utils/sessionUtils.test.ts @@ -27,7 +27,7 @@ describe('convertSessionToClientHistory', () => { const history = convertSessionToClientHistory(messages); - expect(history).toEqual([ + expect(history.map((h) => h.content)).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there' }] }, ]); @@ -58,7 +58,7 @@ describe('convertSessionToClientHistory', () => { const history = convertSessionToClientHistory(messages); - expect(history).toEqual([ + expect(history.map((h) => h.content)).toEqual([ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', @@ -100,7 +100,7 @@ describe('convertSessionToClientHistory', () => { const history = convertSessionToClientHistory(messages); - expect(history).toEqual([ + expect(history.map((h) => h.content)).toEqual([ { role: 'user', parts: [{ text: 'Actual query' }] }, ]); }); @@ -133,7 +133,7 @@ describe('convertSessionToClientHistory', () => { const history = convertSessionToClientHistory(messages); - expect(history).toEqual([ + expect(history.map((h) => h.content)).toEqual([ { role: 'user', parts: [{ text: 'List files' }] }, { role: 'model', @@ -172,7 +172,7 @@ describe('convertSessionToClientHistory', () => { const history = convertSessionToClientHistory(messages); - expect(history).toEqual([ + expect(history.map((h) => h.content)).toEqual([ { role: 'user', parts: [ diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index c6c12247a1..822612cb28 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -52,7 +52,7 @@ export function convertSessionToClientHistory( content: { role: 'user', parts: ensurePartArray(msg.content), - } + }, }); } else if (msg.type === 'gemini') { const modelParts: Part[] = []; @@ -93,7 +93,7 @@ export function convertSessionToClientHistory( content: { role: 'model', parts: modelParts, - } + }, }); const functionResponseParts: Part[] = []; @@ -128,7 +128,7 @@ export function convertSessionToClientHistory( content: { role: 'user', parts: functionResponseParts, - } + }, }); } } else { @@ -150,4 +150,4 @@ export function convertSessionToClientHistory( } return clientHistory; -} \ No newline at end of file +}