diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index a51c7af12c..8b287c9cb5 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -157,6 +157,7 @@ describe('bugCommand', () => { { role: 'user', parts: [{ text: 'hello' }] }, { role: 'model', parts: [{ text: 'hi' }] }, ]; + const mockGetSubagentTrajectories = vi.fn().mockResolvedValue({}); const mockContext = createMockCommandContext({ services: { agentContext: { @@ -173,6 +174,7 @@ describe('bugCommand', () => { geminiClient: { getChat: () => ({ getHistory: () => history, + getSubagentTrajectories: mockGetSubagentTrajectories, }), }, }, @@ -189,6 +191,7 @@ describe('bugCommand', () => { expect(exportHistoryToFile).toHaveBeenCalledWith({ history, filePath: expectedPath, + trajectories: {}, }); const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0]; diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 19bc7183d0..ed226636cc 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -88,7 +88,12 @@ 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(); + await exportHistoryToFile({ + history, + filePath: historyFilePath, + trajectories, + }); 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) { diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 04d0753ee8..5a7e53c690 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -63,6 +63,7 @@ describe('chatCommand', () => { mockGetHistory = vi.fn().mockReturnValue([]); mockGetChat = vi.fn().mockReturnValue({ getHistory: mockGetHistory, + getSubagentTrajectories: vi.fn().mockResolvedValue({}), }); mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined); mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] }); @@ -230,7 +231,7 @@ describe('chatCommand', () => { expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check expect(mockSaveCheckpoint).toHaveBeenCalledWith( - { history, authType: AuthType.LOGIN_WITH_GOOGLE }, + { history, authType: AuthType.LOGIN_WITH_GOOGLE, trajectories: {} }, tag, ); expect(result).toEqual({ @@ -465,6 +466,7 @@ describe('chatCommand', () => { expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, + trajectories: {}, }); expect(result).toEqual({ type: 'message', @@ -480,6 +482,7 @@ describe('chatCommand', () => { expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, + trajectories: {}, }); expect(result).toEqual({ type: 'message', @@ -495,6 +498,7 @@ describe('chatCommand', () => { expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, + trajectories: {}, }); expect(result).toEqual({ type: 'message', @@ -545,6 +549,7 @@ describe('chatCommand', () => { expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, + trajectories: {}, }); }); @@ -555,6 +560,7 @@ describe('chatCommand', () => { expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, + trajectories: {}, }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 05fd081dfb..dd67ae1e35 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -139,7 +139,8 @@ 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(); + await logger.saveCheckpoint({ history, authType, trajectories }, tag); return { type: 'message', messageType: 'info', @@ -324,7 +325,8 @@ const shareCommand: SlashCommand = { } try { - await exportHistoryToFile({ history, filePath }); + const trajectories = await chat.getSubagentTrajectories(); + await exportHistoryToFile({ history, filePath, trajectories }); return { type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/utils/historyExportUtils.ts b/packages/cli/src/ui/utils/historyExportUtils.ts index 325c880b2b..cff446d83f 100644 --- a/packages/cli/src/ui/utils/historyExportUtils.ts +++ b/packages/cli/src/ui/utils/historyExportUtils.ts @@ -7,6 +7,7 @@ import * as fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { Content } from '@google/genai'; +import type { ConversationRecord } from '@google/gemini-cli-core'; /** * Serializes chat history to a Markdown string. @@ -53,6 +54,7 @@ export function serializeHistoryToMarkdown( export interface ExportHistoryOptions { history: readonly Content[]; filePath: string; + trajectories?: Record; } /** @@ -61,12 +63,16 @@ export interface ExportHistoryOptions { export async function exportHistoryToFile( options: ExportHistoryOptions, ): Promise { - const { history, filePath } = options; + const { history, filePath, trajectories } = options; const extension = path.extname(filePath).toLowerCase(); let content: string; if (extension === '.json') { - content = JSON.stringify(history, null, 2); + if (trajectories && Object.keys(trajectories).length > 0) { + content = JSON.stringify({ history, trajectories }, null, 2); + } else { + content = JSON.stringify(history, null, 2); + } } else if (extension === '.md') { content = serializeHistoryToMarkdown(history); } else { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 6a728884a5..eb8a81bd6d 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -39,6 +39,7 @@ import { import { ChatRecordingService, type ResumedSessionData, + type ConversationRecord, } from '../services/chatRecordingService.js'; import { ContentRetryEvent, @@ -1222,6 +1223,13 @@ export class GeminiChat { return this.chatRecordingService; } + /** + * Gets all subagent trajectories associated with this chat session. + */ + async getSubagentTrajectories(): Promise> { + return this.chatRecordingService.getSubagentTrajectories(); + } + /** * Records completed tool calls with full metadata. * This is called by external components when tool calls complete, before sending responses to Gemini. diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 5a937b4edc..58a73fdd68 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -11,6 +11,7 @@ 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 } from '../services/chatRecordingService.js'; const LOG_FILE_NAME = 'logs.json'; @@ -29,6 +30,7 @@ export interface LogEntry { export interface Checkpoint { history: readonly Content[]; authType?: AuthType; + trajectories?: Record; } // This regex matches any character that is NOT a letter (a-z, A-Z), diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 7af8380a5a..0a82a68035 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -1315,4 +1315,111 @@ describe('ChatRecordingService', () => { mkdirSyncSpy.mockRestore(); }); }); + + 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 subagentDir = path.join(tempDir, 'chats', '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(tempDir, 'chats', 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({}); + }); + }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index e070a1c542..34948951d1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -920,6 +920,74 @@ export class ChatRecordingService { throw error; } } + + /** + * Recursively collects all subagent trajectories associated with this session. + */ + async getSubagentTrajectories(): Promise> { + const allTrajectories: Record = {}; + await this.collectSubagentTrajectories( + this.sessionId, + this.getConversation(), + allTrajectories, + ); + return allTrajectories; + } + + private async collectSubagentTrajectories( + sessionId: string, + conversation: ConversationRecord | null, + allTrajectories: Record, + ) { + if (!conversation) return; + + const agentIds = new Set(); + 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(