mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 22:02:59 -07:00
feat: include subagent trajectories in chat save and bug reports
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user