feat: include subagent trajectories in chat save and bug reports

This commit is contained in:
Aishanee Shah
2026-05-11 20:45:34 +00:00
parent 27a39b04b0
commit d1f78161a0
9 changed files with 213 additions and 6 deletions
@@ -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];
+6 -1
View File
@@ -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) {
@@ -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: {},
});
});
});
+4 -2
View File
@@ -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',
@@ -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<string, ConversationRecord>;
}
/**
@@ -61,12 +63,16 @@ export interface ExportHistoryOptions {
export async function exportHistoryToFile(
options: ExportHistoryOptions,
): Promise<void> {
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 {
+8
View File
@@ -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<Record<string, ConversationRecord>> {
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.
+2
View File
@@ -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<string, ConversationRecord>;
}
// This regex matches any character that is NOT a letter (a-z, A-Z),
@@ -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({});
});
});
});
@@ -920,6 +920,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(