From 621ddbe74451157681e37075c2182ca1995f39f9 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:18:07 -0500 Subject: [PATCH] refactor(core): move session conversion logic to core (#19972) --- packages/cli/src/nonInteractiveCli.ts | 6 +- .../cli/src/ui/commands/rewindCommand.tsx | 6 +- .../src/ui/hooks/useSessionBrowser.test.ts | 30 ++-- .../cli/src/ui/hooks/useSessionBrowser.ts | 7 +- packages/cli/src/ui/hooks/useSessionResume.ts | 3 +- packages/cli/src/utils/sessionUtils.ts | 114 +------------- .../cli/src/zed-integration/acpResume.test.ts | 14 +- .../cli/src/zed-integration/zedIntegration.ts | 10 +- packages/core/src/index.ts | 1 + packages/core/src/utils/sessionUtils.test.ts | 148 ++++++++++++++++++ packages/core/src/utils/sessionUtils.ts | 131 ++++++++++++++++ 11 files changed, 328 insertions(+), 142 deletions(-) create mode 100644 packages/core/src/utils/sessionUtils.test.ts create mode 100644 packages/core/src/utils/sessionUtils.ts diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 44af6bc81e..c2cab72353 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -13,6 +13,7 @@ import type { import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { + convertSessionToClientHistory, GeminiEventType, FatalInputError, promptIdContext, @@ -35,7 +36,6 @@ import type { Content, Part } from '@google/genai'; import readline from 'node:readline'; import stripAnsi from 'strip-ansi'; -import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js'; import { handleSlashCommand } from './nonInteractiveCliCommands.js'; import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; @@ -220,9 +220,9 @@ export async function runNonInteractive({ // Initialize chat. Resume if resume data is passed. if (resumedSessionData) { await geminiClient.resumeChat( - convertSessionToHistoryFormats( + convertSessionToClientHistory( resumedSessionData.conversation.messages, - ).clientHistory, + ), resumedSessionData, ); } diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index d405172661..c4af3e845d 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -23,6 +23,7 @@ import { RewindEvent, type ChatRecordingService, type GeminiClient, + convertSessionToClientHistory, } from '@google/gemini-cli-core'; /** @@ -54,9 +55,8 @@ async function rewindConversation( } // Convert to UI and Client formats - const { uiHistory, clientHistory } = convertSessionToHistoryFormats( - conversation.messages, - ); + const { uiHistory } = convertSessionToHistoryFormats(conversation.messages); + const clientHistory = convertSessionToClientHistory(conversation.messages); client.setHistory(clientHistory as Content[]); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index d6326bdfef..ceff3e9c8c 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -20,7 +20,10 @@ import { type MessageRecord, CoreToolCallStatus, } from '@google/gemini-cli-core'; -import { coreEvents } from '@google/gemini-cli-core'; +import { + coreEvents, + convertSessionToClientHistory, +} from '@google/gemini-cli-core'; // Mock modules vi.mock('fs/promises'); @@ -157,7 +160,7 @@ describe('convertSessionToHistoryFormats', () => { it('should convert empty messages array', () => { const result = convertSessionToHistoryFormats([]); expect(result.uiHistory).toEqual([]); - expect(result.clientHistory).toEqual([]); + expect(convertSessionToClientHistory([])).toEqual([]); }); it('should convert basic user and model messages', () => { @@ -175,12 +178,13 @@ describe('convertSessionToHistoryFormats', () => { text: 'Hi there', }); - expect(result.clientHistory).toHaveLength(2); - expect(result.clientHistory[0]).toEqual({ + const clientHistory = convertSessionToClientHistory(messages); + expect(clientHistory).toHaveLength(2); + expect(clientHistory[0]).toEqual({ role: 'user', parts: [{ text: 'Hello' }], }); - expect(result.clientHistory[1]).toEqual({ + expect(clientHistory[1]).toEqual({ role: 'model', parts: [{ text: 'Hi there' }], }); @@ -203,8 +207,9 @@ describe('convertSessionToHistoryFormats', () => { text: 'User input', }); - expect(result.clientHistory).toHaveLength(1); - expect(result.clientHistory[0]).toEqual({ + const clientHistory = convertSessionToClientHistory(messages); + expect(clientHistory).toHaveLength(1); + expect(clientHistory[0]).toEqual({ role: 'user', parts: [{ text: 'Expanded content' }], }); @@ -225,7 +230,7 @@ describe('convertSessionToHistoryFormats', () => { text: 'Help text', }); - expect(result.clientHistory).toHaveLength(0); + expect(convertSessionToClientHistory(messages)).toHaveLength(0); }); it('should handle tool calls and responses', () => { @@ -264,12 +269,13 @@ describe('convertSessionToHistoryFormats', () => { ], }); - expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response) - expect(result.clientHistory[0]).toEqual({ + 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(result.clientHistory[1]).toEqual({ + expect(clientHistory[1]).toEqual({ role: 'model', parts: [ { @@ -281,7 +287,7 @@ describe('convertSessionToHistoryFormats', () => { }, ], }); - expect(result.clientHistory[2]).toEqual({ + expect(clientHistory[2]).toEqual({ role: 'user', parts: [ { diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 79baacbee1..9c6d05b322 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -13,7 +13,10 @@ import type { ConversationRecord, ResumedSessionData, } from '@google/gemini-cli-core'; -import { coreEvents } from '@google/gemini-cli-core'; +import { + coreEvents, + convertSessionToClientHistory, +} from '@google/gemini-cli-core'; import type { SessionInfo } from '../../utils/sessionUtils.js'; import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js'; import type { Part } from '@google/genai'; @@ -78,7 +81,7 @@ export const useSessionBrowser = ( ); await onLoadHistory( historyData.uiHistory, - historyData.clientHistory, + convertSessionToClientHistory(conversation.messages), resumedSessionData, ); } catch (error) { diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 9889c4bd12..055686773b 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -9,6 +9,7 @@ import { coreEvents, type Config, type ResumedSessionData, + convertSessionToClientHistory, } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import type { HistoryItemWithoutId } from '../types.js'; @@ -113,7 +114,7 @@ export function useSessionResume({ ); void loadHistoryForResume( historyData.uiHistory, - historyData.clientHistory, + convertSessionToClientHistory(resumedSessionData.conversation.messages), resumedSessionData, ); } diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 039c1232a2..7bf05fe94a 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -16,7 +16,6 @@ import { import * as fs from 'node:fs/promises'; import path from 'node:path'; import { stripUnsafeCharacters } from '../ui/utils/textUtils.js'; -import type { Part } from '@google/genai'; import { MessageType, type HistoryItemWithoutId } from '../ui/types.js'; /** @@ -526,13 +525,12 @@ export class SessionSelector { } /** - * Converts session/conversation data into UI history and Gemini client history formats. + * Converts session/conversation data into UI history format. */ export function convertSessionToHistoryFormats( messages: ConversationRecord['messages'], ): { uiHistory: HistoryItemWithoutId[]; - clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>; } { const uiHistory: HistoryItemWithoutId[] = []; @@ -599,117 +597,7 @@ export function convertSessionToHistoryFormats( } } - // Convert to Gemini client history format - const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = []; - - for (const msg of messages) { - // Skip system/error messages and user slash commands - if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { - continue; - } - - if (msg.type === 'user') { - // Skip user slash commands - const contentString = partListUnionToString(msg.content); - if ( - contentString.trim().startsWith('/') || - contentString.trim().startsWith('?') - ) { - continue; - } - - // Add regular user message - clientHistory.push({ - role: 'user', - parts: Array.isArray(msg.content) - ? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (msg.content as Part[]) - : [{ text: contentString }], - }); - } else if (msg.type === 'gemini') { - // Handle Gemini messages with potential tool calls - const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; - - if (hasToolCalls) { - // Create model message with function calls - const modelParts: Part[] = []; - - // Add text content if present - const contentString = partListUnionToString(msg.content); - if (msg.content && contentString.trim()) { - modelParts.push({ text: contentString }); - } - - // Add function calls - for (const toolCall of msg.toolCalls!) { - modelParts.push({ - functionCall: { - name: toolCall.name, - args: toolCall.args, - ...(toolCall.id && { id: toolCall.id }), - }, - }); - } - - clientHistory.push({ - role: 'model', - parts: modelParts, - }); - - // Create single function response message with all tool call responses - const functionResponseParts: Part[] = []; - for (const toolCall of msg.toolCalls!) { - if (toolCall.result) { - // Convert PartListUnion result to function response format - let responseData: Part; - - if (typeof toolCall.result === 'string') { - responseData = { - functionResponse: { - id: toolCall.id, - name: toolCall.name, - response: { - output: toolCall.result, - }, - }, - }; - } else if (Array.isArray(toolCall.result)) { - // toolCall.result is an array containing properly formatted - // function responses - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - functionResponseParts.push(...(toolCall.result as Part[])); - continue; - } else { - // Fallback for non-array results - responseData = toolCall.result; - } - - functionResponseParts.push(responseData); - } - } - - // Only add user message if we have function responses - if (functionResponseParts.length > 0) { - clientHistory.push({ - role: 'user', - parts: functionResponseParts, - }); - } - } else { - // Regular Gemini message without tool calls - const contentString = partListUnionToString(msg.content); - if (msg.content && contentString.trim()) { - clientHistory.push({ - role: 'model', - parts: [{ text: contentString }], - }); - } - } - } - } - return { uiHistory, - clientHistory, }; } diff --git a/packages/cli/src/zed-integration/acpResume.test.ts b/packages/cli/src/zed-integration/acpResume.test.ts index 869c27bf52..f814a9e586 100644 --- a/packages/cli/src/zed-integration/acpResume.test.ts +++ b/packages/cli/src/zed-integration/acpResume.test.ts @@ -26,6 +26,7 @@ import { SessionSelector, convertSessionToHistoryFormats, } from '../utils/sessionUtils.js'; +import { convertSessionToClientHistory } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../config/settings.js'; vi.mock('../config/config.js', () => ({ @@ -42,6 +43,15 @@ vi.mock('../utils/sessionUtils.js', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + convertSessionToClientHistory: vi.fn(), + }; +}); + describe('GeminiAgent Session Resume', () => { let mockConfig: Mocked; let mockSettings: Mocked; @@ -142,9 +152,11 @@ describe('GeminiAgent Session Resume', () => { { role: 'model', parts: [{ text: 'Hi there' }] }, ]; (convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({ - clientHistory: mockClientHistory, uiHistory: [], }); + (convertSessionToClientHistory as unknown as Mock).mockReturnValue( + mockClientHistory, + ); const response = await agent.loadSession({ sessionId, diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 6f18cab7d6..1dce8d5e6d 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -37,6 +37,7 @@ import { partListUnionToString, LlmRole, ApprovalMode, + convertSessionToClientHistory, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -53,10 +54,7 @@ import { randomUUID } from 'node:crypto'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { runExitCleanup } from '../utils/cleanup.js'; -import { - SessionSelector, - convertSessionToHistoryFormats, -} from '../utils/sessionUtils.js'; +import { SessionSelector } from '../utils/sessionUtils.js'; export async function runZedIntegration( config: Config, @@ -258,9 +256,7 @@ export class GeminiAgent { config.setFileSystemService(acpFileSystemService); } - const { clientHistory } = convertSessionToHistoryFormats( - sessionData.messages, - ); + const clientHistory = convertSessionToClientHistory(sessionData.messages); const geminiClient = config.getGeminiClient(); await geminiClient.initialize(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b05b0b6fb..8b8d0bd34c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -107,6 +107,7 @@ export * from './utils/secure-browser-launcher.js'; export * from './utils/apiConversionUtils.js'; export * from './utils/channel.js'; export * from './utils/constants.js'; +export * from './utils/sessionUtils.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/utils/sessionUtils.test.ts b/packages/core/src/utils/sessionUtils.test.ts new file mode 100644 index 0000000000..35f9462c11 --- /dev/null +++ b/packages/core/src/utils/sessionUtils.test.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect } from 'vitest'; +import { convertSessionToClientHistory } from './sessionUtils.js'; +import { type ConversationRecord } from '../services/chatRecordingService.js'; +import { CoreToolCallStatus } from '../scheduler/types.js'; + +describe('convertSessionToClientHistory', () => { + it('should convert a simple conversation without tool calls', () => { + const messages: ConversationRecord['messages'] = [ + { + id: '1', + type: 'user', + timestamp: '2024-01-01T10:00:00Z', + content: 'Hello', + }, + { + id: '2', + type: 'gemini', + timestamp: '2024-01-01T10:01:00Z', + content: 'Hi there', + }, + ]; + + const history = convertSessionToClientHistory(messages); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]); + }); + + it('should ignore info, error, and slash commands', () => { + const messages: ConversationRecord['messages'] = [ + { + id: '1', + type: 'info', + timestamp: '2024-01-01T10:00:00Z', + content: 'System info', + }, + { + id: '2', + type: 'user', + timestamp: '2024-01-01T10:01:00Z', + content: '/clear', + }, + { + id: '3', + type: 'user', + timestamp: '2024-01-01T10:02:00Z', + content: '?help', + }, + { + id: '4', + type: 'user', + timestamp: '2024-01-01T10:03:00Z', + content: 'Actual query', + }, + ]; + + const history = convertSessionToClientHistory(messages); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'Actual query' }] }, + ]); + }); + + it('should correctly map tool calls and their responses', () => { + const messages: ConversationRecord['messages'] = [ + { + id: 'msg1', + type: 'user', + timestamp: '2024-01-01T10:00:00Z', + content: 'List files', + }, + { + id: 'msg2', + type: 'gemini', + timestamp: '2024-01-01T10:01:00Z', + content: 'Let me check.', + toolCalls: [ + { + id: 'call123', + name: 'ls', + args: { dir: '.' }, + status: CoreToolCallStatus.Success, + timestamp: '2024-01-01T10:01:05Z', + result: 'file.txt', + }, + ], + }, + ]; + + const history = convertSessionToClientHistory(messages); + + expect(history).toEqual([ + { role: 'user', parts: [{ text: 'List files' }] }, + { + role: 'model', + parts: [ + { text: 'Let me check.' }, + { functionCall: { name: 'ls', args: { dir: '.' }, id: 'call123' } }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call123', + name: 'ls', + response: { output: 'file.txt' }, + }, + }, + ], + }, + ]); + }); + + it('should preserve multi-modal parts (inlineData)', () => { + const messages: ConversationRecord['messages'] = [ + { + id: 'msg1', + type: 'user', + timestamp: '2024-01-01T10:00:00Z', + content: [ + { text: 'Look at this image' }, + { inlineData: { mimeType: 'image/png', data: 'base64data' } }, + ], + }, + ]; + + const history = convertSessionToClientHistory(messages); + + expect(history).toEqual([ + { + role: 'user', + parts: [ + { text: 'Look at this image' }, + { inlineData: { mimeType: 'image/png', data: 'base64data' } }, + ], + }, + ]); + }); +}); diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts new file mode 100644 index 0000000000..b20c853ff7 --- /dev/null +++ b/packages/core/src/utils/sessionUtils.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type Part, type PartListUnion } from '@google/genai'; +import { type ConversationRecord } from '../services/chatRecordingService.js'; +import { partListUnionToString } from '../core/geminiRequest.js'; + +/** + * Converts a PartListUnion into a normalized array of Part objects. + * This handles converting raw strings into { text: string } parts. + */ +function ensurePartArray(content: PartListUnion): Part[] { + if (Array.isArray(content)) { + return content.map((part) => + typeof part === 'string' ? { text: part } : part, + ); + } + if (typeof content === 'string') { + return [{ text: content }]; + } + return [content]; +} + +/** + * Converts session/conversation data into Gemini client history formats. + */ +export function convertSessionToClientHistory( + messages: ConversationRecord['messages'], +): Array<{ role: 'user' | 'model'; parts: Part[] }> { + const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = []; + + for (const msg of messages) { + if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { + continue; + } + + if (msg.type === 'user') { + const contentString = partListUnionToString(msg.content); + if ( + contentString.trim().startsWith('/') || + contentString.trim().startsWith('?') + ) { + continue; + } + + clientHistory.push({ + role: 'user', + parts: ensurePartArray(msg.content), + }); + } else if (msg.type === 'gemini') { + const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; + + if (hasToolCalls) { + const modelParts: Part[] = []; + + // TODO: Revisit if we should preserve more than just Part metadata (e.g. thoughtSignatures) + // currently those are only required within an active loop turn which resume clears + // by forcing a new user text prompt. + + // Preserve original parts to maintain multimodal integrity + if (msg.content) { + modelParts.push(...ensurePartArray(msg.content)); + } + + for (const toolCall of msg.toolCalls!) { + modelParts.push({ + functionCall: { + name: toolCall.name, + args: toolCall.args, + ...(toolCall.id && { id: toolCall.id }), + }, + }); + } + + clientHistory.push({ + role: 'model', + parts: modelParts, + }); + + const functionResponseParts: Part[] = []; + for (const toolCall of msg.toolCalls!) { + if (toolCall.result) { + let responseData: Part; + + if (typeof toolCall.result === 'string') { + responseData = { + functionResponse: { + id: toolCall.id, + name: toolCall.name, + response: { + output: toolCall.result, + }, + }, + }; + } else if (Array.isArray(toolCall.result)) { + functionResponseParts.push(...ensurePartArray(toolCall.result)); + continue; + } else { + responseData = toolCall.result; + } + + functionResponseParts.push(responseData); + } + } + + if (functionResponseParts.length > 0) { + clientHistory.push({ + role: 'user', + parts: functionResponseParts, + }); + } + } else { + if (msg.content) { + const parts = ensurePartArray(msg.content); + + if (parts.length > 0) { + clientHistory.push({ + role: 'model', + parts, + }); + } + } + } + } + } + + return clientHistory; +}