From 50b279f0d9e076584042c002a7d1895da22cedcb Mon Sep 17 00:00:00 2001 From: Aishanee Shah Date: Wed, 13 May 2026 15:16:59 +0000 Subject: [PATCH] feat: implement unified session bundle format (v2.0) and history reconstruction --- .../cli/src/ui/commands/bugCommand.test.ts | 2 +- packages/cli/src/ui/commands/bugCommand.ts | 4 +- .../cli/src/ui/commands/chatCommand.test.ts | 25 ++++-- packages/cli/src/ui/commands/chatCommand.ts | 12 +-- .../cli/src/ui/hooks/slashCommandProcessor.ts | 11 ++- .../cli/src/ui/utils/historyExportUtils.ts | 39 +++++---- packages/core/src/commands/types.ts | 4 + packages/core/src/core/client.ts | 8 +- packages/core/src/core/geminiChat.ts | 9 +++ packages/core/src/core/logger.test.ts | 29 ++++--- packages/core/src/core/logger.ts | 52 +++++++++--- packages/core/src/index.ts | 1 + .../core/src/services/chatRecordingService.ts | 10 +++ .../core/src/utils/history-reconstruction.ts | 80 +++++++++++++++++++ 14 files changed, 232 insertions(+), 54 deletions(-) create mode 100644 packages/core/src/utils/history-reconstruction.ts diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index a9c868639c..9a0d579064 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -195,10 +195,10 @@ describe('bugCommand', () => { 'bug-report-history-1704067200000.json', ); expect(exportHistoryToFile).toHaveBeenCalledWith({ - history, messages: [], filePath: expectedPath, trajectories: {}, + history, }); const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0]; diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 5ab6d4ad9f..516782c0c1 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -89,12 +89,12 @@ export const bugCommand: SlashCommand = { const historyFilePath = path.join(tempDir, historyFileName); try { const trajectories = await chat?.getSubagentTrajectories(); - const messages = chat?.getConversation()?.messages; + const messages = chat?.getConversation()?.messages ?? []; await exportHistoryToFile({ - history, messages, filePath: historyFilePath, trajectories, + history, }); historyFileMessage = `\n\n--------------------------------------------------------------------------------\n\nšŸ“„ **Chat History Exported**\nTo help us debug, we've exported your current chat history to:\n${historyFilePath}\n\nPlease consider attaching this file to your GitHub issue if you feel comfortable doing so.\n\n**Privacy Disclaimer:** Please do not upload any logs containing sensitive or private information that you are not comfortable sharing publicly.`; problemValue += `\n\n[ACTION REQUIRED] šŸ“Ž PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.`; diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index b735990827..80c3d4540b 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -193,6 +193,15 @@ describe('chatCommand', () => { { role: 'user', parts: [{ text: 'Hello, how are you?' }] }, ]); result = await saveCommand?.action?.(mockContext, tag); + expect(mockSaveCheckpoint).toHaveBeenCalledWith( + { + version: '2.0', + authType: AuthType.LOGIN_WITH_GOOGLE, + trajectories: {}, + messages: [], + }, + tag, + ); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -233,7 +242,7 @@ describe('chatCommand', () => { expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check expect(mockSaveCheckpoint).toHaveBeenCalledWith( { - history, + version: '2.0', authType: AuthType.LOGIN_WITH_GOOGLE, trajectories: {}, messages: [], @@ -299,6 +308,8 @@ describe('chatCommand', () => { { type: 'gemini', text: 'hello world' }, ] as HistoryItemWithoutId[], clientHistory: conversation, + messages: undefined, + version: undefined, }); }); @@ -339,6 +350,8 @@ describe('chatCommand', () => { { type: 'gemini', text: 'hello world' }, ] as HistoryItemWithoutId[], clientHistory: conversation, + messages: undefined, + version: undefined, }); }); @@ -470,10 +483,10 @@ describe('chatCommand', () => { 'gemini-conversation-1234567890.json', ); expect(mockExport).toHaveBeenCalledWith({ - history: mockHistory, messages: [], filePath: expectedPath, trajectories: {}, + history: mockHistory, }); expect(result).toEqual({ type: 'message', @@ -487,10 +500,10 @@ describe('chatCommand', () => { const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); expect(mockExport).toHaveBeenCalledWith({ - history: mockHistory, messages: [], filePath: expectedPath, trajectories: {}, + history: mockHistory, }); expect(result).toEqual({ type: 'message', @@ -504,10 +517,10 @@ describe('chatCommand', () => { const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); expect(mockExport).toHaveBeenCalledWith({ - history: mockHistory, messages: [], filePath: expectedPath, trajectories: {}, + history: mockHistory, }); expect(result).toEqual({ type: 'message', @@ -556,10 +569,10 @@ describe('chatCommand', () => { await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); expect(mockExport).toHaveBeenCalledWith({ - history: mockHistory, messages: [], filePath: expectedPath, trajectories: {}, + history: mockHistory, }); }); @@ -568,10 +581,10 @@ describe('chatCommand', () => { await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); expect(mockExport).toHaveBeenCalledWith({ - history: mockHistory, messages: [], filePath: expectedPath, trajectories: {}, + history: mockHistory, }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index cfb2be3961..8d83ab9bd9 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -140,9 +140,9 @@ const saveCommand: SlashCommand = { if (history.length > INITIAL_HISTORY_LENGTH) { const authType = config?.getContentGeneratorConfig()?.authType; const trajectories = await chat.getSubagentTrajectories(); - const messages = chat.getConversation()?.messages; + const messages = chat.getConversation()?.messages ?? []; await logger.saveCheckpoint( - { history, authType, trajectories, messages }, + { version: '2.0', authType, trajectories, messages }, tag, ); return { @@ -183,7 +183,7 @@ const resumeCheckpointCommand: SlashCommand = { const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); - const conversation = checkpoint.history; + const conversation = checkpoint.history ?? []; if (conversation.length === 0) { return { @@ -233,6 +233,8 @@ const resumeCheckpointCommand: SlashCommand = { type: 'load_history', history: uiHistory, clientHistory: conversation, + messages: checkpoint.messages, + version: checkpoint.version, }; }, completion: async (context, partialArg) => { @@ -330,8 +332,8 @@ const shareCommand: SlashCommand = { try { const trajectories = await chat.getSubagentTrajectories(); - const messages = chat.getConversation()?.messages; - await exportHistoryToFile({ history, filePath, trajectories, messages }); + const messages = chat.getConversation()?.messages ?? []; + await exportHistoryToFile({ messages, filePath, trajectories, history }); return { type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 6e880ed4bb..04ae67a758 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -549,7 +549,16 @@ export const useSlashCommandProcessor = ( } } case 'load_history': { - config?.getGeminiClient()?.setHistory(result.clientHistory); + const client = config?.getGeminiClient(); + if (result.version === '2.0' && client) { + await client.resumeChat( + [...result.clientHistory], + undefined, + result.messages, + ); + } else { + client?.setHistory(result.clientHistory); + } fullCommandContext.ui.clear(); result.history.forEach((item, index) => { fullCommandContext.ui.addItem(item, index); diff --git a/packages/cli/src/ui/utils/historyExportUtils.ts b/packages/cli/src/ui/utils/historyExportUtils.ts index 2c20bcb4af..f8d8b78b3a 100644 --- a/packages/cli/src/ui/utils/historyExportUtils.ts +++ b/packages/cli/src/ui/utils/historyExportUtils.ts @@ -7,9 +7,10 @@ import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { Content } from '@google/genai'; -import type { - ConversationRecord, - MessageRecord, +import { + type ConversationRecord, + type MessageRecord, + reconstructHistory, } from '@google/gemini-cli-core'; /** @@ -55,17 +56,21 @@ export function serializeHistoryToMarkdown( * Options for exporting chat history. */ export interface ExportHistoryOptions { - /** The standard history array used for model requests. */ - history: readonly Content[]; + /** + * Full message records which contain metadata like agentId for tool calls, + * providing the link between history and trajectories. + * This is the primary source of truth. + */ + messages: MessageRecord[]; /** The file path to export to. */ filePath: string; /** Optional subagent trajectories to include. */ trajectories?: Record; /** - * Optional full message records which contain metadata like agentId for tool calls, - * providing the link between history and trajectories. + * Optional standard history array used for model requests. + * If provided, it is used for Markdown export to avoid reconstruction. */ - messages?: MessageRecord[]; + history?: readonly Content[]; } /** @@ -74,17 +79,23 @@ export interface ExportHistoryOptions { export async function exportHistoryToFile( options: ExportHistoryOptions, ): Promise { - const { history, filePath, trajectories, messages } = options; + const { + messages, + filePath, + trajectories, + history: providedHistory, + } = options; const extension = path.extname(filePath).toLowerCase(); let content: string; if (extension === '.json') { - if (trajectories && Object.keys(trajectories).length > 0) { - content = JSON.stringify({ history, messages, trajectories }, null, 2); - } else { - content = JSON.stringify(history, null, 2); - } + content = JSON.stringify( + { version: '2.0', messages, trajectories }, + null, + 2, + ); } else if (extension === '.md') { + const history = providedHistory ?? reconstructHistory(messages); content = serializeHistoryToMarkdown(history); } else { throw new Error( diff --git a/packages/core/src/commands/types.ts b/packages/core/src/commands/types.ts index 62bda279af..32dd76a3a9 100644 --- a/packages/core/src/commands/types.ts +++ b/packages/core/src/commands/types.ts @@ -5,6 +5,8 @@ */ import type { Content, PartListUnion } from '@google/genai'; +import type { MessageRecord } from '../services/chatRecordingTypes.js'; + /** * The return type for a command action that results in scheduling a tool call. */ @@ -37,6 +39,8 @@ export interface LoadHistoryActionReturn { type: 'load_history'; history: HistoryType; clientHistory: readonly Content[]; // The history for the generative client + messages?: MessageRecord[]; + version?: '2.0'; } /** diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 302b89d7f0..2f081a1689 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -40,6 +40,7 @@ import { tokenLimit } from './tokenLimits.js'; import type { ChatRecordingService, ResumedSessionData, + MessageRecord, } from '../services/chatRecordingService.js'; import type { ContentGenerator } from './contentGenerator.js'; import { LoopDetectionService } from '../services/loopDetectionService.js'; @@ -337,8 +338,9 @@ export class GeminiClient { async resumeChat( history: Content[], resumedSessionData?: ResumedSessionData, + messages?: MessageRecord[], ): Promise { - this.chat = await this.startChat(history, resumedSessionData); + this.chat = await this.startChat(history, resumedSessionData, messages); this.updateTelemetryTokenCount(); } @@ -378,6 +380,7 @@ export class GeminiClient { async startChat( extraHistory?: Content[], resumedSessionData?: ResumedSessionData, + messages?: MessageRecord[], ): Promise { this.forceFullIdeContext = true; this.hasFailedCompressionAttempt = false; @@ -407,8 +410,9 @@ export class GeminiClient { toolRegistry.getFunctionDeclarations(modelId); return [{ functionDeclarations: toolDeclarations }]; }, + messages, ); - await chat.initialize(resumedSessionData, 'main'); + await chat.initialize(resumedSessionData, 'main', messages); this.contextManager = await initializeContextManager( this.config, chat, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index d22a93449d..1fe3dcc964 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -40,6 +40,7 @@ import { ChatRecordingService, type ResumedSessionData, type ConversationRecord, + type MessageRecord, } from '../services/chatRecordingService.js'; import { ContentRetryEvent, @@ -267,6 +268,7 @@ export class GeminiChat { private readonly chatRecordingService: ChatRecordingService; private lastPromptTokenCount: number; private callCounter = 0; + private initialMessages?: MessageRecord[]; agentHistory: AgentChatHistory; constructor( @@ -276,8 +278,10 @@ export class GeminiChat { history: Content[] = [], resumedSessionData?: ResumedSessionData, private readonly onModelChanged?: (modelId: string) => Promise, + messages?: MessageRecord[], ) { validateHistory(history); + this.initialMessages = messages; this.agentHistory = new AgentChatHistory(history); this.chatRecordingService = new ChatRecordingService(context); this.lastPromptTokenCount = estimateTokenCountSync( @@ -292,8 +296,13 @@ export class GeminiChat { async initialize( resumedSessionData?: ResumedSessionData, kind: 'main' | 'subagent' = 'main', + messages?: MessageRecord[], ) { + const messagesToUse = messages ?? this.initialMessages; await this.chatRecordingService.initialize(resumedSessionData, kind); + if (messagesToUse) { + this.chatRecordingService.resetMessages(messagesToUse); + } } setSystemInstruction(sysInstr: string) { diff --git a/packages/core/src/core/logger.test.ts b/packages/core/src/core/logger.test.ts index dd150aec87..f7e2b47041 100644 --- a/packages/core/src/core/logger.test.ts +++ b/packages/core/src/core/logger.test.ts @@ -437,7 +437,11 @@ describe('Logger', () => { }, ])('should save a checkpoint', async ({ tag, encodedTag }) => { await logger.saveCheckpoint( - { history: conversation, authType: AuthType.LOGIN_WITH_GOOGLE }, + { + history: conversation, + messages: [], + authType: AuthType.LOGIN_WITH_GOOGLE, + }, tag, ); const taggedFilePath = path.join( @@ -447,6 +451,7 @@ describe('Logger', () => { const fileContent = await fs.readFile(taggedFilePath, 'utf-8'); expect(JSON.parse(fileContent)).toEqual({ history: conversation, + messages: [], authType: AuthType.LOGIN_WITH_GOOGLE, }); }); @@ -462,7 +467,10 @@ describe('Logger', () => { .mockImplementation(() => {}); await expect( - uninitializedLogger.saveCheckpoint({ history: conversation }, 'tag'), + uninitializedLogger.saveCheckpoint( + { history: conversation, messages: [] }, + 'tag', + ), ).resolves.not.toThrow(); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Logger not initialized or checkpoint file path not set. Cannot save a checkpoint.', @@ -507,6 +515,7 @@ describe('Logger', () => { ...conversation, { role: 'user', parts: [{ text: 'hello' }] }, ], + messages: [], authType: AuthType.USE_GEMINI, }; const taggedFilePath = path.join( @@ -534,18 +543,18 @@ describe('Logger', () => { await fs.writeFile(taggedFilePath, JSON.stringify(conversation, null, 2)); const loaded = await logger.loadCheckpoint(tag); - expect(loaded).toEqual({ history: conversation }); + expect(loaded).toEqual({ history: conversation, messages: [] }); }); - it('should return an empty history if a tagged checkpoint file does not exist', async () => { + it('should return an empty message list if a tagged checkpoint file does not exist', async () => { const loaded = await logger.loadCheckpoint('nonexistent-tag'); - expect(loaded).toEqual({ history: [] }); + expect(loaded).toEqual({ messages: [] }); }); - it('should return an empty history if the checkpoint file does not exist', async () => { + it('should return an empty message list if the checkpoint file does not exist', async () => { await fs.unlink(TEST_CHECKPOINT_FILE_PATH); // Ensure it's gone const loaded = await logger.loadCheckpoint('missing'); - expect(loaded).toEqual({ history: [] }); + expect(loaded).toEqual({ messages: [] }); }); it('should return an empty history if the file contains invalid JSON', async () => { @@ -560,14 +569,14 @@ describe('Logger', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); const loadedCheckpoint = await logger.loadCheckpoint(tag); - expect(loadedCheckpoint).toEqual({ history: [] }); + expect(loadedCheckpoint).toEqual({ messages: [] }); expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to read or parse checkpoint file'), expect.any(Error), ); }); - it('should return an empty history if logger is not initialized', async () => { + it('should return an empty message list if logger is not initialized', async () => { const uninitializedLogger = new Logger( testSessionId, new Storage(process.cwd()), @@ -577,7 +586,7 @@ describe('Logger', () => { .spyOn(debugLogger, 'error') .mockImplementation(() => {}); const loadedCheckpoint = await uninitializedLogger.loadCheckpoint('tag'); - expect(loadedCheckpoint).toEqual({ history: [] }); + expect(loadedCheckpoint).toEqual({ messages: [] }); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', ); diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 81852ab4c2..30ed0e754f 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -16,6 +16,8 @@ import { type MessageRecord, } from '../services/chatRecordingService.js'; +import { reconstructHistory } from '../utils/history-reconstruction.js'; + const LOG_FILE_NAME = 'logs.json'; export enum MessageSenderType { @@ -31,10 +33,22 @@ export interface LogEntry { } export interface Checkpoint { - history: readonly Content[]; + /** + * The rich message records which are the source of truth for the session. + */ + messages: MessageRecord[]; + /** + * The version of the checkpoint format. + * Version 2.0 uses messages as the source of truth and reconstructs history. + */ + version?: '2.0'; + /** + * The standard history array used for model requests. + * Only included in legacy checkpoints (pre-2.0). + */ + history?: readonly Content[]; authType?: AuthType; trajectories?: Record; - messages?: MessageRecord[]; } // This regex matches any character that is NOT a letter (a-z, A-Z), @@ -353,7 +367,7 @@ export class Logger { debugLogger.error( 'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.', ); - return { history: [] }; + return { messages: [] }; } const path = await this._getCheckpointPath(tag); @@ -365,34 +379,46 @@ export class Logger { // Handle legacy format (just an array of Content) if (Array.isArray(parsedContent)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return { history: parsedContent as Content[] }; + return { history: parsedContent as Content[], messages: [] }; } - if ( - typeof parsedContent === 'object' && - parsedContent !== null && - 'history' in parsedContent - ) { + if (typeof parsedContent === 'object' && parsedContent !== null) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return parsedContent as Checkpoint; + const checkpoint = parsedContent as Checkpoint; + + // Version 2.0: Reconstruct history from messages + if (checkpoint.version === '2.0' && checkpoint.messages) { + return { + ...checkpoint, + history: reconstructHistory(checkpoint.messages), + }; + } + + // Legacy Object format (pre-2.0, had history but maybe not messages) + if (checkpoint.history) { + return { + ...checkpoint, + messages: checkpoint.messages ?? [], + }; + } } debugLogger.warn( `Checkpoint file at ${path} has an unknown format. Returning empty checkpoint.`, ); - return { history: [] }; + return { messages: [] }; } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { // This is okay, it just means the checkpoint doesn't exist in either format. - return { history: [] }; + return { messages: [] }; } debugLogger.error( `Failed to read or parse checkpoint file ${path}:`, error, ); - return { history: [] }; + return { messages: [] }; } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fc1892139..44c2d1a03d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,6 +128,7 @@ export * from './utils/channel.js'; export * from './utils/constants.js'; export * from './utils/sessionUtils.js'; export * from './utils/cache.js'; +export * from './utils/history-reconstruction.js'; export * from './utils/markdownUtils.js'; // Export services diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 34948951d1..27320e9a47 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -673,6 +673,16 @@ export class ChatRecordingService { return this.conversationFile; } + /** + * Resets the current message history. Used during session resumption. + */ + resetMessages(messages: MessageRecord[]): void { + if (!this.cachedConversation) return; + this.cachedConversation.messages = [...messages]; + // We don't append to the log here, as we are resetting the in-memory state + // to match a loaded checkpoint. + } + /** * Deletes a session file by sessionId, filename, or basename. * Derives an 8-character shortId to find and delete all associated files diff --git a/packages/core/src/utils/history-reconstruction.ts b/packages/core/src/utils/history-reconstruction.ts new file mode 100644 index 0000000000..e7e161a8f3 --- /dev/null +++ b/packages/core/src/utils/history-reconstruction.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content, Part } from '@google/genai'; +import type { MessageRecord } from '../services/chatRecordingTypes.js'; + +/** + * Reconstructs the model-compatible history array from rich message records. + * This allows us to treat MessageRecord as the source of truth and generate + * the API-specific Content array on-the-fly. + */ +export function reconstructHistory(messages: MessageRecord[]): Content[] { + const history: Content[] = []; + + for (const msg of messages) { + const parts: Part[] = []; + if (Array.isArray(msg.content)) { + // Map PartUnion to Part + for (const p of msg.content) { + if (typeof p === 'string') { + parts.push({ text: p }); + } else { + parts.push(p); + } + } + } else if (typeof msg.content === 'string') { + parts.push({ text: msg.content }); + } + + if (msg.type === 'user') { + history.push({ role: 'user', parts }); + } else if (msg.type === 'gemini') { + // 1. Add model-generated tool calls if present + if (msg.toolCalls && msg.toolCalls.length > 0) { + msg.toolCalls.forEach((tc) => { + parts.push({ + functionCall: { + name: tc.name, + args: tc.args, + id: tc.id, + }, + }); + }); + } + + history.push({ role: 'model', parts }); + + // 2. Add the tool responses as a following user turn if results exist + const toolResponseParts: Part[] = []; + if (msg.toolCalls) { + for (const tc of msg.toolCalls) { + if (tc.result) { + if (Array.isArray(tc.result)) { + for (const r of tc.result) { + if (typeof r === 'string') { + toolResponseParts.push({ text: r }); + } else { + toolResponseParts.push(r); + } + } + } else if (typeof tc.result === 'string') { + toolResponseParts.push({ text: tc.result }); + } else { + toolResponseParts.push(tc.result); + } + } + } + } + + if (toolResponseParts.length > 0) { + history.push({ role: 'user', parts: toolResponseParts }); + } + } + } + + return history; +}