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

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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',
});
});
});
});
});

View File

@@ -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',

View File

@@ -73,6 +73,7 @@ import type { ModelConfigServiceConfig } from '../services/modelConfigService.js
import { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { ContextManager } from '../services/contextManager.js';
import type { GenerateContentParameters } from '@google/genai';
// Re-export OAuth config type
export type { MCPOAuthConfig, AnyToolInvocation };
@@ -499,6 +500,7 @@ export class Config {
private contextManager?: ContextManager;
private terminalBackground: string | undefined = undefined;
private remoteAdminSettings: GeminiCodeAssistSetting | undefined;
private latestApiRequest: GenerateContentParameters | undefined;
constructor(params: ConfigParameters) {
this.sessionId = params.sessionId;
@@ -897,6 +899,14 @@ export class Config {
return this.terminalBackground;
}
getLatestApiRequest(): GenerateContentParameters | undefined {
return this.latestApiRequest;
}
setLatestApiRequest(req: GenerateContentParameters): void {
this.latestApiRequest = req;
}
getRemoteAdminSettings(): GeminiCodeAssistSetting | undefined {
return this.remoteAdminSettings;
}

View File

@@ -218,6 +218,54 @@ describe('LoggingContentGenerator', () => {
const errorEvent = vi.mocked(logApiError).mock.calls[0][1];
expect(errorEvent.duration_ms).toBe(1000);
});
it('should set latest API request in config for main agent requests', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
// Main agent prompt IDs end with exactly 8 hashes and a turn counter
const mainAgentPromptId = 'session-uuid########1';
config.setLatestApiRequest = vi.fn();
async function* createAsyncGenerator() {
yield { candidates: [] } as unknown as GenerateContentResponse;
}
vi.mocked(wrapped.generateContentStream).mockResolvedValue(
createAsyncGenerator(),
);
await loggingContentGenerator.generateContentStream(
req,
mainAgentPromptId,
);
expect(config.setLatestApiRequest).toHaveBeenCalledWith(req);
});
it('should NOT set latest API request in config for sub-agent requests', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
// Sub-agent prompt IDs contain fewer hashes, typically separating the agent name and ID
const subAgentPromptId = 'codebase_investigator#12345';
config.setLatestApiRequest = vi.fn();
async function* createAsyncGenerator() {
yield { candidates: [] } as unknown as GenerateContentResponse;
}
vi.mocked(wrapped.generateContentStream).mockResolvedValue(
createAsyncGenerator(),
);
await loggingContentGenerator.generateContentStream(
req,
subAgentPromptId,
);
expect(config.setLatestApiRequest).not.toHaveBeenCalled();
});
});
describe('getWrapped', () => {

View File

@@ -258,6 +258,13 @@ export class LoggingContentGenerator implements ContentGenerator {
req,
'generateContentStream',
);
// For debugging: Capture the latest main agent request payload.
// Main agent prompt IDs end with exactly 8 hashes and a turn counter (e.g. "...########1")
if (/########\d+$/.test(userPromptId)) {
this.config.setLatestApiRequest(req);
}
this.logApiRequest(
toContents(req.contents),
req.model,

View File

@@ -89,6 +89,8 @@ export * from './utils/extensionLoader.js';
export * from './utils/package.js';
export * from './utils/version.js';
export * from './utils/checkpointUtils.js';
export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js';
// Export services
export * from './services/fileDiscoveryService.js';

View File

@@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { convertToRestPayload } from './apiConversionUtils.js';
import type { GenerateContentParameters } from '@google/genai';
import {
FunctionCallingConfigMode,
HarmCategory,
HarmBlockThreshold,
} from '@google/genai';
describe('apiConversionUtils', () => {
describe('convertToRestPayload', () => {
it('handles minimal requests with no config', () => {
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
};
const result = convertToRestPayload(req);
expect(result).toStrictEqual({
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
});
expect(result['generationConfig']).toBeUndefined();
});
it('normalizes string systemInstruction to REST format', () => {
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
config: {
systemInstruction: 'You are a helpful assistant.',
},
};
const result = convertToRestPayload(req);
expect(result['systemInstruction']).toStrictEqual({
parts: [{ text: 'You are a helpful assistant.' }],
});
expect(result['generationConfig']).toBeUndefined();
});
it('preserves object-based systemInstruction', () => {
const sysInstruction = { parts: [{ text: 'Object instruction' }] };
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
config: {
systemInstruction: sysInstruction,
},
};
const result = convertToRestPayload(req);
expect(result['systemInstruction']).toStrictEqual(sysInstruction);
});
it('hoists capabilities (tools, safety, cachedContent) to the root level', () => {
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
config: {
tools: [{ functionDeclarations: [{ name: 'myTool' }] }],
toolConfig: {
functionCallingConfig: { mode: FunctionCallingConfigMode.ANY },
},
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold: HarmBlockThreshold.BLOCK_NONE,
},
],
cachedContent: 'cached-content-id',
},
};
const result = convertToRestPayload(req);
expect(result['tools']).toBeDefined();
expect(result['toolConfig']).toBeDefined();
expect(result['safetySettings']).toBeDefined();
expect(result['cachedContent']).toBe('cached-content-id');
// generationConfig should be omitted since no pure hyperparameters were passed
expect(result['generationConfig']).toBeUndefined();
});
it('retains pure hyperparameters in generationConfig', () => {
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
config: {
temperature: 0.7,
topP: 0.9,
maxOutputTokens: 100,
},
};
const result = convertToRestPayload(req);
expect(result['generationConfig']).toStrictEqual({
temperature: 0.7,
topP: 0.9,
maxOutputTokens: 100,
});
});
it('strips JS-specific abortSignal from the final payload', () => {
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
config: {
temperature: 0.5,
abortSignal: new AbortController().signal,
},
};
const result = convertToRestPayload(req);
expect(result['generationConfig']).toStrictEqual({
temperature: 0.5,
});
expect(result['abortSignal']).toBeUndefined();
// @ts-expect-error Checking that the key doesn't exist inside generationConfig
expect(result['generationConfig']?.abortSignal).toBeUndefined();
});
it('handles a complex kitchen-sink request correctly', () => {
const req: GenerateContentParameters = {
model: 'gemini-3-flash',
contents: [{ role: 'user', parts: [{ text: 'Kitchen sink' }] }],
config: {
systemInstruction: 'Be witty.',
temperature: 0.8,
tools: [{ functionDeclarations: [{ name: 'test' }] }],
abortSignal: new AbortController().signal,
safetySettings: [
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
},
],
topK: 40,
},
};
const result = convertToRestPayload(req);
// Root level checks
expect(result['contents']).toBeDefined();
expect(result['systemInstruction']).toStrictEqual({
parts: [{ text: 'Be witty.' }],
});
expect(result['tools']).toStrictEqual([
{ functionDeclarations: [{ name: 'test' }] },
]);
expect(result['safetySettings']).toStrictEqual([
{
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold: HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
},
]);
expect(result['abortSignal']).toBeUndefined();
// Generation config checks
expect(result['generationConfig']).toStrictEqual({
temperature: 0.8,
topK: 40,
});
});
});
});

