feat(cli): add /chat debug command for nightly builds (#16339)

This commit is contained in:
Abhi
2026-01-11 14:11:06 -05:00
committed by GitHub
parent 39b3f20a22
commit 0e955da171
11 changed files with 555 additions and 5 deletions
@@ -53,16 +53,29 @@ vi.mock('../ui/commands/permissionsCommand.js', async () => {
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import type { Config } from '@google/gemini-cli-core';
import { isNightly } from '@google/gemini-cli-core';
import { CommandKind } from '../ui/commands/types.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
isNightly: vi.fn().mockResolvedValue(false),
};
});
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
vi.mock('../ui/commands/agentsCommand.js', () => ({
agentsCommand: { name: 'agents' },
}));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} }));
vi.mock('../ui/commands/chatCommand.js', () => ({
chatCommand: { name: 'chat', subCommands: [] },
debugCommand: { name: 'debug' },
}));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
@@ -209,6 +222,30 @@ describe('BuiltinCommandLoader', () => {
const agentsCmd = commands.find((c) => c.name === 'agents');
expect(agentsCmd).toBeUndefined();
});
describe('chat debug command', () => {
it('should NOT add debug subcommand to chatCommand if not a nightly build', async () => {
vi.mocked(isNightly).mockResolvedValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const chatCmd = commands.find((c) => c.name === 'chat');
expect(chatCmd?.subCommands).toBeDefined();
const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug');
expect(hasDebug).toBe(false);
});
it('should add debug subcommand to chatCommand if it is a nightly build', async () => {
vi.mocked(isNightly).mockResolvedValue(true);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const chatCmd = commands.find((c) => c.name === 'chat');
expect(chatCmd?.subCommands).toBeDefined();
const hasDebug = chatCmd!.subCommands!.some((c) => c.name === 'debug');
expect(hasDebug).toBe(true);
});
});
});
describe('BuiltinCommandLoader profile', () => {
@@ -12,12 +12,12 @@ import {
type CommandContext,
} from '../ui/commands/types.js';
import type { MessageActionReturn, Config } from '@google/gemini-cli-core';
import { startupProfiler } from '@google/gemini-cli-core';
import { isNightly, startupProfiler } from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
import { chatCommand, debugCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
@@ -65,12 +65,20 @@ export class BuiltinCommandLoader implements ICommandLoader {
*/
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
const handle = startupProfiler.start('load_builtin_commands');
const isNightlyBuild = await isNightly(process.cwd());
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
...(this.config?.isAgentsEnabled() ? [agentsCommand] : []),
authCommand,
bugCommand,
chatCommand,
{
...chatCommand,
subCommands: isNightlyBuild
? [...(chatCommand.subCommands || []), debugCommand]
: chatCommand.subCommands,
},
clearCommand,
compressCommand,
copyCommand,
@@ -12,7 +12,7 @@ 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 { chatCommand, debugCommand } from './chatCommand.js';
import {
serializeHistoryToMarkdown,
exportHistoryToFile,
@@ -693,5 +693,66 @@ Hi there!`;
const result = serializeHistoryToMarkdown(history as Content[]);
expect(result).toBe(expectedMarkdown);
});
describe('debug subcommand', () => {
let mockGetLatestApiRequest: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockGetLatestApiRequest = vi.fn();
mockContext.services.config!.getLatestApiRequest =
mockGetLatestApiRequest;
vi.spyOn(process, 'cwd').mockReturnValue('/project/root');
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockFs.writeFile.mockClear();
});
it('should return an error if no API request is found', async () => {
mockGetLatestApiRequest.mockReturnValue(undefined);
const result = await debugCommand.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No recent API request found to export.',
});
expect(mockFs.writeFile).not.toHaveBeenCalled();
});
it('should convert and write the API request to a json file', async () => {
const mockRequest = {
contents: [{ role: 'user', parts: [{ text: 'test' }] }],
};
mockGetLatestApiRequest.mockReturnValue(mockRequest);
const result = await debugCommand.action?.(mockContext, '');
const expectedFilename = 'gcli-request-1234567890.json';
const expectedPath = path.join('/project/root', expectedFilename);
expect(mockFs.writeFile).toHaveBeenCalledWith(
expectedPath,
expect.stringContaining('"role": "user"'),
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Debug API request saved to ${expectedFilename}`,
});
});
it('should handle errors during file write', async () => {
const mockRequest = { contents: [] };
mockGetLatestApiRequest.mockReturnValue(mockRequest);
mockFs.writeFile.mockRejectedValue(new Error('Write failed'));
const result = await debugCommand.action?.(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Error saving debug request: Write failed',
});
});
});
});
});
@@ -27,6 +27,7 @@ import type {
} from '../types.js';
import { MessageType } from '../types.js';
import { exportHistoryToFile } from '../utils/historyExportUtils.js';
import { convertToRestPayload } from '@google/gemini-cli-core';
const getSavedChatTags = async (
context: CommandContext,
@@ -334,6 +335,46 @@ const shareCommand: SlashCommand = {
},
};
export const debugCommand: SlashCommand = {
name: 'debug',
description: 'Export the most recent API request as a JSON payload',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context): Promise<MessageActionReturn> => {
const req = context.services.config?.getLatestApiRequest();
if (!req) {
return {
type: 'message',
messageType: 'error',
content: 'No recent API request found to export.',
};
}
const restPayload = convertToRestPayload(req);
const filename = `gcli-request-${Date.now()}.json`;
const filePath = path.join(process.cwd(), filename);
try {
await fsPromises.writeFile(
filePath,
JSON.stringify(restPayload, null, 2),
);
return {
type: 'message',
messageType: 'info',
content: `Debug API request saved to ${filename}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error saving debug request: ${errorMessage}`,
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history',