/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { SlashCommand, CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import type { Content } from '@google/genai'; import { AuthType, type GeminiClient } from '@google/gemini-cli-core'; import * as fsPromises from 'node:fs/promises'; 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'; vi.mock('fs/promises', () => ({ stat: vi.fn(), readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]), writeFile: vi.fn(), })); vi.mock('../utils/historyExportUtils.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, exportHistoryToFile: vi.fn(), }; }); describe('chatCommand', () => { const mockFs = vi.mocked(fsPromises); const mockExport = vi.mocked(exportHistoryToFile); let mockContext: CommandContext; let mockGetChat: ReturnType; let mockSaveCheckpoint: ReturnType; let mockLoadCheckpoint: ReturnType; let mockDeleteCheckpoint: ReturnType; let mockGetHistory: ReturnType; const getSubCommand = ( name: 'list' | 'save' | 'resume' | 'delete' | 'share', ): SlashCommand => { const subCommand = chatCommand.subCommands?.find( (cmd) => cmd.name === name, ); if (!subCommand) { throw new Error(`/chat ${name} command not found.`); } return subCommand; }; beforeEach(() => { mockGetHistory = vi.fn().mockReturnValue([]); mockGetChat = vi.fn().mockReturnValue({ getHistory: mockGetHistory, }); mockSaveCheckpoint = vi.fn().mockResolvedValue(undefined); mockLoadCheckpoint = vi.fn().mockResolvedValue({ history: [] }); mockDeleteCheckpoint = vi.fn().mockResolvedValue(true); mockContext = createMockCommandContext({ services: { config: { getProjectRoot: () => '/project/root', getGeminiClient: () => ({ getChat: mockGetChat, }) as unknown as GeminiClient, storage: { getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', }, getContentGeneratorConfig: () => ({ authType: AuthType.LOGIN_WITH_GOOGLE, }), }, logger: { saveCheckpoint: mockSaveCheckpoint, loadCheckpoint: mockLoadCheckpoint, deleteCheckpoint: mockDeleteCheckpoint, initialize: vi.fn().mockResolvedValue(undefined), }, }, }); }); afterEach(() => { vi.restoreAllMocks(); }); it('should have the correct main command definition', () => { expect(chatCommand.name).toBe('chat'); expect(chatCommand.description).toBe('Manage conversation history'); expect(chatCommand.subCommands).toHaveLength(5); }); describe('list subcommand', () => { let listCommand: SlashCommand; beforeEach(() => { listCommand = getSubCommand('list'); }); it('should add a chat_list item to the UI', async () => { const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; const date1 = new Date(); const date2 = new Date(date1.getTime() + 1000); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockFs.readdir.mockResolvedValue(fakeFiles as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockFs.stat.mockImplementation(async (path: any): Promise => { if (path.endsWith('test1.json')) { return { mtime: date1 } as Stats; } return { mtime: date2 } as Stats; }); await listCommand?.action?.(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( { type: 'chat_list', chats: [ { name: 'test1', mtime: date1.toISOString(), }, { name: 'test2', mtime: date2.toISOString(), }, ], }, expect.any(Number), ); }); }); describe('save subcommand', () => { let saveCommand: SlashCommand; const tag = 'my-tag'; let mockCheckpointExists: ReturnType; beforeEach(() => { saveCommand = getSubCommand('save'); mockCheckpointExists = vi.fn().mockResolvedValue(false); mockContext.services.logger.checkpointExists = mockCheckpointExists; }); it('should return an error if tag is missing', async () => { const result = await saveCommand?.action?.(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing tag. Usage: /chat save ', }); }); it('should inform if conversation history is empty or only contains system context', async () => { mockGetHistory.mockReturnValue([]); let result = await saveCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found to save.', }); mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: 'context for our chat' }] }, ]); result = await saveCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found to save.', }); mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: 'context for our chat' }] }, { role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] }, { role: 'user', parts: [{ text: 'Hello, how are you?' }] }, ]); result = await saveCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation checkpoint saved with tag: ${tag}.`, }); }); it('should return confirm_action if checkpoint already exists', async () => { mockCheckpointExists.mockResolvedValue(true); mockContext.invocation = { raw: `/chat save ${tag}`, name: 'save', args: tag, }; const result = await saveCommand?.action?.(mockContext, tag); expect(mockCheckpointExists).toHaveBeenCalledWith(tag); expect(mockSaveCheckpoint).not.toHaveBeenCalled(); expect(result).toMatchObject({ type: 'confirm_action', originalInvocation: { raw: `/chat save ${tag}` }, }); // Check that prompt is a React element expect(result).toHaveProperty('prompt'); }); it('should save the conversation if overwrite is confirmed', async () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'context for our chat' }] }, { role: 'user', parts: [{ text: 'hello' }] }, ]; mockGetHistory.mockReturnValue(history); mockContext.overwriteConfirmed = true; const result = await saveCommand?.action?.(mockContext, tag); expect(mockCheckpointExists).not.toHaveBeenCalled(); // Should skip existence check expect(mockSaveCheckpoint).toHaveBeenCalledWith( { history, authType: AuthType.LOGIN_WITH_GOOGLE }, tag, ); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation checkpoint saved with tag: ${tag}.`, }); }); }); describe('resume subcommand', () => { const goodTag = 'good-tag'; const badTag = 'bad-tag'; let resumeCommand: SlashCommand; beforeEach(() => { resumeCommand = getSubCommand('resume'); }); it('should return an error if tag is missing', async () => { const result = await resumeCommand?.action?.(mockContext, ''); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing tag. Usage: /chat resume ', }); }); it('should inform if checkpoint is not found', async () => { mockLoadCheckpoint.mockResolvedValue({ history: [] }); const result = await resumeCommand?.action?.(mockContext, badTag); expect(result).toEqual({ type: 'message', messageType: 'info', content: `No saved checkpoint found with tag: ${badTag}.`, }); }); it('should resume a conversation with matching authType', async () => { const conversation: Content[] = [ { role: 'user', parts: [{ text: 'system setup' }] }, { role: 'user', parts: [{ text: 'hello gemini' }] }, { role: 'model', parts: [{ text: 'hello world' }] }, ]; mockLoadCheckpoint.mockResolvedValue({ history: conversation, authType: AuthType.LOGIN_WITH_GOOGLE, }); const result = await resumeCommand?.action?.(mockContext, goodTag); expect(result).toEqual({ type: 'load_history', history: [ { type: 'user', text: 'hello gemini' }, { type: 'gemini', text: 'hello world' }, ] as HistoryItemWithoutId[], clientHistory: conversation, }); }); it('should block resuming a conversation with mismatched authType', async () => { const conversation: Content[] = [ { role: 'user', parts: [{ text: 'system setup' }] }, { role: 'user', parts: [{ text: 'hello gemini' }] }, { role: 'model', parts: [{ text: 'hello world' }] }, ]; mockLoadCheckpoint.mockResolvedValue({ history: conversation, authType: AuthType.USE_GEMINI, }); const result = await resumeCommand?.action?.(mockContext, goodTag); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Cannot resume chat. It was saved with a different authentication method (${AuthType.USE_GEMINI}) than the current one (${AuthType.LOGIN_WITH_GOOGLE}).`, }); }); it('should resume a legacy conversation without authType', async () => { const conversation: Content[] = [ { role: 'user', parts: [{ text: 'system setup' }] }, { role: 'user', parts: [{ text: 'hello gemini' }] }, { role: 'model', parts: [{ text: 'hello world' }] }, ]; mockLoadCheckpoint.mockResolvedValue({ history: conversation }); const result = await resumeCommand?.action?.(mockContext, goodTag); expect(result).toEqual({ type: 'load_history', history: [ { type: 'user', text: 'hello gemini' }, { type: 'gemini', text: 'hello world' }, ] as HistoryItemWithoutId[], clientHistory: conversation, }); }); describe('completion', () => { it('should provide completion suggestions', async () => { const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; mockFs.readdir.mockImplementation( (async (_: string): Promise => fakeFiles) as unknown as typeof fsPromises.readdir, ); mockFs.stat.mockImplementation( (async (_: string): Promise => ({ mtime: new Date(), }) as Stats) as unknown as typeof fsPromises.stat, ); const result = await resumeCommand?.completion?.(mockContext, 'a'); expect(result).toEqual(['alpha']); }); it('should suggest filenames sorted by modified time (newest first)', async () => { const fakeFiles = ['checkpoint-test1.json', 'checkpoint-test2.json']; const date = new Date(); mockFs.readdir.mockImplementation( (async (_: string): Promise => fakeFiles) as unknown as typeof fsPromises.readdir, ); mockFs.stat.mockImplementation((async ( path: string, ): Promise => { if (path.endsWith('test1.json')) { return { mtime: date } as Stats; } return { mtime: new Date(date.getTime() + 1000) } as Stats; }) as unknown as typeof fsPromises.stat); const result = await resumeCommand?.completion?.(mockContext, ''); // Sort items by last modified time (newest first) expect(result).toEqual(['test2', 'test1']); }); }); }); describe('delete subcommand', () => { let deleteCommand: SlashCommand; const tag = 'my-tag'; beforeEach(() => { deleteCommand = getSubCommand('delete'); }); it('should return an error if tag is missing', async () => { const result = await deleteCommand?.action?.(mockContext, ' '); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Missing tag. Usage: /chat delete ', }); }); it('should return an error if checkpoint is not found', async () => { mockDeleteCheckpoint.mockResolvedValue(false); const result = await deleteCommand?.action?.(mockContext, tag); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Error: No checkpoint found with tag '${tag}'.`, }); }); it('should delete the conversation', async () => { const result = await deleteCommand?.action?.(mockContext, tag); expect(mockDeleteCheckpoint).toHaveBeenCalledWith(tag); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation checkpoint '${tag}' has been deleted.`, }); }); describe('completion', () => { it('should provide completion suggestions', async () => { const fakeFiles = ['checkpoint-alpha.json', 'checkpoint-beta.json']; mockFs.readdir.mockImplementation( (async (_: string): Promise => fakeFiles) as unknown as typeof fsPromises.readdir, ); mockFs.stat.mockImplementation( (async (_: string): Promise => ({ mtime: new Date(), }) as Stats) as unknown as typeof fsPromises.stat, ); const result = await deleteCommand?.completion?.(mockContext, 'a'); expect(result).toEqual(['alpha']); }); }); }); describe('share subcommand', () => { let shareCommand: SlashCommand; const mockHistory = [ { role: 'user', parts: [{ text: 'context' }] }, { role: 'model', parts: [{ text: 'context response' }] }, { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there!' }] }, ]; beforeEach(() => { shareCommand = getSubCommand('share'); vi.spyOn(process, 'cwd').mockReturnValue( path.resolve('/usr/local/google/home/myuser/gemini-cli'), ); vi.spyOn(Date, 'now').mockReturnValue(1234567890); mockGetHistory.mockReturnValue(mockHistory); mockFs.writeFile.mockClear(); }); it('should default to a json file if no path is provided', async () => { const result = await shareCommand?.action?.(mockContext, ''); const expectedPath = path.join( process.cwd(), 'gemini-conversation-1234567890.json', ); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation shared to ${expectedPath}`, }); }); it('should share the conversation to a JSON file', async () => { const filePath = 'my-chat.json'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation shared to ${expectedPath}`, }); }); it('should share the conversation to a Markdown file', async () => { const filePath = 'my-chat.md'; const result = await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); expect(result).toEqual({ type: 'message', messageType: 'info', content: `Conversation shared to ${expectedPath}`, }); }); it('should return an error for unsupported file extensions', async () => { const filePath = 'my-chat.txt'; const result = await shareCommand?.action?.(mockContext, filePath); expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'error', content: 'Invalid file format. Only .md and .json are supported.', }); }); it('should inform if there is no conversation to share', async () => { mockGetHistory.mockReturnValue([ { role: 'user', parts: [{ text: 'context' }] }, ]); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); expect(mockExport).not.toHaveBeenCalled(); expect(result).toEqual({ type: 'message', messageType: 'info', content: 'No conversation found to share.', }); }); it('should handle errors during file writing', async () => { const error = new Error('Permission denied'); mockExport.mockRejectedValue(error); const result = await shareCommand?.action?.(mockContext, 'my-chat.json'); expect(result).toEqual({ type: 'message', messageType: 'error', content: `Error sharing conversation: ${error.message}`, }); }); it('should output valid JSON schema', async () => { const filePath = 'my-chat.json'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.json'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); }); it('should output correct markdown format', async () => { const filePath = 'my-chat.md'; await shareCommand?.action?.(mockContext, filePath); const expectedPath = path.join(process.cwd(), 'my-chat.md'); expect(mockExport).toHaveBeenCalledWith({ history: mockHistory, filePath: expectedPath, }); }); }); describe('serializeHistoryToMarkdown', () => { it('should correctly serialize chat history to Markdown with icons', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [{ text: 'Hi there!' }] }, { role: 'user', parts: [{ text: 'How are you?' }] }, ]; const expectedMarkdown = '## USER 🧑‍💻\n\nHello\n\n---\n\n' + '## MODEL ✨\n\nHi there!\n\n---\n\n' + '## USER 🧑‍💻\n\nHow are you?'; const result = serializeHistoryToMarkdown(history); expect(result).toBe(expectedMarkdown); }); it('should handle empty history', () => { const history: Content[] = []; const result = serializeHistoryToMarkdown(history); expect(result).toBe(''); }); it('should handle items with no text parts', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'Hello' }] }, { role: 'model', parts: [] }, { role: 'user', parts: [{ text: 'How are you?' }] }, ]; const expectedMarkdown = `## USER 🧑‍💻 Hello --- ## MODEL ✨ --- ## USER 🧑‍💻 How are you?`; const result = serializeHistoryToMarkdown(history); expect(result).toBe(expectedMarkdown); }); it('should correctly serialize function calls and responses', () => { const history: Content[] = [ { role: 'user', parts: [{ text: 'Please call a function.' }], }, { role: 'model', parts: [ { functionCall: { name: 'my-function', args: { arg1: 'value1' }, }, }, ], }, { role: 'user', parts: [ { functionResponse: { name: 'my-function', response: { result: 'success' }, }, }, ], }, ]; const expectedMarkdown = `## USER 🧑‍💻 Please call a function. --- ## MODEL ✨ **Tool Command**: \`\`\`json { "name": "my-function", "args": { "arg1": "value1" } } \`\`\` --- ## USER 🧑‍💻 **Tool Response**: \`\`\`json { "name": "my-function", "response": { "result": "success" } } \`\`\``; const result = serializeHistoryToMarkdown(history); expect(result).toBe(expectedMarkdown); }); it('should handle items with undefined role', () => { const history: Array> = [ { role: 'user', parts: [{ text: 'Hello' }] }, { parts: [{ text: 'Hi there!' }] }, ]; const expectedMarkdown = `## USER 🧑‍💻 Hello --- ## MODEL ✨ Hi there!`; const result = serializeHistoryToMarkdown(history as Content[]); expect(result).toBe(expectedMarkdown); }); }); });