View File

@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentParameters } from '@google/genai';
/**
* Transforms a standard SDK GenerateContentParameters object into the
* equivalent REST API payload format. This is primarily used for debugging
* and exporting requests.
*/
export function convertToRestPayload(
req: GenerateContentParameters,
): Record<string, unknown> {
// Extract top-level REST fields from the SDK config object.
// 'pureGenerationConfig' will capture any remaining hyperparameters (e.g., temperature, topP).
const {
systemInstruction: sdkSystemInstruction,
tools: sdkTools,
toolConfig: sdkToolConfig,
safetySettings: sdkSafetySettings,
cachedContent: sdkCachedContent,
abortSignal: _sdkAbortSignal, // Exclude JS-specific abort controller
...pureGenerationConfig
} = req.config || {};
// Normalize systemInstruction to the expected REST Content format.
let restSystemInstruction;
if (typeof sdkSystemInstruction === 'string') {
restSystemInstruction = {
parts: [{ text: sdkSystemInstruction }],
};
} else if (sdkSystemInstruction !== undefined) {
restSystemInstruction = sdkSystemInstruction;
}
const restPayload: Record<string, unknown> = {
contents: req.contents,
};
// Only include generationConfig if actual hyperparameters exist.
if (Object.keys(pureGenerationConfig).length > 0) {
restPayload['generationConfig'] = pureGenerationConfig;
}
// Assign extracted capabilities to the root level.
if (restSystemInstruction)
restPayload['systemInstruction'] = restSystemInstruction;
if (sdkTools) restPayload['tools'] = sdkTools;
if (sdkToolConfig) restPayload['toolConfig'] = sdkToolConfig;
if (sdkSafetySettings) restPayload['safetySettings'] = sdkSafetySettings;
if (sdkCachedContent) restPayload['cachedContent'] = sdkCachedContent;
return restPayload;
}

