mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
refactor(core): move session conversion logic to core (#19972)
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
|||||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||||
import type { LoadedSettings } from './config/settings.js';
|
import type { LoadedSettings } from './config/settings.js';
|
||||||
import {
|
import {
|
||||||
|
convertSessionToClientHistory,
|
||||||
GeminiEventType,
|
GeminiEventType,
|
||||||
FatalInputError,
|
FatalInputError,
|
||||||
promptIdContext,
|
promptIdContext,
|
||||||
@@ -35,7 +36,6 @@ import type { Content, Part } from '@google/genai';
|
|||||||
import readline from 'node:readline';
|
import readline from 'node:readline';
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
|
|
||||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
|
||||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||||
@@ -220,9 +220,9 @@ export async function runNonInteractive({
|
|||||||
// Initialize chat. Resume if resume data is passed.
|
// Initialize chat. Resume if resume data is passed.
|
||||||
if (resumedSessionData) {
|
if (resumedSessionData) {
|
||||||
await geminiClient.resumeChat(
|
await geminiClient.resumeChat(
|
||||||
convertSessionToHistoryFormats(
|
convertSessionToClientHistory(
|
||||||
resumedSessionData.conversation.messages,
|
resumedSessionData.conversation.messages,
|
||||||
).clientHistory,
|
),
|
||||||
resumedSessionData,
|
resumedSessionData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
RewindEvent,
|
RewindEvent,
|
||||||
type ChatRecordingService,
|
type ChatRecordingService,
|
||||||
type GeminiClient,
|
type GeminiClient,
|
||||||
|
convertSessionToClientHistory,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,9 +55,8 @@ async function rewindConversation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert to UI and Client formats
|
// Convert to UI and Client formats
|
||||||
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
|
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
|
||||||
conversation.messages,
|
const clientHistory = convertSessionToClientHistory(conversation.messages);
|
||||||
);
|
|
||||||
|
|
||||||
client.setHistory(clientHistory as Content[]);
|
client.setHistory(clientHistory as Content[]);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import {
|
|||||||
type MessageRecord,
|
type MessageRecord,
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { coreEvents } from '@google/gemini-cli-core';
|
import {
|
||||||
|
coreEvents,
|
||||||
|
convertSessionToClientHistory,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
// Mock modules
|
// Mock modules
|
||||||
vi.mock('fs/promises');
|
vi.mock('fs/promises');
|
||||||
@@ -157,7 +160,7 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
it('should convert empty messages array', () => {
|
it('should convert empty messages array', () => {
|
||||||
const result = convertSessionToHistoryFormats([]);
|
const result = convertSessionToHistoryFormats([]);
|
||||||
expect(result.uiHistory).toEqual([]);
|
expect(result.uiHistory).toEqual([]);
|
||||||
expect(result.clientHistory).toEqual([]);
|
expect(convertSessionToClientHistory([])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should convert basic user and model messages', () => {
|
it('should convert basic user and model messages', () => {
|
||||||
@@ -175,12 +178,13 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
text: 'Hi there',
|
text: 'Hi there',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.clientHistory).toHaveLength(2);
|
const clientHistory = convertSessionToClientHistory(messages);
|
||||||
expect(result.clientHistory[0]).toEqual({
|
expect(clientHistory).toHaveLength(2);
|
||||||
|
expect(clientHistory[0]).toEqual({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [{ text: 'Hello' }],
|
parts: [{ text: 'Hello' }],
|
||||||
});
|
});
|
||||||
expect(result.clientHistory[1]).toEqual({
|
expect(clientHistory[1]).toEqual({
|
||||||
role: 'model',
|
role: 'model',
|
||||||
parts: [{ text: 'Hi there' }],
|
parts: [{ text: 'Hi there' }],
|
||||||
});
|
});
|
||||||
@@ -203,8 +207,9 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
text: 'User input',
|
text: 'User input',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.clientHistory).toHaveLength(1);
|
const clientHistory = convertSessionToClientHistory(messages);
|
||||||
expect(result.clientHistory[0]).toEqual({
|
expect(clientHistory).toHaveLength(1);
|
||||||
|
expect(clientHistory[0]).toEqual({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [{ text: 'Expanded content' }],
|
parts: [{ text: 'Expanded content' }],
|
||||||
});
|
});
|
||||||
@@ -225,7 +230,7 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
text: 'Help text',
|
text: 'Help text',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.clientHistory).toHaveLength(0);
|
expect(convertSessionToClientHistory(messages)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle tool calls and responses', () => {
|
it('should handle tool calls and responses', () => {
|
||||||
@@ -264,12 +269,13 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
const clientHistory = convertSessionToClientHistory(messages);
|
||||||
expect(result.clientHistory[0]).toEqual({
|
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
||||||
|
expect(clientHistory[0]).toEqual({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [{ text: 'What time is it?' }],
|
parts: [{ text: 'What time is it?' }],
|
||||||
});
|
});
|
||||||
expect(result.clientHistory[1]).toEqual({
|
expect(clientHistory[1]).toEqual({
|
||||||
role: 'model',
|
role: 'model',
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
@@ -281,7 +287,7 @@ describe('convertSessionToHistoryFormats', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(result.clientHistory[2]).toEqual({
|
expect(clientHistory[2]).toEqual({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import type {
|
|||||||
ConversationRecord,
|
ConversationRecord,
|
||||||
ResumedSessionData,
|
ResumedSessionData,
|
||||||
} from '@google/gemini-cli-core';
|
} 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 type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||||
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
|
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
|
||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
@@ -78,7 +81,7 @@ export const useSessionBrowser = (
|
|||||||
);
|
);
|
||||||
await onLoadHistory(
|
await onLoadHistory(
|
||||||
historyData.uiHistory,
|
historyData.uiHistory,
|
||||||
historyData.clientHistory,
|
convertSessionToClientHistory(conversation.messages),
|
||||||
resumedSessionData,
|
resumedSessionData,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
coreEvents,
|
coreEvents,
|
||||||
type Config,
|
type Config,
|
||||||
type ResumedSessionData,
|
type ResumedSessionData,
|
||||||
|
convertSessionToClientHistory,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part } from '@google/genai';
|
import type { Part } from '@google/genai';
|
||||||
import type { HistoryItemWithoutId } from '../types.js';
|
import type { HistoryItemWithoutId } from '../types.js';
|
||||||
@@ -113,7 +114,7 @@ export function useSessionResume({
|
|||||||
);
|
);
|
||||||
void loadHistoryForResume(
|
void loadHistoryForResume(
|
||||||
historyData.uiHistory,
|
historyData.uiHistory,
|
||||||
historyData.clientHistory,
|
convertSessionToClientHistory(resumedSessionData.conversation.messages),
|
||||||
resumedSessionData,
|
resumedSessionData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
||||||
import type { Part } from '@google/genai';
|
|
||||||
import { MessageType, type HistoryItemWithoutId } from '../ui/types.js';
|
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(
|
export function convertSessionToHistoryFormats(
|
||||||
messages: ConversationRecord['messages'],
|
messages: ConversationRecord['messages'],
|
||||||
): {
|
): {
|
||||||
uiHistory: HistoryItemWithoutId[];
|
uiHistory: HistoryItemWithoutId[];
|
||||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
|
||||||
} {
|
} {
|
||||||
const uiHistory: HistoryItemWithoutId[] = [];
|
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 {
|
return {
|
||||||
uiHistory,
|
uiHistory,
|
||||||
clientHistory,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
SessionSelector,
|
SessionSelector,
|
||||||
convertSessionToHistoryFormats,
|
convertSessionToHistoryFormats,
|
||||||
} from '../utils/sessionUtils.js';
|
} from '../utils/sessionUtils.js';
|
||||||
|
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
|
||||||
import type { LoadedSettings } from '../config/settings.js';
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
|
|
||||||
vi.mock('../config/config.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<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
convertSessionToClientHistory: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('GeminiAgent Session Resume', () => {
|
describe('GeminiAgent Session Resume', () => {
|
||||||
let mockConfig: Mocked<Config>;
|
let mockConfig: Mocked<Config>;
|
||||||
let mockSettings: Mocked<LoadedSettings>;
|
let mockSettings: Mocked<LoadedSettings>;
|
||||||
@@ -142,9 +152,11 @@ describe('GeminiAgent Session Resume', () => {
|
|||||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||||
];
|
];
|
||||||
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
|
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
|
||||||
clientHistory: mockClientHistory,
|
|
||||||
uiHistory: [],
|
uiHistory: [],
|
||||||
});
|
});
|
||||||
|
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
|
||||||
|
mockClientHistory,
|
||||||
|
);
|
||||||
|
|
||||||
const response = await agent.loadSession({
|
const response = await agent.loadSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
partListUnionToString,
|
partListUnionToString,
|
||||||
LlmRole,
|
LlmRole,
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
|
convertSessionToClientHistory,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import * as acp from '@agentclientprotocol/sdk';
|
import * as acp from '@agentclientprotocol/sdk';
|
||||||
import { AcpFileSystemService } from './fileSystemService.js';
|
import { AcpFileSystemService } from './fileSystemService.js';
|
||||||
@@ -53,10 +54,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { CliArgs } from '../config/config.js';
|
import type { CliArgs } from '../config/config.js';
|
||||||
import { loadCliConfig } from '../config/config.js';
|
import { loadCliConfig } from '../config/config.js';
|
||||||
import { runExitCleanup } from '../utils/cleanup.js';
|
import { runExitCleanup } from '../utils/cleanup.js';
|
||||||
import {
|
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||||
SessionSelector,
|
|
||||||
convertSessionToHistoryFormats,
|
|
||||||
} from '../utils/sessionUtils.js';
|
|
||||||
|
|
||||||
export async function runZedIntegration(
|
export async function runZedIntegration(
|
||||||
config: Config,
|
config: Config,
|
||||||
@@ -258,9 +256,7 @@ export class GeminiAgent {
|
|||||||
config.setFileSystemService(acpFileSystemService);
|
config.setFileSystemService(acpFileSystemService);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { clientHistory } = convertSessionToHistoryFormats(
|
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||||
sessionData.messages,
|
|
||||||
);
|
|
||||||
|
|
||||||
const geminiClient = config.getGeminiClient();
|
const geminiClient = config.getGeminiClient();
|
||||||
await geminiClient.initialize();
|
await geminiClient.initialize();
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export * from './utils/secure-browser-launcher.js';
|
|||||||
export * from './utils/apiConversionUtils.js';
|
export * from './utils/apiConversionUtils.js';
|
||||||
export * from './utils/channel.js';
|
export * from './utils/channel.js';
|
||||||
export * from './utils/constants.js';
|
export * from './utils/constants.js';
|
||||||
|
export * from './utils/sessionUtils.js';
|
||||||
|
|
||||||
// Export services
|
// Export services
|
||||||
export * from './services/fileDiscoveryService.js';
|
export * from './services/fileDiscoveryService.js';
|
||||||
|
|||||||
@@ -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' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user