mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 05:42:54 -07:00
feat: implement unified session bundle format (v2.0) and history reconstruction
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, ConversationRecord>;
|
||||
/**
|
||||
* 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<void> {
|
||||
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(
|
||||
|
||||
@@ -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<HistoryType = unknown> {
|
||||
type: 'load_history';
|
||||
history: HistoryType;
|
||||
clientHistory: readonly Content[]; // The history for the generative client
|
||||
messages?: MessageRecord[];
|
||||
version?: '2.0';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void> {
|
||||
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<GeminiChat> {
|
||||
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,
|
||||
|
||||
@@ -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<Tool[]>,
|
||||
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) {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
|
||||
@@ -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<string, ConversationRecord>;
|
||||
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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user