feat: switch to Version 2.0 session bundle format (Stage 3)

This commit is contained in:
Aishanee Shah
2026-05-14 18:38:45 +00:00
parent 216a9d5535
commit 5c44ed893e
3 changed files with 464 additions and 509 deletions

View File

@@ -21,7 +21,7 @@ import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
@@ -90,6 +90,7 @@ describe('chatCommand', () => {
saveCheckpoint: mockSaveCheckpoint,
loadCheckpoint: mockLoadCheckpoint,
deleteCheckpoint: mockDeleteCheckpoint,
checkpointExists: vi.fn().mockResolvedValue(false),
initialize: vi.fn().mockResolvedValue(undefined),
},
},
@@ -124,8 +125,8 @@ describe('chatCommand', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.readdir.mockResolvedValue(fakeFiles as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.stat.mockImplementation(async (path: any): Promise<Stats> => {
if (path.endsWith('test1.json')) {
mockFs.stat.mockImplementation(async (filePath: any): Promise<Stats> => {
if (filePath.endsWith('test1.json')) {
return { mtime: date1 } as Stats;
}
return { mtime: date2 } as Stats;
@@ -133,30 +134,29 @@ describe('chatCommand', () => {
await listCommand?.action?.(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: 'chat_list',
chats: [
{
name: 'test1',
mtime: date1.toISOString(),
},
{
name: 'test2',
mtime: date2.toISOString(),
},
],
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'chat_list',
chats: [
{
name: 'test2',
mtime: date2.toISOString(),
},
{
name: 'test1',
mtime: date1.toISOString(),
},
],
}),
);
});
});
describe('save subcommand', () => {
let saveCommand: SlashCommand;
const tag = 'my-tag';
let mockCheckpointExists: ReturnType<typeof vi.fn>;
beforeEach(() => {
saveCommand = getSubCommand('save');
mockCheckpointExists = vi.fn().mockResolvedValue(false);
mockContext.services.logger.checkpointExists = mockCheckpointExists;
});
it('should return an error if tag is missing', async () => {
@@ -195,7 +195,7 @@ describe('chatCommand', () => {
result = await saveCommand?.action?.(mockContext, tag);
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{
history: expect.any(Array),
version: '2.0',
authType: AuthType.LOGIN_WITH_GOOGLE,
trajectories: {},
messages: [],
@@ -210,7 +210,14 @@ describe('chatCommand', () => {
});
it('should return confirm_action if checkpoint already exists', async () => {
mockCheckpointExists.mockResolvedValue(true);
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
{ role: 'user', parts: [{ text: 'Hello, how are you?' }] },
]);
vi.mocked(mockContext.services.logger.checkpointExists).mockResolvedValue(
true,
);
mockContext.invocation = {
raw: `/chat save ${tag}`,
name: 'save',
@@ -219,19 +226,22 @@ describe('chatCommand', () => {
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).toHaveBeenCalledWith(tag);
expect(mockContext.services.logger.checkpointExists).toHaveBeenCalledWith(
tag,
);
expect(mockSaveCheckpoint).not.toHaveBeenCalled();
expect(result).toMatchObject({
type: 'confirm_action',
originalInvocation: { raw: `/chat save ${tag}` },
});
// Check that prompt is a React element
// Check that prompt is a React element or string
expect(result).toHaveProperty('prompt');
});
it('should save the conversation if overwrite is confirmed', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'context for our chat' }] },
{ role: 'model', parts: [{ text: 'Got it!' }] },
{ role: 'user', parts: [{ text: 'hello' }] },
];
mockGetHistory.mockReturnValue(history);
@@ -239,10 +249,12 @@ describe('chatCommand', () => {
const result = await saveCommand?.action?.(mockContext, tag);
expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check
expect(
mockContext.services.logger.checkpointExists,
).not.toHaveBeenCalled(); // Should skip existence check
expect(mockSaveCheckpoint).toHaveBeenCalledWith(
{
history,
version: '2.0',
authType: AuthType.LOGIN_WITH_GOOGLE,
trajectories: {},
messages: [],
@@ -358,19 +370,12 @@ describe('chatCommand', () => {
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles) as unknown as typeof fsPromises.readdir,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.readdir.mockResolvedValue(fakeFiles as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.stat.mockResolvedValue({ mtime: new Date() } as any);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await resumeCommand?.completion?.(mockContext, 'a');
const result = await resumeCommand?.completion?.(mockContext, 'al');
expect(result).toEqual(['alpha']);
});
@@ -378,18 +383,17 @@ describe('chatCommand', () => {
it('should suggest filenames sorted by modified time (newest first)', async () => {
const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json'];
const date = new Date();
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles) as unknown as typeof fsPromises.readdir,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.readdir.mockResolvedValue(fakeFiles as any);
mockFs.stat.mockImplementation(
async (filePath: string): Promise<Stats> => {
if (filePath.endsWith('test1.json')) {
return { mtime: date } as Stats;
}
return { mtime: new Date(date.getTime() + 1000) } as Stats;
},
);
mockFs.stat.mockImplementation((async (
path: string,
): Promise<Stats> => {
if (path.endsWith('test1.json')) {
return { mtime: date } as Stats;
}
return { mtime: new Date(date.getTime() + 1000) } as Stats;
}) as unknown as typeof fsPromises.stat);
const result = await resumeCommand?.completion?.(mockContext, '');
// Sort items by last modified time (newest first)
@@ -438,19 +442,12 @@ describe('chatCommand', () => {
describe('completion', () => {
it('should provide completion suggestions', async () => {
const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json'];
mockFs.readdir.mockImplementation(
(async (_: string): Promise<string[]> =>
fakeFiles) as unknown as typeof fsPromises.readdir,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.readdir.mockResolvedValue(fakeFiles as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockFs.stat.mockResolvedValue({ mtime: new Date() } as any);
mockFs.stat.mockImplementation(
(async (_: string): Promise<Stats> =>
({
mtime: new Date(),
}) as Stats) as unknown as typeof fsPromises.stat,
);
const result = await deleteCommand?.completion?.(mockContext, 'a');
const result = await deleteCommand?.completion?.(mockContext, 'al');
expect(result).toEqual(['alpha']);
});
@@ -724,70 +721,82 @@ Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
describe('debug subcommand', () => {
let mockGetLatestApiRequest: ReturnType<typeof vi.fn>;
});
});
beforeEach(() => {
mockGetLatestApiRequest = vi.fn();
if (!mockContext.services.agentContext!.config) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockContext.services.agentContext!.config as any) = {};
}
mockContext.services.agentContext!.config.getLatestApiRequest =
mockGetLatestApiRequest;
vi.spyOn(process, 'cwd').mockReturnValue('/project/root');
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockFs.writeFile.mockClear();
});
describe('debugCommand', () => {
const mockFs = vi.mocked(fsPromises);
let mockContext: CommandContext;
let mockGetLatestApiRequest: ReturnType<typeof vi.fn>;
it('should return an error if no API request is found', async () => {
mockGetLatestApiRequest.mockReturnValue(undefined);
beforeEach(() => {
mockGetLatestApiRequest = vi.fn();
mockContext = createMockCommandContext({
services: {
agentContext: {
config: {
getLatestApiRequest: mockGetLatestApiRequest,
getIdeMode: () => false,
},
},
},
});
const result = await debugCommand.action?.(mockContext, '');
vi.spyOn(process, 'cwd').mockReturnValue('/project/root');
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockFs.writeFile.mockClear();
});
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No recent API request found to export.',
});
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should convert and write the API request to a json file', async () => {
const mockRequest = {
contents: [{ role: 'user', parts: [{ text: 'test' }] }],
};
mockGetLatestApiRequest.mockReturnValue(mockRequest);
it('should return an error if no API request is found', async () => {
mockGetLatestApiRequest.mockReturnValue(undefined);
const result = await debugCommand.action?.(mockContext, '');
const result = await debugCommand.action?.(mockContext, '');
const expectedFilename = 'gcli-request-1234567890.json';
const expectedPath = path.join('/project/root', expectedFilename);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No recent API request found to export.',
});
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
expect(mockFs.writeFile).toHaveBeenCalledWith(
expectedPath,
expect.stringContaining('"role": "user"'),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Debug API request saved to ${expectedFilename}`,
});
});
it('should convert and write the API request to a json file', async () => {
const mockRequest = {
contents: [{ role: 'user', parts: [{ text: 'test' }] }],
};
mockGetLatestApiRequest.mockReturnValue(mockRequest);
it('should handle errors during file write', async () => {
const mockRequest = { contents: [] };
mockGetLatestApiRequest.mockReturnValue(mockRequest);
mockFs.writeFile.mockRejectedValue(new Error('Write failed'));
const result = await debugCommand.action?.(mockContext, '');
const result = await debugCommand.action?.(mockContext, '');
const expectedFilename = 'gcli-request-1234567890.json';
const expectedPath = path.join('/project/root', expectedFilename);
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Error saving debug request: Write failed',
});
});
expect(mockFs.writeFile).toHaveBeenCalledWith(
expectedPath,
expect.stringContaining('"role": "user"'),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Debug API request saved to ${expectedFilename}`,
});
});
it('should handle errors during file write', async () => {
const mockRequest = { contents: [] };
mockGetLatestApiRequest.mockReturnValue(mockRequest);
mockFs.writeFile.mockRejectedValue(new Error('Write failed'));
const result = await debugCommand.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Error saving debug request: Write failed',
});
});
});

View File

@@ -4,360 +4,353 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fsPromises from 'node:fs/promises';
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type {
CommandContext,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
decodeTagName,
type MessageActionReturn,
INITIAL_HISTORY_LENGTH,
} from '@google/gemini-cli-core';
import path from 'node:path';
import type {
HistoryItemWithoutId,
HistoryItemChatList,
ChatDetail,
} from '../types.js';
import { MessageType } from '../types.js';
import * as fs from 'node:fs/promises';
import {
type SlashCommand,
type CommandContext,
CommandKind,
type SlashCommandActionReturn,
} from './types.js';
import { MessageType, type HistoryItemWithoutId } from '../types.js';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
import { convertToRestPayload } from '@google/gemini-cli-core';
import { INITIAL_HISTORY_LENGTH } from '@google/gemini-cli-core';
const CHECKPOINT_MENU_GROUP = 'checkpoints';
const baseChatSubCommands: SlashCommand[] = [
{
name: 'list',
description: 'List saved conversation checkpoints',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const logger = context.services.logger;
await logger.initialize();
const geminiDir =
context.services.agentContext?.config.storage.getProjectTempDir();
if (!geminiDir) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Error: Could not determine project directory.',
});
return;
}
const getSavedChatTags = async (
context: CommandContext,
mtSortDesc: boolean,
): Promise<ChatDetail[]> => {
const cfg = context.services.agentContext?.config;
const geminiDir = cfg?.storage?.getProjectTempDir();
if (!geminiDir) {
return [];
}
try {
const file_head = 'checkpoint-';
const file_tail = '.json';
const files = await fsPromises.readdir(geminiDir);
const chatDetails: ChatDetail[] = [];
try {
const files = await fs.readdir(geminiDir);
const checkpoints = await Promise.all(
files
.filter((f) => f.startsWith('checkpoint-') && f.endsWith('.json'))
.map(async (f) => {
const name = f.replace(/^checkpoint-/, '').replace(/\.json$/, '');
const stats = await fs.stat(path.join(geminiDir, f));
return {
name,
mtime: stats.mtime.toISOString(),
};
}),
);
for (const file of files) {
if (file.startsWith(file_head) && file.endsWith(file_tail)) {
const filePath = path.join(geminiDir, file);
const stats = await fsPromises.stat(filePath);
const tagName = file.slice(file_head.length, -file_tail.length);
chatDetails.push({
name: decodeTagName(tagName),
mtime: stats.mtime.toISOString(),
const chatListItem: HistoryItemWithoutId = {
type: 'chat_list',
chats: checkpoints.sort(
(a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime(),
),
};
context.ui.addItem(chatListItem);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
context.ui.addItem({
type: MessageType.ERROR,
text: `Error listing checkpoints: ${errorMessage}`,
});
}
}
chatDetails.sort((a, b) =>
mtSortDesc
? b.mtime.localeCompare(a.mtime)
: a.mtime.localeCompare(b.mtime),
);
return chatDetails;
} catch {
return [];
}
};
const listCommand: SlashCommand = {
name: 'list',
description: 'List saved manual conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
const item: HistoryItemChatList = {
type: MessageType.CHAT_LIST,
chats: chatDetails,
};
context.ui.addItem(item);
},
},
};
const saveCommand: SlashCommand = {
name: 'save',
description:
'Save the current conversation as a checkpoint. Usage: /resume save <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /resume save <tag>',
};
}
const { logger } = context.services;
const config = context.services.agentContext?.config;
await logger.initialize();
if (!context.overwriteConfirmed) {
const exists = await logger.checkpointExists(tag);
if (exists) {
{
name: 'save',
description: 'Save a named checkpoint of the current conversation',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args?: string,
): Promise<SlashCommandActionReturn | void> => {
const tag = (args || '').trim();
if (!tag) {
return {
type: 'confirm_action',
prompt: React.createElement(
Text,
null,
'A checkpoint with the tag ',
React.createElement(Text, { color: theme.text.accent }, tag),
' already exists. Do you want to overwrite it?',
),
originalInvocation: {
raw: context.invocation?.raw || `/resume save ${tag}`,
},
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /resume save <tag>',
};
}
}
const chat = context.services.agentContext?.geminiClient?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to save conversation.',
};
}
const agentContext = context.services.agentContext;
const chat = agentContext?.geminiClient?.getChat();
const history = chat?.getHistory() || [];
const history = chat.getHistory();
if (history.length > INITIAL_HISTORY_LENGTH) {
const authType = config?.getContentGeneratorConfig()?.authType;
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages ?? [];
await logger.saveCheckpoint(
{ history, authType, trajectories, messages },
tag,
);
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${decodeTagName(
tag,
)}.`,
};
} else {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
};
}
},
};
const resumeCheckpointCommand: SlashCommand = {
name: 'resume',
altNames: ['load'],
description:
'Resume a conversation from a checkpoint. Usage: /resume resume <tag>',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args) => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /resume resume <tag>',
};
}
const { logger } = context.services;
const config = context.services.agentContext?.config;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history ?? [];
if (conversation.length === 0 && !checkpoint.messages) {
return {
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`,
};
}
const currentAuthType = config?.getContentGeneratorConfig()?.authType;
if (
checkpoint.authType &&
currentAuthType &&
checkpoint.authType !== currentAuthType
) {
return {
type: 'message',
messageType: 'error',
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
};
}
const rolemap: { [key: string]: MessageType } = {
user: MessageType.USER,
model: MessageType.GEMINI,
};
const uiHistory: HistoryItemWithoutId[] = [];
for (const item of conversation.slice(INITIAL_HISTORY_LENGTH)) {
const text =
item.parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
if (!text) {
continue;
// Simple heuristic: don't save if there's only system context or no history.
if (history.length <= INITIAL_HISTORY_LENGTH) {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to save.',
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
uiHistory.push({
type: (item.role && rolemap[item.role]) || MessageType.GEMINI,
text,
} as HistoryItemWithoutId);
}
return {
type: 'load_history',
history: uiHistory,
clientHistory: conversation,
messages: checkpoint.messages,
version: checkpoint.version,
};
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
const logger = context.services.logger;
await logger.initialize();
const deleteCommand: SlashCommand = {
name: 'delete',
description: 'Delete a conversation checkpoint. Usage: /resume delete <tag>',
if (!context.overwriteConfirmed && (await logger.checkpointExists(tag))) {
return {
type: 'confirm_action',
prompt: `Checkpoint '${tag}' already exists. Overwrite?`,
originalInvocation: context.invocation!,
};
}
const authType =
agentContext?.config.getContentGeneratorConfig()?.authType;
const trajectories = await chat?.getSubagentTrajectories();
const messages = chat?.getConversation()?.messages ?? [];
await logger.saveCheckpoint(
{ version: '2.0', authType, trajectories, messages },
tag,
);
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint saved with tag: ${tag}.`,
};
},
},
{
name: 'resume',
description: 'Resume a saved conversation checkpoint',
kind: CommandKind.BUILT_IN,
completion: async (context: CommandContext, input: string) => {
const geminiDir =
context.services.agentContext?.config.storage.getProjectTempDir();
if (!geminiDir) return [];
try {
const files = await fs.readdir(geminiDir);
const checkpoints = await Promise.all(
files
.filter(
(f) =>
f.startsWith('checkpoint-') &&
f.endsWith('.json') &&
f.toLowerCase().includes(input.toLowerCase()),
)
.map(async (f) => {
const stats = await fs.stat(path.join(geminiDir, f));
return {
name: f.replace(/^checkpoint-/, '').replace(/\.json$/, ''),
mtime: stats.mtime.getTime(),
};
}),
);
return checkpoints.sort((a, b) => b.mtime - a.mtime).map((c) => c.name);
} catch {
return [];
}
},
action: async (
context: CommandContext,
args?: string,
): Promise<SlashCommandActionReturn | void> => {
const tag = (args || '').trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /resume resume <tag>',
};
}
const logger = context.services.logger;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
if (!checkpoint.history || checkpoint.history.length === 0) {
return {
type: 'message',
messageType: 'info',
content: `No saved checkpoint found with tag: ${tag}.`,
};
}
const currentAuthType =
context.services.agentContext?.config.getContentGeneratorConfig()
?.authType;
if (
checkpoint.authType &&
currentAuthType &&
checkpoint.authType !== currentAuthType
) {
return {
type: 'message',
messageType: 'error',
content: `Cannot resume chat. It was saved with a different authentication method (${checkpoint.authType}) than the current one (${currentAuthType}).`,
};
}
// Return the load_history action which UI hooks handle
return {
type: 'load_history',
// Strip the "System Setup" turns which are re-added during startChat/resumeChat
history: checkpoint.history.slice(INITIAL_HISTORY_LENGTH).map((h) => ({
type: h.role === 'user' ? 'user' : 'gemini',
text:
h.parts
?.map((p) => p.text)
.filter(Boolean)
.join('\n') || '',
})),
clientHistory: checkpoint.history,
messages: checkpoint.messages,
version: checkpoint.version,
};
},
},
{
name: 'delete',
description: 'Delete a saved conversation checkpoint',
kind: CommandKind.BUILT_IN,
completion: async (context: CommandContext, input: string) => {
// Reuse the logic from resume completion
const resumeCmd = baseChatSubCommands.find((c) => c.name === 'resume');
return (await resumeCmd?.completion?.(context, input)) || [];
},
action: async (
context: CommandContext,
args?: string,
): Promise<SlashCommandActionReturn | void> => {
const tag = (args || '').trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /resume delete <tag>',
};
}
const logger = context.services.logger;
await logger.initialize();
const deleted = await logger.deleteCheckpoint(tag);
if (!deleted) {
return {
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${tag}'.`,
};
}
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${tag}' has been deleted.`,
};
},
},
{
name: 'share',
description: 'Export current conversation history to a file',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args?: string,
): Promise<SlashCommandActionReturn | void> => {
const agentContext = context.services.agentContext;
const chat = agentContext?.geminiClient?.getChat();
const history = chat?.getHistory() || [];
// Simple heuristic: don't share if there's only system context or no history.
if (history.length <= INITIAL_HISTORY_LENGTH) {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
};
}
let filePath = (args || '').trim();
if (!filePath) {
filePath = `gemini-conversation-${Date.now()}.json`;
}
const extension = path.extname(filePath).toLowerCase();
if (extension !== '.json' && extension !== '.md') {
return {
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
};
}
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.join(process.cwd(), filePath);
try {
const trajectories = await chat?.getSubagentTrajectories();
const messages = chat?.getConversation()?.messages ?? [];
await exportHistoryToFile({
messages,
filePath: absolutePath,
trajectories,
history,
});
return {
type: 'message',
messageType: 'info',
content: `Conversation shared to ${absolutePath}`,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
};
}
},
},
];
export const chatResumeSubCommands: SlashCommand[] = [
...baseChatSubCommands,
{
name: 'checkpoints',
description: 'List saved conversation checkpoints (legacy alias)',
kind: CommandKind.BUILT_IN,
hidden: true,
subCommands: baseChatSubCommands,
},
];
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
if (!tag) {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /resume delete <tag>',
};
}
const { logger } = context.services;
await logger.initialize();
const deleted = await logger.deleteCheckpoint(tag);
if (deleted) {
return {
type: 'message',
messageType: 'info',
content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`,
};
} else {
return {
type: 'message',
messageType: 'error',
content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`,
};
}
},
completion: async (context, partialArg) => {
const chatDetails = await getSavedChatTags(context, true);
return chatDetails
.map((chat) => chat.name)
.filter((name) => name.startsWith(partialArg));
},
};
const shareCommand: SlashCommand = {
name: 'share',
description:
'Share the current conversation to a markdown or json file. Usage: /resume share <file>',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
filePathArg = `gemini-conversation-${Date.now()}.json`;
}
const filePath = path.resolve(filePathArg);
const extension = path.extname(filePath);
if (extension !== '.md' && extension !== '.json') {
return {
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
};
}
const chat = context.services.agentContext?.geminiClient?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to share conversation.',
};
}
const history = chat.getHistory();
// An empty conversation has a hidden message that sets up the context for
// the chat. Thus, to check whether a conversation has been started, we
// can't check for length 0.
if (history.length <= INITIAL_HISTORY_LENGTH) {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
};
}
try {
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages ?? [];
await exportHistoryToFile({ messages, filePath, trajectories, history });
return {
type: 'message',
messageType: 'info',
content: `Conversation shared to ${filePath}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
};
}
},
subCommands: chatResumeSubCommands,
};
export const debugCommand: SlashCommand = {
name: 'debug',
description: 'Export the most recent API request as a JSON payload',
description: 'Export the last API request and response for debugging',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context): Promise<MessageActionReturn> => {
const req = context.services.agentContext?.config.getLatestApiRequest();
if (!req) {
autoExecute: false,
action: async (
context: CommandContext,
): Promise<SlashCommandActionReturn | void> => {
const config = context.services.agentContext?.config;
const lastRequest = config?.getLatestApiRequest?.();
if (!lastRequest) {
return {
type: 'message',
messageType: 'error',
@@ -365,22 +358,19 @@ export const debugCommand: SlashCommand = {
};
}
const restPayload = convertToRestPayload(req);
const filename = `gcli-request-${Date.now()}.json`;
const filePath = path.join(process.cwd(), filename);
const fileName = `gcli-request-${Date.now()}.json`;
const absolutePath = path.join(process.cwd(), fileName);
try {
await fsPromises.writeFile(
filePath,
JSON.stringify(restPayload, null, 2),
);
await fs.writeFile(absolutePath, JSON.stringify(lastRequest, null, 2));
return {
type: 'message',
messageType: 'info',
content: `Debug API request saved to ${filename}`,
content: `Debug API request saved to ${fileName}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
type: 'message',
messageType: 'error',
@@ -389,51 +379,3 @@ export const debugCommand: SlashCommand = {
}
},
};
export const checkpointSubCommands: SlashCommand[] = [
listCommand,
saveCommand,
resumeCheckpointCommand,
deleteCommand,
shareCommand,
];
const checkpointCompatibilityCommand: SlashCommand = {
name: 'checkpoints',
altNames: ['checkpoint'],
description: 'Compatibility command for nested checkpoint operations',
kind: CommandKind.BUILT_IN,
hidden: true,
autoExecute: false,
subCommands: checkpointSubCommands,
};
export const chatResumeSubCommands: SlashCommand[] = [
...checkpointSubCommands.map((subCommand) => ({
...subCommand,
suggestionGroup: CHECKPOINT_MENU_GROUP,
})),
checkpointCompatibilityCommand,
];
import { parseSlashCommand } from '../../utils/commands.js';
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return {
type: 'dialog',
dialog: 'sessionBrowser',
};
},
subCommands: chatResumeSubCommands,
};

View File

@@ -57,10 +57,11 @@ export function serializeHistoryToMarkdown(
*/
export interface ExportHistoryOptions {
/**
* Optional full message records which contain metadata like agentId for tool calls,
* 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[];
messages: MessageRecord[];
/** The file path to export to. */
filePath: string;
/** Optional subagent trajectories to include. */
@@ -81,18 +82,21 @@ export async function exportHistoryToFile(
const {
messages,
filePath,
trajectories: _trajectories, // Collected but not yet included in Stage 2 JSON output
trajectories,
history: providedHistory,
} = options;
const extension = path.extname(filePath).toLowerCase();
let content: string;
if (extension === '.json') {
// Stage 1 & 2: Maintain legacy behavior - only export the raw history array.
// Trajectories and messages are collected but not yet included in Stage 2 JSON output.
content = JSON.stringify(providedHistory ?? [], null, 2);
// Stage 3: Pivot to the new format - export messages and trajectories as an object.
content = JSON.stringify(
{ version: '2.0', messages, trajectories },
null,
2,
);
} else if (extension === '.md') {
const history = providedHistory ?? reconstructHistory(messages ?? []);
const history = providedHistory ?? reconstructHistory(messages);
content = serializeHistoryToMarkdown(history);
} else {
throw new Error(