102
scripts/send_gemini_request.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/bash
# -----------------------------------------------------------------------------
# Gemini API Replay Script
# -----------------------------------------------------------------------------
# Purpose:
# This script is used to replay a Gemini API request using a raw JSON payload.
# It is particularly useful for debugging the exact requests made by the
# Gemini CLI.
#
# Prerequisites:
# 1. Export your Gemini API key:
# export GEMINI_API_KEY="your_api_key_here"
#
# 2. Generate a request payload from the Gemini CLI:
# Inside the CLI, run the `/chat debug` command. This will save the most
# recent API request to a file named `gcli-request-<timestamp>.json`.
#
# Usage:
# ./scripts/send_gemini_request.sh --payload <path_to_json> --model <model_id> [--stream]
#
# Options:
# --payload <file> Path to the JSON request payload.
# --model <id> The Gemini model ID (e.g., gemini-3-flash-preview).
# --stream (Optional) Use the streaming API endpoint. Defaults to non-streaming.
#
# Example:
# ./scripts/send_gemini_request.sh --payload gcli-request.json --model gemini-3-flash-preview
# -----------------------------------------------------------------------------
set -e -E
# Load environment variables from .env if it exists
if [ -f ".env" ]; then
echo "Loading environment variables from .env file..."
set -a # Automatically export all variables
source .env
set +a
fi
# Function to print usage
usage() {
echo "Usage: $0 --payload <path_to_json_file> --model <model_id> [--stream]"
echo "Ensure GEMINI_API_KEY environment variable is set."
exit 1
}
STREAM_MODE=false
# Parse command line arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--payload) PAYLOAD_FILE="$2"; shift ;;
--model) MODEL_ID="$2"; shift ;;
--stream) STREAM_MODE=true ;;
*) echo "Unknown parameter passed: $1"; usage ;;
esac
shift
done
# Validate inputs
if [ -z "$PAYLOAD_FILE" ] || [ -z "$MODEL_ID" ]; then
echo "Error: Missing required arguments."
usage
fi
if [ -z "$GEMINI_API_KEY" ]; then
echo "Error: GEMINI_API_KEY environment variable is not set."
exit 1
fi
if [ ! -f "$PAYLOAD_FILE" ]; then
echo "Error: Payload file '$PAYLOAD_FILE' does not exist."
exit 1
fi
# API Endpoint definition
if [ "$STREAM_MODE" = true ]; then
GENERATE_CONTENT_API="streamGenerateContent"
echo "Mode: Streaming"
else
GENERATE_CONTENT_API="generateContent"
echo "Mode: Non-streaming (Default)"
fi
echo "Sending request to model: $MODEL_ID"
echo "Using payload from: $PAYLOAD_FILE"
echo "----------------------------------------"
# Make the cURL request. If non-streaming, pipe through jq for readability if available.
if [ "$STREAM_MODE" = false ] && command -v jq &> /dev/null; then
curl -s -X POST \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
-d "@${PAYLOAD_FILE}" | jq .
else
curl -X POST \
-H "Content-Type: application/json" \
"https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:${GENERATE_CONTENT_API}?key=${GEMINI_API_KEY}" \
-d "@${PAYLOAD_FILE}"
fi
echo -e "\n----------------------------------------"