mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-25 21:54:12 +00:00
feat: switch to Version 2.0 session bundle format (Stage 3)
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user