feat: implement unified session bundle format (v2.0) and history reconstruction

This commit is contained in:
Aishanee Shah
2026-05-13 15:16:59 +00:00
parent 101f6efcae
commit 50b279f0d9
14 changed files with 232 additions and 54 deletions
@@ -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];
+2 -2
View File
@@ -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,
});
});
});
+7 -5
View File
@@ -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);
+25 -14
View File
@@ -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(