Compare commits

...

5 Commits

16 changed files with 532 additions and 65 deletions
@@ -9,7 +9,11 @@ import open from 'open';
import path from 'node:path';
import { bugCommand } from './bugCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { getVersion, type Config } from '@google/gemini-cli-core';
import {
getVersion,
type Config,
type ConversationRecord,
} from '@google/gemini-cli-core';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatBytes } from '../utils/formatters.js';
import { MessageType } from '../types.js';
@@ -157,6 +161,8 @@ describe('bugCommand', () => {
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const mockGetSubagentTrajectories = vi.fn().mockResolvedValue({});
const mockGetConversation = vi.fn().mockReturnValue({ messages: [] });
const mockContext = createMockCommandContext({
services: {
agentContext: {
@@ -173,6 +179,8 @@ describe('bugCommand', () => {
geminiClient: {
getChat: () => ({
getHistory: () => history,
getSubagentTrajectories: mockGetSubagentTrajectories,
getConversation: mockGetConversation,
}),
},
},
@@ -187,8 +195,10 @@ describe('bugCommand', () => {
'bug-report-history-1704067200000.json',
);
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
messages: [],
filePath: expectedPath,
trajectories: {},
history,
});
const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0];
@@ -203,6 +213,60 @@ describe('bugCommand', () => {
expect(messageText).toContain(encodeURIComponent(reminder));
});
it('should include subagent trajectories in history export if available', async () => {
const history = [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'hi' }] },
];
const trajectories = {
'subagent-1': {
sessionId: 'subagent-1',
messages: [],
} as unknown as ConversationRecord,
};
const mockGetSubagentTrajectories = vi.fn().mockResolvedValue(trajectories);
const mockGetConversation = vi.fn().mockReturnValue({ messages: [] });
const mockContext = createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'gemini-pro',
getBugCommand: () => undefined,
getIdeMode: () => true,
getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }),
storage: {
getProjectTempDir: () => '/tmp/gemini',
},
getSessionId: vi.fn().mockReturnValue('test-session-id'),
} as unknown as Config,
geminiClient: {
getChat: () => ({
getHistory: () => history,
getSubagentTrajectories: mockGetSubagentTrajectories,
getConversation: mockGetConversation,
}),
},
},
},
});
if (!bugCommand.action) throw new Error('Action is not defined');
await bugCommand.action(mockContext, 'Bug with trajectories');
const expectedPath = path.join(
'/tmp/gemini',
'bug-report-history-1704067200000.json',
);
expect(mockGetSubagentTrajectories).toHaveBeenCalled();
expect(exportHistoryToFile).toHaveBeenCalledWith({
history,
messages: [],
filePath: expectedPath,
trajectories,
});
});
it('should use a custom URL template from config if provided', async () => {
const customTemplate =
'https://internal.bug-tracker.com/new?desc={title}&details={info}';
+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) {
@@ -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: [] });
@@ -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',
@@ -230,7 +241,12 @@ describe('chatCommand', () => {
expect(mockCheckpointExists).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 +308,8 @@ describe('chatCommand', () => {
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
messages: undefined,
version: undefined,
});
});
@@ -332,6 +350,8 @@ describe('chatCommand', () => {
{ type: 'gemini', text: 'hello world' },
] as HistoryItemWithoutId[],
clientHistory: conversation,
messages: undefined,
version: undefined,
});
});
@@ -463,8 +483,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 +500,10 @@ describe('chatCommand', () => {
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
expect(result).toEqual({
type: 'message',
@@ -493,8 +517,10 @@ describe('chatCommand', () => {
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
expect(result).toEqual({
type: 'message',
@@ -543,8 +569,10 @@ describe('chatCommand', () => {
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
});
@@ -553,8 +581,10 @@ describe('chatCommand', () => {
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
expect(mockExport).toHaveBeenCalledWith({
history: mockHistory,
messages: [],
filePath: expectedPath,
trajectories: {},
history: mockHistory,
});
});
});
+12 -3
View File
@@ -139,7 +139,12 @@ const saveCommand: SlashCommand = {
const history = chat.getHistory();
if (history.length > INITIAL_HISTORY_LENGTH) {
const authType = config?.getContentGeneratorConfig()?.authType;
await logger.saveCheckpoint({ history, authType }, tag);
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',
@@ -178,7 +183,7 @@ const resumeCheckpointCommand: SlashCommand = {
const config = context.services.agentContext?.config;
await logger.initialize();
const checkpoint = await logger.loadCheckpoint(tag);
const conversation = checkpoint.history;
const conversation = checkpoint.history ?? [];
if (conversation.length === 0) {
return {
@@ -228,6 +233,8 @@ const resumeCheckpointCommand: SlashCommand = {
type: 'load_history',
history: uiHistory,
clientHistory: conversation,
messages: checkpoint.messages,
version: checkpoint.version,
};
},
completion: async (context, partialArg) => {
@@ -324,7 +331,9 @@ const shareCommand: SlashCommand = {
}
try {
await exportHistoryToFile({ history, filePath });
const trajectories = await chat.getSubagentTrajectories();
const messages = chat.getConversation()?.messages ?? [];
await exportHistoryToFile({ messages, filePath, trajectories, history });
return {
type: 'message',
messageType: 'info',
@@ -549,7 +549,16 @@ export const useSlashCommandProcessor = (
}
}
case 'load_history': {
config?.getGeminiClient()?.setHistory(result.clientHistory);
const client = config?.getGeminiClient();
if (result.version === '2.0' && client) {
await client.resumeChat(
[...result.clientHistory],
undefined,
result.messages,
);
} else {
client?.setHistory(result.clientHistory);
}
fullCommandContext.ui.clear();
result.history.forEach((item, index) => {
fullCommandContext.ui.addItem(item, index);
@@ -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,23 @@ 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);
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 ConversationRecord,
type MessageRecord,
} 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.
+19 -10
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,18 +543,18 @@ 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 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({ 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({ messages: [] });
});
it('should return an empty history if the file contains invalid JSON', async () => {
@@ -560,14 +569,14 @@ describe('Logger', () => {
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const loadedCheckpoint = await logger.loadCheckpoint(tag);
expect(loadedCheckpoint).toEqual({ history: [] });
expect(loadedCheckpoint).toEqual({ 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 +586,7 @@ describe('Logger', () => {
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const loadedCheckpoint = await uninitializedLogger.loadCheckpoint('tag');
expect(loadedCheckpoint).toEqual({ history: [] });
expect(loadedCheckpoint).toEqual({ messages: [] });
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
+44 -12
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 {
type ConversationRecord,
type MessageRecord,
} from '../services/chatRecordingService.js';
import { reconstructHistory } from '../utils/history-reconstruction.js';
const LOG_FILE_NAME = 'logs.json';
@@ -27,8 +33,22 @@ export interface LogEntry {
}
export interface Checkpoint {
history: readonly Content[];
/**
* The rich message records which are the source of truth for the session.
*/
messages: MessageRecord[];
/**
* The version of the checkpoint format.
* Version 2.0 uses messages as the source of truth and reconstructs history.
*/
version?: '2.0';
/**
* The standard history array used for model requests.
* Only included in legacy checkpoints (pre-2.0).
*/
history?: readonly Content[];
authType?: AuthType;
trajectories?: Record<string, ConversationRecord>;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
@@ -347,7 +367,7 @@ export class Logger {
debugLogger.error(
'Logger not initialized or checkpoint file path not set. Cannot load checkpoint.',
);
return { history: [] };
return { messages: [] };
}
const path = await this._getCheckpointPath(tag);
@@ -359,34 +379,46 @@ export class Logger {
// 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 checkpoint = parsedContent as Checkpoint;
// Version 2.0: Reconstruct history from messages
if (checkpoint.version === '2.0' && checkpoint.messages) {
return {
...checkpoint,
history: reconstructHistory(checkpoint.messages),
};
}
// Legacy Object format (pre-2.0, had history but maybe not messages)
if (checkpoint.history) {
return {
...checkpoint,
messages: checkpoint.messages ?? [],
};
}
}
debugLogger.warn(
`Checkpoint file at ${path} has an unknown format. Returning empty checkpoint.`,
);
return { history: [] };
return { 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 { messages: [] };
}
debugLogger.error(
`Failed to read or parse checkpoint file ${path}:`,
error,
);
return { history: [] };
return { 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
@@ -1407,4 +1407,112 @@ describe('ChatRecordingService', () => {
expect(record2!.messages[1].id).toBe('h2');
});
});
describe('getSubagentTrajectories', () => {
it('should recursively collect subagent trajectories', async () => {
await chatRecordingService.initialize();
// Setup a main conversation with a subagent call
const subagentId = 'sub-1';
chatRecordingService.recordToolCalls('gemini-pro', [
{
id: 'call-1',
name: 'invoke_agent',
args: { agent_name: 'test-agent', prompt: 'test' },
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: subagentId,
},
]);
// Mock the subagent session file
const tempDir = mockConfig.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const subagentDir = path.join(chatsDir, 'test-session-id');
const subagentFile = path.join(subagentDir, `${subagentId}.jsonl`);
await fs.promises.mkdir(subagentDir, { recursive: true });
// Subagent conversation has another subagent call
const subSubagentId = 'sub-2';
const subagentConversation: ConversationRecord = {
sessionId: subagentId,
projectHash: 'mocked-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: 'subagent',
messages: [
{
id: 'msg-1',
type: 'gemini',
timestamp: new Date().toISOString(),
content: [],
toolCalls: [
{
id: 'call-2',
name: 'invoke_agent',
args: { agent_name: 'inner-agent', prompt: 'inner' },
status: CoreToolCallStatus.Success,
timestamp: new Date().toISOString(),
agentId: subSubagentId,
},
],
},
],
};
await fs.promises.writeFile(
subagentFile,
JSON.stringify(subagentConversation) + '\n',
);
// Mock the sub-subagent session file
const subSubagentDir = path.join(chatsDir, subagentId);
const subSubagentFile = path.join(
subSubagentDir,
`${subSubagentId}.jsonl`,
);
await fs.promises.mkdir(subSubagentDir, { recursive: true });
const subSubagentConversation: ConversationRecord = {
sessionId: subSubagentId,
projectHash: 'mocked-hash',
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
kind: 'subagent',
messages: [
{
id: 'msg-2',
type: 'gemini',
timestamp: new Date().toISOString(),
content: [{ text: 'done' }],
},
],
};
await fs.promises.writeFile(
subSubagentFile,
JSON.stringify(subSubagentConversation) + '\n',
);
const trajectories = await chatRecordingService.getSubagentTrajectories();
expect(trajectories).toHaveProperty(subagentId);
expect(trajectories).toHaveProperty(subSubagentId);
expect(trajectories[subagentId].sessionId).toBe(subagentId);
expect(trajectories[subSubagentId].sessionId).toBe(subSubagentId);
});
it('should return empty object if no subagents are called', async () => {
await chatRecordingService.initialize();
chatRecordingService.recordMessage({
type: 'user',
content: 'hello',
model: 'gemini-pro',
});
const trajectories = await chatRecordingService.getSubagentTrajectories();
expect(trajectories).toEqual({});
});
});
});
@@ -694,6 +694,16 @@ export class ChatRecordingService {
return this.conversationFile;
}
/**
* 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
@@ -976,6 +986,74 @@ export class ChatRecordingService {
throw error;
}
}
/**
* Recursively collects all subagent trajectories associated with this session.
*/
async getSubagentTrajectories(): Promise<Record<string, ConversationRecord>> {
const allTrajectories: Record<string, ConversationRecord> = {};
await this.collectSubagentTrajectories(
this.sessionId,
this.getConversation(),
allTrajectories,
);
return allTrajectories;
}
private async collectSubagentTrajectories(
sessionId: string,
conversation: ConversationRecord | null,
allTrajectories: Record<string, ConversationRecord>,
) {
if (!conversation) return;
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 && !allTrajectories[toolCall.agentId]) {
agentIds.add(toolCall.agentId);
}
}
}
}
if (agentIds.size === 0) return;
const tempDir = this.context.config.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const safeParentId = sanitizeFilenamePart(sessionId);
if (!safeParentId) return;
const loadPromises = Array.from(agentIds).map(async (agentId) => {
const subagentFilePath = path.join(
chatsDir,
safeParentId,
`${agentId}.jsonl`,
);
try {
const subagentConversation =
await loadConversationRecord(subagentFilePath);
if (subagentConversation) {
allTrajectories[agentId] = subagentConversation;
// Recursively collect for this subagent
await this.collectSubagentTrajectories(
agentId,
subagentConversation,
allTrajectories,
);
}
} catch (err) {
debugLogger.warn(
`Failed to load subagent trajectory for ${agentId}:`,
err,
);
}
});
await Promise.all(loadPromises);
}
}
async function parseLegacyRecordFallback(
+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;
}