mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d74b296e4f | |||
| 0ae939422c | |||
| 431155cf41 | |||
| d0b05a8f2a | |||
| 865130cd54 | |||
| a7936df7c5 |
@@ -173,6 +173,8 @@ describe('bugCommand', () => {
|
||||
geminiClient: {
|
||||
getChat: () => ({
|
||||
getHistory: () => history,
|
||||
getSubagentTrajectories: vi.fn().mockResolvedValue({}),
|
||||
getConversation: vi.fn().mockReturnValue({ messages: [] }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
@@ -187,8 +189,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];
|
||||
|
||||
@@ -88,7 +88,14 @@ export const bugCommand: SlashCommand = {
|
||||
const historyFileName = `bug-report-history-${Date.now()}.json`;
|
||||
const historyFilePath = path.join(tempDir, historyFileName);
|
||||
try {
|
||||
await exportHistoryToFile({ history, filePath: historyFilePath });
|
||||
const trajectories = await chat?.getSubagentTrajectories();
|
||||
const messages = chat?.getConversation()?.messages ?? [];
|
||||
await exportHistoryToFile({
|
||||
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.`;
|
||||
} catch (err) {
|
||||
|
||||
@@ -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(),
|
||||
@@ -63,6 +63,8 @@ describe('chatCommand', () => {
|
||||
mockGetHistory = vi.fn().mockReturnValue([]);
|
||||
mockGetChat = vi.fn().mockReturnValue({
|
||||
getHistory: mockGetHistory,
|
||||
getSubagentTrajectories: vi.fn().mockResolvedValue({}),
|
||||
getConversation: vi.fn().mockReturnValue({ messages: [] }),
|
||||
});
|
||||
mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] });
|
||||
@@ -88,6 +90,7 @@ describe('chatCommand', () => {
|
||||
saveCheckpoint: mockSaveCheckpoint,
|
||||
loadCheckpoint: mockLoadCheckpoint,
|
||||
deleteCheckpoint: mockDeleteCheckpoint,
|
||||
checkpointExists: vi.fn().mockResolvedValue(false),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
@@ -122,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;
|
||||
@@ -131,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 () => {
|
||||
@@ -191,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',
|
||||
@@ -199,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',
|
||||
@@ -208,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);
|
||||
@@ -228,9 +249,16 @@ 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, authType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
{
|
||||
version: '2.0',
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
trajectories: {},
|
||||
messages: [],
|
||||
},
|
||||
tag,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
@@ -292,6 +320,8 @@ describe('chatCommand', () => {
|
||||
{ type: 'gemini', text: 'hello world' },
|
||||
] as HistoryItemWithoutId[],
|
||||
clientHistory: conversation,
|
||||
messages: undefined,
|
||||
version: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,25 +362,20 @@ describe('chatCommand', () => {
|
||||
{ type: 'gemini', text: 'hello world' },
|
||||
] as HistoryItemWithoutId[],
|
||||
clientHistory: conversation,
|
||||
messages: undefined,
|
||||
version: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
@@ -358,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)
|
||||
@@ -418,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']);
|
||||
});
|
||||
@@ -463,8 +480,10 @@ describe('chatCommand', () => {
|
||||
'gemini-conversation-1234567890.json',
|
||||
);
|
||||
expect(mockExport).toHaveBeenCalledWith({
|
||||
history: mockHistory,
|
||||
messages: [],
|
||||
filePath: expectedPath,
|
||||
trajectories: {},
|
||||
history: mockHistory,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
@@ -478,8 +497,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',
|
||||
@@ -493,8 +514,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',
|
||||
@@ -543,8 +566,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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -553,8 +578,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -694,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,351 +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;
|
||||
await logger.saveCheckpoint({ history, authType }, 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) {
|
||||
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,
|
||||
};
|
||||
},
|
||||
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 {
|
||||
await exportHistoryToFile({ history, filePath });
|
||||
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',
|
||||
@@ -356,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',
|
||||
@@ -380,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,
|
||||
};
|
||||
|
||||
@@ -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,6 +7,11 @@
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Content } from '@google/genai';
|
||||
import {
|
||||
type ConversationRecord,
|
||||
type MessageRecord,
|
||||
reconstructHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Serializes chat history to a Markdown string.
|
||||
@@ -51,8 +56,21 @@ export function serializeHistoryToMarkdown(
|
||||
* Options for exporting chat history.
|
||||
*/
|
||||
export interface ExportHistoryOptions {
|
||||
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 standard history array used for model requests.
|
||||
* If provided, it is used for Markdown export to avoid reconstruction.
|
||||
*/
|
||||
history?: readonly Content[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,13 +79,24 @@ export interface ExportHistoryOptions {
|
||||
export async function exportHistoryToFile(
|
||||
options: ExportHistoryOptions,
|
||||
): Promise<void> {
|
||||
const { history, filePath } = options;
|
||||
const {
|
||||
messages,
|
||||
filePath,
|
||||
trajectories,
|
||||
history: providedHistory,
|
||||
} = options;
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
let content: string;
|
||||
if (extension === '.json') {
|
||||
content = JSON.stringify(history, 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);
|
||||
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';
|
||||
@@ -339,8 +340,9 @@ export class GeminiClient {
|
||||
async resumeChat(
|
||||
history: ReadonlyArray<Content | HistoryTurn>,
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
messages?: MessageRecord[],
|
||||
): Promise<void> {
|
||||
this.chat = await this.startChat(history, resumedSessionData);
|
||||
this.chat = await this.startChat(history, resumedSessionData, messages);
|
||||
this.updateTelemetryTokenCount();
|
||||
}
|
||||
|
||||
@@ -380,6 +382,7 @@ export class GeminiClient {
|
||||
async startChat(
|
||||
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
messages?: MessageRecord[],
|
||||
): Promise<GeminiChat> {
|
||||
this.forceFullIdeContext = true;
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
@@ -409,8 +412,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,
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
import {
|
||||
ChatRecordingService,
|
||||
type ResumedSessionData,
|
||||
type MessageRecord,
|
||||
type ConversationRecord,
|
||||
} from '../services/chatRecordingService.js';
|
||||
import {
|
||||
ContentRetryEvent,
|
||||
@@ -272,6 +274,7 @@ export class GeminiChat {
|
||||
private readonly chatRecordingService: ChatRecordingService;
|
||||
private lastPromptTokenCount: number;
|
||||
private callCounter = 0;
|
||||
private initialMessages?: MessageRecord[];
|
||||
agentHistory: AgentChatHistory;
|
||||
|
||||
constructor(
|
||||
@@ -281,6 +284,7 @@ export class GeminiChat {
|
||||
history: Array<Content | HistoryTurn> = [],
|
||||
resumedSessionData?: ResumedSessionData,
|
||||
private readonly onModelChanged?: (modelId: string) => Promise<Tool[]>,
|
||||
messages?: MessageRecord[],
|
||||
) {
|
||||
validateHistory(history);
|
||||
|
||||
@@ -311,6 +315,7 @@ export class GeminiChat {
|
||||
initialHistory = [];
|
||||
}
|
||||
|
||||
this.initialMessages = messages;
|
||||
this.agentHistory = new AgentChatHistory(initialHistory);
|
||||
this.chatRecordingService = new ChatRecordingService(context);
|
||||
this.lastPromptTokenCount = estimateTokenCountSync(
|
||||
@@ -325,8 +330,15 @@ 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);
|
||||
}
|
||||
|
||||
// Sync initial history with the recorder to ensure all turns (even bootstrapped ones)
|
||||
// are durable and coordinated.
|
||||
this.chatRecordingService.updateMessagesFromHistory(
|
||||
@@ -342,6 +354,18 @@ export class GeminiChat {
|
||||
return this.systemInstruction;
|
||||
}
|
||||
|
||||
getConversation(): ConversationRecord | null {
|
||||
return this.chatRecordingService.getConversation();
|
||||
}
|
||||
|
||||
getChatRecordingService(): ChatRecordingService {
|
||||
return this.chatRecordingService;
|
||||
}
|
||||
|
||||
async getSubagentTrajectories(): Promise<Record<string, ConversationRecord>> {
|
||||
return this.chatRecordingService.getSubagentTrajectories();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the model and returns the response in chunks.
|
||||
*
|
||||
@@ -747,7 +771,7 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
throw new AgentExecutionBlockedError(
|
||||
beforeModelResult.reason || 'Model call blocked by hook',
|
||||
beforeModelResult.reason || 'Agent execution blocked by hook',
|
||||
syntheticResponse,
|
||||
);
|
||||
}
|
||||
@@ -1325,13 +1349,6 @@ export class GeminiChat {
|
||||
return this.lastPromptTokenCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chat recording service instance.
|
||||
*/
|
||||
getChatRecordingService(): ChatRecordingService {
|
||||
return this.chatRecordingService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records completed tool calls with full metadata.
|
||||
* This is called by external components when tool calls complete, before sending responses to Gemini.
|
||||
|
||||
@@ -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,21 +543,48 @@ 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 load a version 2.0 checkpoint and reconstruct history', async () => {
|
||||
const tag = 'v2-tag';
|
||||
const v2Checkpoint = {
|
||||
version: '2.0',
|
||||
history: [{ role: 'user', parts: [{ text: 'preserved' }] }],
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'user',
|
||||
content: 'hello',
|
||||
timestamp: '2025-01-01T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
const taggedFilePath = path.join(
|
||||
TEST_GEMINI_DIR,
|
||||
'checkpoint-v2-tag.json',
|
||||
);
|
||||
await fs.writeFile(taggedFilePath, JSON.stringify(v2Checkpoint, null, 2));
|
||||
|
||||
const loaded = await logger.loadCheckpoint(tag);
|
||||
expect(loaded.messages).toEqual(v2Checkpoint.messages);
|
||||
expect(loaded.history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'hello' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
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({ history: [], 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({ history: [], messages: [] });
|
||||
});
|
||||
|
||||
it('should return an empty history if the file contains invalid JSON', async () => {
|
||||
it('should return an empty message list if the file contains invalid JSON', async () => {
|
||||
const tag = 'invalid-json-tag';
|
||||
const encodedTag = 'invalid-json-tag';
|
||||
const taggedFilePath = path.join(
|
||||
@@ -560,14 +596,14 @@ describe('Logger', () => {
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const loadedCheckpoint = await logger.loadCheckpoint(tag);
|
||||
expect(loadedCheckpoint).toEqual({ history: [] });
|
||||
expect(loadedCheckpoint).toEqual({ history: [], 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 +613,7 @@ describe('Logger', () => {
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const loadedCheckpoint = await uninitializedLogger.loadCheckpoint('tag');
|
||||
expect(loadedCheckpoint).toEqual({ history: [] });
|
||||
expect(loadedCheckpoint).toEqual({ history: [], messages: [] });
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
|
||||
);
|
||||
|
||||
@@ -11,6 +11,12 @@ import type { AuthType } from './contentGenerator.js';
|
||||
import type { Storage } from '../config/storage.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import { reconstructHistory } from '../utils/history-reconstruction.js';
|
||||
|
||||
import {
|
||||
type ConversationRecord,
|
||||
type MessageRecord,
|
||||
} from '../services/chatRecordingService.js';
|
||||
|
||||
const LOG_FILE_NAME = 'logs.json';
|
||||
|
||||
@@ -27,8 +33,23 @@ export interface LogEntry {
|
||||
}
|
||||
|
||||
export interface Checkpoint {
|
||||
history: readonly Content[];
|
||||
/**
|
||||
* The standard history array used for model requests.
|
||||
* Only included in legacy checkpoints (pre-2.0).
|
||||
*/
|
||||
history?: readonly Content[];
|
||||
authType?: AuthType;
|
||||
/**
|
||||
* The rich message records which are the source of truth for the session.
|
||||
* Optional in Stage 1 to maintain backward compatibility.
|
||||
*/
|
||||
messages?: MessageRecord[];
|
||||
/**
|
||||
* The version of the checkpoint format.
|
||||
*/
|
||||
version?: '2.0';
|
||||
/** Optional subagent trajectories to include. */
|
||||
trajectories?: Record<string, ConversationRecord>;
|
||||
}
|
||||
|
||||
// This regex matches any character that is NOT a letter (a-z, A-Z),
|
||||
@@ -347,7 +368,7 @@ export class Logger {
|
||||
debugLogger.error(
|
||||
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
|
||||
);
|
||||
return { history: [] };
|
||||
return { history: [], messages: [] };
|
||||
}
|
||||
|
||||
const path = await this._getCheckpointPath(tag);
|
||||
@@ -356,37 +377,44 @@ export class Logger {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsedContent = JSON.parse(fileContent);
|
||||
|
||||
// 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 raw = parsedContent as Record<string, unknown>;
|
||||
if (raw['version'] === '2.0') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const msgs = (raw['messages'] as MessageRecord[]) ?? [];
|
||||
return {
|
||||
...raw,
|
||||
history: reconstructHistory(msgs),
|
||||
messages: msgs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
history: (raw['history'] as Content[]) ?? [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
messages: (raw['messages'] as MessageRecord[]) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
debugLogger.warn(
|
||||
`Checkpoint file at ${path} has an unknown format. Returning empty checkpoint.`,
|
||||
);
|
||||
return { history: [] };
|
||||
return { history: [], 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 { history: [], messages: [] };
|
||||
}
|
||||
debugLogger.error(
|
||||
`Failed to read or parse checkpoint file ${path}:`,
|
||||
error,
|
||||
);
|
||||
return { history: [] };
|
||||
return { history: [], messages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,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
|
||||
|
||||
@@ -694,6 +694,63 @@ export class ChatRecordingService {
|
||||
return this.conversationFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively finds all subagent trajectories called from this session.
|
||||
*/
|
||||
async getSubagentTrajectories(): Promise<Record<string, ConversationRecord>> {
|
||||
const trajectories: Record<string, ConversationRecord> = {};
|
||||
const conversation = this.getConversation();
|
||||
if (!conversation) return trajectories;
|
||||
|
||||
const agentIds = new Set<string>();
|
||||
for (const message of conversation.messages) {
|
||||
if (message.type === 'gemini' && message.toolCalls) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (toolCall.agentId) {
|
||||
agentIds.add(toolCall.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agentIds.size === 0) return trajectories;
|
||||
|
||||
const tempDir = this.context.config.storage.getProjectTempDir();
|
||||
const chatsDir = path.join(tempDir, 'chats');
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
const subagentFilePath = path.join(chatsDir, `${agentId}.json`);
|
||||
try {
|
||||
const content = await fs.promises.readFile(subagentFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const subagentRecord = JSON.parse(content) as ConversationRecord;
|
||||
trajectories[agentId] = subagentRecord;
|
||||
|
||||
// Recursive discovery: Create a temporary service to find nested trajectories
|
||||
const subService = new ChatRecordingService(this.context);
|
||||
// Manual override of cached state for discovery
|
||||
subService.cachedConversation = subagentRecord;
|
||||
subService.conversationFile = subagentFilePath;
|
||||
const nested = await subService.getSubagentTrajectories();
|
||||
Object.assign(trajectories, nested);
|
||||
} catch (err) {
|
||||
debugLogger.warn(`Failed to load subagent trajectory ${agentId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return trajectories;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -33,26 +33,13 @@ export const SHADOW_REPO_AUTHOR_EMAIL = 'gemini-cli@google.com';
|
||||
*/
|
||||
const SHADOW_REPO_GIT_OPTIONS: Partial<SimpleGitOptions> = {
|
||||
unsafe: {
|
||||
allowUnsafeAlias: true,
|
||||
allowUnsafeAskPass: true,
|
||||
allowUnsafeConfigEnvCount: true,
|
||||
allowUnsafeConfigPaths: true,
|
||||
allowUnsafeCredentialHelper: true,
|
||||
allowUnsafeCustomBinary: true,
|
||||
allowUnsafeDiffExternal: true,
|
||||
allowUnsafeDiffTextConv: true,
|
||||
allowUnsafeEditor: true,
|
||||
allowUnsafeFilter: true,
|
||||
allowUnsafeFsMonitor: true,
|
||||
allowUnsafeGitProxy: true,
|
||||
allowUnsafeGpgProgram: true,
|
||||
allowUnsafeHooksPath: true,
|
||||
allowUnsafeMergeDriver: true,
|
||||
allowUnsafePack: true,
|
||||
allowUnsafePager: true,
|
||||
allowUnsafeProtocolOverride: true,
|
||||
allowUnsafePack: true,
|
||||
allowUnsafeSshCommand: true,
|
||||
allowUnsafeTemplateDir: true,
|
||||
allowUnsafeGitProxy: true,
|
||||
allowUnsafeHooksPath: true,
|
||||
allowUnsafeDiffExternal: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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