diff --git a/.gitignore b/.gitignore index 1222895148..d9cfed7b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ patch_output.log .genkit .gemini-clipboard/ +.eslintcache diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 9031e918f5..78f6bfb4a1 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import open from 'open'; +import path from 'node:path'; import { bugCommand } from './bugCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { getVersion } from '@google/gemini-cli-core'; @@ -15,6 +16,16 @@ import { formatMemoryUsage } from '../utils/formatters.js'; // Mock dependencies vi.mock('open'); vi.mock('../utils/formatters.js'); +vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + exportHistoryToFile: vi.fn(), + }; +}); +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -27,6 +38,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }, sessionId: 'test-session-id', getVersion: vi.fn(), + INITIAL_HISTORY_LENGTH: 1, + debugLogger: { + error: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, }; }); vi.mock('node:process', () => ({ @@ -52,11 +70,14 @@ describe('bugCommand', () => { vi.mocked(getVersion).mockResolvedValue('0.1.0'); vi.mocked(formatMemoryUsage).mockReturnValue('100 MB'); vi.stubEnv('SANDBOX', 'gemini-test'); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:00Z')); }); afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); + vi.useRealTimers(); }); it('should generate the default GitHub issue URL', async () => { @@ -66,6 +87,11 @@ describe('bugCommand', () => { getModel: () => 'gemini-pro', getBugCommand: () => undefined, getIdeMode: () => true, + getGeminiClient: () => ({ + getChat: () => ({ + getHistory: () => [], + }), + }), }, }, }); @@ -86,13 +112,58 @@ describe('bugCommand', () => { * **Kitty Keyboard Protocol:** Supported * **IDE Client:** VSCode `; - const expectedUrl = - 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=' + - encodeURIComponent(expectedInfo); + const expectedUrl = `https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title=A%20test%20bug&info=${encodeURIComponent(expectedInfo)}&problem=A%20test%20bug`; expect(open).toHaveBeenCalledWith(expectedUrl); }); + it('should export chat history if available', async () => { + const history = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + ]; + const mockContext = createMockCommandContext({ + services: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getGeminiClient: () => ({ + getChat: () => ({ + getHistory: () => history, + }), + }), + storage: { + getProjectTempDir: () => '/tmp/gemini', + }, + }, + }, + }); + + if (!bugCommand.action) throw new Error('Action is not defined'); + await bugCommand.action(mockContext, 'Bug with history'); + + const expectedPath = path.join( + '/tmp/gemini', + 'bug-report-history-1704067200000.json', + ); + expect(exportHistoryToFile).toHaveBeenCalledWith({ + history, + filePath: expectedPath, + }); + + const addItemCall = vi.mocked(mockContext.ui.addItem).mock.calls[0]; + const messageText = addItemCall[0].text; + expect(messageText).toContain(expectedPath); + expect(messageText).toContain('šŸ“„ **Chat History Exported**'); + expect(messageText).toContain('Privacy Disclaimer:'); + expect(messageText).not.toContain('additional-context='); + expect(messageText).toContain('problem='); + const reminder = + '\n\n[ACTION REQUIRED] šŸ“Ž PLEASE ATTACH THE EXPORTED CHAT HISTORY JSON FILE TO THIS ISSUE IF YOU FEEL COMFORTABLE SHARING IT.'; + expect(messageText).toContain(encodeURIComponent(reminder)); + }); + it('should use a custom URL template from config if provided', async () => { const customTemplate = 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; @@ -102,6 +173,11 @@ describe('bugCommand', () => { getModel: () => 'gemini-pro', getBugCommand: () => ({ urlTemplate: customTemplate }), getIdeMode: () => true, + getGeminiClient: () => ({ + getChat: () => ({ + getHistory: () => [], + }), + }), }, }, }); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 21df2028cc..6c3a5a70d1 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -14,8 +14,16 @@ import { import { MessageType } from '../types.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { formatMemoryUsage } from '../utils/formatters.js'; -import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core'; +import { + IdeClient, + sessionId, + getVersion, + INITIAL_HISTORY_LENGTH, + debugLogger, +} from '@google/gemini-cli-core'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; +import path from 'node:path'; export const bugCommand: SlashCommand = { name: 'bug', @@ -63,8 +71,31 @@ export const bugCommand: SlashCommand = { info += `* **IDE Client:** ${ideClient}\n`; } + const chat = config?.getGeminiClient()?.getChat(); + const history = chat?.getHistory() || []; + let historyFileMessage = ''; + let problemValue = bugDescription; + + if (history.length > INITIAL_HISTORY_LENGTH) { + const tempDir = config?.storage?.getProjectTempDir(); + if (tempDir) { + const historyFileName = `bug-report-history-${Date.now()}.json`; + const historyFilePath = path.join(tempDir, historyFileName); + try { + await exportHistoryToFile({ history, filePath: historyFilePath }); + 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) { + const errorMessage = err instanceof Error ? err.message : String(err); + debugLogger.error( + `Failed to export chat history for bug report: ${errorMessage}`, + ); + } + } + } + let bugReportUrl = - 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}'; + 'https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml&title={title}&info={info}&problem={problem}'; const bugCommandSettings = config?.getBugCommand(); if (bugCommandSettings?.urlTemplate) { @@ -73,12 +104,13 @@ export const bugCommand: SlashCommand = { bugReportUrl = bugReportUrl .replace('{title}', encodeURIComponent(bugDescription)) - .replace('{info}', encodeURIComponent(info)); + .replace('{info}', encodeURIComponent(info)) + .replace('{problem}', encodeURIComponent(problemValue)); context.ui.addItem( { type: MessageType.INFO, - text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}`, + text: `To submit your bug report, please open the following URL in your browser:\n${bugReportUrl}${historyFileMessage}`, }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index c934c29dfd..20d0be1e06 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Mocked } from 'vitest'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { SlashCommand, CommandContext } from './types.js'; @@ -13,7 +12,11 @@ import type { Content } from '@google/genai'; import { AuthType, type GeminiClient } from '@google/gemini-cli-core'; import * as fsPromises from 'node:fs/promises'; -import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js'; +import { chatCommand } from './chatCommand.js'; +import { + serializeHistoryToMarkdown, + exportHistoryToFile, +} from '../utils/historyExportUtils.js'; import type { Stats } from 'node:fs'; import type { HistoryItemWithoutId } from '../types.js'; import path from 'node:path'; @@ -24,8 +27,18 @@ vi.mock('fs/promises', () => ({ writeFile: vi.fn(), })); +vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + exportHistoryToFile: vi.fn(), + }; +}); + describe('chatCommand', () => { - const mockFs = fsPromises as Mocked; + const mockFs = vi.mocked(fsPromises); + const mockExport = vi.mocked(exportHistoryToFile); let mockContext: CommandContext; let mockGetChat: ReturnType; @@ -448,9 +461,10 @@ describe('chatCommand', () => { process.cwd(), 'gemini-conversation-1234567890.json', ); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2)); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -462,9 +476,10 @@ describe('chatCommand', () => { const filePath = 'my-chat.json'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2)); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -476,30 +491,10 @@ describe('chatCommand', () => { const filePath = 'my-chat.md'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - const expectedContent = `## USER šŸ§‘ā€šŸ’» - -context - ---- - -## MODEL ✨ - -context response - ---- - -## USER šŸ§‘ā€šŸ’» - -Hello - ---- - -## MODEL ✨ - -Hi there!`; - expect(actualContent).toEqual(expectedContent); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, + }); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -510,7 +505,7 @@ Hi there!`; it('should return an error for unsupported file extensions', async () => { const filePath = 'my-chat.txt'; const result = await shareCommand?.action?.(mockContext, filePath); - expect(mockFs.writeFile).not.toHaveBeenCalled(); + expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', @@ -523,7 +518,7 @@ Hi there!`; { role: 'user', parts: [{ text: 'context' }] }, ]); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); - expect(mockFs.writeFile).not.toHaveBeenCalled(); + expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -533,7 +528,7 @@ Hi there!`; it('should handle errors during file writing', async () => { const error = new Error('Permission denied'); - mockFs.writeFile.mockRejectedValue(error); + mockExport.mockRejectedValue(error); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); expect(result).toEqual({ type: 'message', @@ -546,14 +541,9 @@ Hi there!`; const filePath = 'my-chat.json'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - const parsedContent = JSON.parse(actualContent as string); - expect(Array.isArray(parsedContent)).toBe(true); - parsedContent.forEach((item: Content) => { - expect(item).toHaveProperty('role'); - expect(item).toHaveProperty('parts'); - expect(Array.isArray(item.parts)).toBe(true); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, }); }); @@ -561,15 +551,9 @@ Hi there!`; const filePath = 'my-chat.md'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); - const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0]; - expect(actualPath).toEqual(expectedPath); - const entries = (actualContent as string).split('\n\n---\n\n'); - expect(entries.length).toBe(mockHistory.length); - entries.forEach((entry: string, index: number) => { - const { role, parts } = mockHistory[index]; - const text = parts.map((p) => p.text).join(''); - const roleIcon = role === 'user' ? 'šŸ§‘ā€šŸ’»' : '✨'; - expect(entry).toBe(`## ${role.toUpperCase()} ${roleIcon}\n\n${text}`); + expect(mockExport).toHaveBeenCalledWith({ + history: mockHistory, + filePath: expectedPath, }); }); }); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 4b0078309c..7c9b632b1a 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -26,7 +26,7 @@ import type { ChatDetail, } from '../types.js'; import { MessageType } from '../types.js'; -import type { Content } from '@google/genai'; +import { exportHistoryToFile } from '../utils/historyExportUtils.js'; const getSavedChatTags = async ( context: CommandContext, @@ -272,38 +272,6 @@ const deleteCommand: SlashCommand = { }, }; -export function serializeHistoryToMarkdown(history: Content[]): string { - return history - .map((item) => { - const text = - item.parts - ?.map((part) => { - if (part.text) { - return part.text; - } - if (part.functionCall) { - return `**Tool Command**:\n\`\`\`json\n${JSON.stringify( - part.functionCall, - null, - 2, - )}\n\`\`\``; - } - if (part.functionResponse) { - return `**Tool Response**:\n\`\`\`json\n${JSON.stringify( - part.functionResponse, - null, - 2, - )}\n\`\`\``; - } - return ''; - }) - .join('') || ''; - const roleIcon = item.role === 'user' ? 'šŸ§‘ā€šŸ’»' : '✨'; - return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`; - }) - .join('\n\n---\n\n'); -} - const shareCommand: SlashCommand = { name: 'share', description: @@ -348,15 +316,8 @@ const shareCommand: SlashCommand = { }; } - let content = ''; - if (extension === '.json') { - content = JSON.stringify(history, null, 2); - } else { - content = serializeHistoryToMarkdown(history); - } - try { - await fsPromises.writeFile(filePath, content); + await exportHistoryToFile({ history, filePath }); return { type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/utils/historyExportUtils.ts b/packages/cli/src/ui/utils/historyExportUtils.ts new file mode 100644 index 0000000000..85a53dd330 --- /dev/null +++ b/packages/cli/src/ui/utils/historyExportUtils.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fsPromises from 'node:fs/promises'; +import path from 'node:path'; +import type { Content } from '@google/genai'; + +/** + * Serializes chat history to a Markdown string. + */ +export function serializeHistoryToMarkdown(history: Content[]): string { + return history + .map((item) => { + const text = + item.parts + ?.map((part) => { + if (part.text) { + return part.text; + } + if (part.functionCall) { + return ( + `**Tool Command**:\n` + + '```json\n' + + JSON.stringify(part.functionCall, null, 2) + + '\n```' + ); + } + if (part.functionResponse) { + return ( + `**Tool Response**:\n` + + '```json\n' + + JSON.stringify(part.functionResponse, null, 2) + + '\n```' + ); + } + return ''; + }) + .join('') || ''; + const roleIcon = item.role === 'user' ? 'šŸ§‘ā€šŸ’»' : '✨'; + return `## ${(item.role || 'model').toUpperCase()} ${roleIcon}\n\n${text}`; + }) + .join('\n\n---\n\n'); +} + +/** + * Options for exporting chat history. + */ +export interface ExportHistoryOptions { + history: Content[]; + filePath: string; +} + +/** + * Exports chat history to a file (JSON or Markdown). + */ +export async function exportHistoryToFile( + options: ExportHistoryOptions, +): Promise { + const { history, filePath } = options; + const extension = path.extname(filePath).toLowerCase(); + + let content: string; + if (extension === '.json') { + content = JSON.stringify(history, null, 2); + } else if (extension === '.md') { + content = serializeHistoryToMarkdown(history); + } else { + throw new Error( + `Unsupported file extension: ${extension}. Use .json or .md.`, + ); + } + + const dir = path.dirname(filePath); + await fsPromises.mkdir(dir, { recursive: true }); + await fsPromises.writeFile(filePath, content, 'utf-8'); +}