Compare commits

...

6 Commits

Author SHA1 Message Date
Aishanee Shah d74b296e4f feat: switch to Version 2.0 session bundle format (Stage 3) 2026-05-19 19:58:46 +00:00
Aishanee Shah 0ae939422c fix: resolve Stage 2 rebase conflicts and restore non-breaking behavior 2026-05-19 19:53:58 +00:00
Aishanee Shah 431155cf41 feat: implement trajectory discovery and resumption logic (Stage 2) 2026-05-19 19:53:58 +00:00
Aishanee Shah d0b05a8f2a fix: resolve conflict markers in geminiChat.ts for Stage 1 2026-05-19 19:53:58 +00:00
Aishanee Shah 865130cd54 fix: resolve conflict markers in geminiChat.ts for Stage 1 2026-05-19 19:53:39 +00:00
Aishanee Shah a7936df7c5 feat: foundation for subagent trajectories (Stage 1) 2026-05-19 15:35:01 +00:00
15 changed files with 811 additions and 558 deletions
@@ -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];
+8 -1
View File
@@ -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) {
+156 -117
View File
@@ -21,7 +21,7 @@ import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
@@ -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',
});
});
});
+331 -380
View File
@@ -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(
+4
View File
@@ -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';
}
/**
+6 -2
View File
@@ -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,
+25 -8
View File
@@ -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.
+47 -11
View File
@@ -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.',
);
+45 -17
View File
@@ -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: [] };
}
}
+1
View File
@@ -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
+4 -17
View File
@@ -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;
}