refactor(core): move session conversion logic to core (#19972)

This commit is contained in:
Abhi
2026-02-22 20:18:07 -05:00
committed by GitHub
parent c537fd5aec
commit 621ddbe744
11 changed files with 328 additions and 142 deletions
@@ -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' } },
],
},
]);
});
});
+131
View File
@@ -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;
}